@bernierllc/content-storage-adapter 1.0.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/.eslintrc.json +22 -0
- package/README.md +350 -0
- package/__tests__/ContentBatchOperations.test.ts +130 -0
- package/__tests__/ContentStorageAdapter.test.ts +604 -0
- package/__tests__/mocks/MockDatabaseAdapter.test.ts +97 -0
- package/__tests__/mocks/MockDatabaseAdapter.ts +85 -0
- package/dist/ContentBatchOperations.d.ts +22 -0
- package/dist/ContentBatchOperations.d.ts.map +1 -0
- package/dist/ContentBatchOperations.js +60 -0
- package/dist/ContentBatchOperations.js.map +1 -0
- package/dist/ContentStorageAdapter.d.ts +75 -0
- package/dist/ContentStorageAdapter.d.ts.map +1 -0
- package/dist/ContentStorageAdapter.js +382 -0
- package/dist/ContentStorageAdapter.js.map +1 -0
- package/dist/errors/ContentNotFoundError.d.ts +7 -0
- package/dist/errors/ContentNotFoundError.d.ts.map +1 -0
- package/dist/errors/ContentNotFoundError.js +21 -0
- package/dist/errors/ContentNotFoundError.js.map +1 -0
- package/dist/errors/DuplicateSlugError.d.ts +7 -0
- package/dist/errors/DuplicateSlugError.d.ts.map +1 -0
- package/dist/errors/DuplicateSlugError.js +21 -0
- package/dist/errors/DuplicateSlugError.js.map +1 -0
- package/dist/errors/OptimisticLockError.d.ts +7 -0
- package/dist/errors/OptimisticLockError.d.ts.map +1 -0
- package/dist/errors/OptimisticLockError.js +21 -0
- package/dist/errors/OptimisticLockError.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/models/IContent.d.ts +92 -0
- package/dist/models/IContent.d.ts.map +1 -0
- package/dist/models/IContent.js +42 -0
- package/dist/models/IContent.js.map +1 -0
- package/dist/models/IContentFilters.d.ts +29 -0
- package/dist/models/IContentFilters.d.ts.map +1 -0
- package/dist/models/IContentFilters.js +10 -0
- package/dist/models/IContentFilters.js.map +1 -0
- package/dist/models/IContentVersion.d.ts +15 -0
- package/dist/models/IContentVersion.d.ts.map +1 -0
- package/dist/models/IContentVersion.js +10 -0
- package/dist/models/IContentVersion.js.map +1 -0
- package/jest.config.cjs +30 -0
- package/package.json +38 -0
- package/src/ContentBatchOperations.ts +72 -0
- package/src/ContentStorageAdapter.ts +476 -0
- package/src/errors/ContentNotFoundError.ts +17 -0
- package/src/errors/DuplicateSlugError.ts +17 -0
- package/src/errors/OptimisticLockError.ts +17 -0
- package/src/index.ts +33 -0
- package/src/models/IContent.ts +122 -0
- package/src/models/IContentFilters.ts +38 -0
- package/src/models/IContentVersion.ts +22 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ContentStorageAdapter } from '../src/ContentStorageAdapter';
|
|
10
|
+
import { ContentType, ContentFormat, ContentStatus } from '../src/models/IContent';
|
|
11
|
+
import { ContentNotFoundError } from '../src/errors/ContentNotFoundError';
|
|
12
|
+
import { OptimisticLockError } from '../src/errors/OptimisticLockError';
|
|
13
|
+
import { MockDatabaseAdapter } from './mocks/MockDatabaseAdapter';
|
|
14
|
+
import { IDatabaseAdapter } from '@bernierllc/database-adapter-core';
|
|
15
|
+
|
|
16
|
+
describe('ContentStorageAdapter', () => {
|
|
17
|
+
let adapter: ContentStorageAdapter;
|
|
18
|
+
let dbAdapter: MockDatabaseAdapter;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
dbAdapter = new MockDatabaseAdapter();
|
|
22
|
+
await dbAdapter.connect();
|
|
23
|
+
adapter = new ContentStorageAdapter(dbAdapter as unknown as IDatabaseAdapter);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await dbAdapter.disconnect();
|
|
28
|
+
dbAdapter.clearMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('create', () => {
|
|
32
|
+
it('should create content with auto-generated slug', async () => {
|
|
33
|
+
const mockContent = {
|
|
34
|
+
id: '123',
|
|
35
|
+
user_id: 'user1',
|
|
36
|
+
title: 'Test Title',
|
|
37
|
+
slug: 'test-title',
|
|
38
|
+
body: 'Test body',
|
|
39
|
+
excerpt: 'Test excerpt',
|
|
40
|
+
type: ContentType.BLOG,
|
|
41
|
+
format: ContentFormat.MARKDOWN,
|
|
42
|
+
status: ContentStatus.DRAFT,
|
|
43
|
+
ai_reviewed: false,
|
|
44
|
+
view_count: 0,
|
|
45
|
+
share_count: 0,
|
|
46
|
+
generated_from_commit: false,
|
|
47
|
+
created_at: new Date(),
|
|
48
|
+
updated_at: new Date()
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Mock slug existence check (doesn't exist)
|
|
52
|
+
dbAdapter.setQueryResult(
|
|
53
|
+
'SELECT EXISTS(SELECT 1 FROM content WHERE slug = $1 AND deleted_at IS NULL) as exists',
|
|
54
|
+
{ rows: [{ exists: false }], rowCount: 1 }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Mock insert
|
|
58
|
+
dbAdapter.setQueryResult(
|
|
59
|
+
'INSERT INTO content',
|
|
60
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const result = await adapter.create({
|
|
64
|
+
userId: 'user1',
|
|
65
|
+
title: 'Test Title',
|
|
66
|
+
body: 'Test body',
|
|
67
|
+
excerpt: 'Test excerpt'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.id).toBe('123');
|
|
71
|
+
expect(result.title).toBe('Test Title');
|
|
72
|
+
expect(result.slug).toBe('test-title');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should ensure unique slugs', async () => {
|
|
76
|
+
const mockContent = {
|
|
77
|
+
id: '123',
|
|
78
|
+
user_id: 'user1',
|
|
79
|
+
title: 'Test Title',
|
|
80
|
+
slug: 'test-title-1',
|
|
81
|
+
body: 'Test body',
|
|
82
|
+
type: ContentType.BLOG,
|
|
83
|
+
format: ContentFormat.MARKDOWN,
|
|
84
|
+
status: ContentStatus.DRAFT,
|
|
85
|
+
ai_reviewed: false,
|
|
86
|
+
view_count: 0,
|
|
87
|
+
share_count: 0,
|
|
88
|
+
generated_from_commit: false,
|
|
89
|
+
created_at: new Date(),
|
|
90
|
+
updated_at: new Date()
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// First slug check returns true (exists)
|
|
94
|
+
dbAdapter.setQueryResult(
|
|
95
|
+
'SELECT EXISTS(SELECT 1 FROM content WHERE slug = $1 AND deleted_at IS NULL) as exists',
|
|
96
|
+
{ rows: [{ exists: true }], rowCount: 1 }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Mock insert
|
|
100
|
+
dbAdapter.setQueryResult(
|
|
101
|
+
'INSERT INTO content',
|
|
102
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const result = await adapter.create({
|
|
106
|
+
userId: 'user1',
|
|
107
|
+
title: 'Test Title',
|
|
108
|
+
body: 'Test body'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.slug).toMatch(/test-title-\d+/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should set default values for type/format/status', async () => {
|
|
115
|
+
const mockContent = {
|
|
116
|
+
id: '123',
|
|
117
|
+
user_id: 'user1',
|
|
118
|
+
title: 'Test Title',
|
|
119
|
+
slug: 'test-title',
|
|
120
|
+
body: 'Test body',
|
|
121
|
+
type: ContentType.BLOG,
|
|
122
|
+
format: ContentFormat.MARKDOWN,
|
|
123
|
+
status: ContentStatus.DRAFT,
|
|
124
|
+
ai_reviewed: false,
|
|
125
|
+
view_count: 0,
|
|
126
|
+
share_count: 0,
|
|
127
|
+
generated_from_commit: false,
|
|
128
|
+
created_at: new Date(),
|
|
129
|
+
updated_at: new Date()
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
dbAdapter.setQueryResult(
|
|
133
|
+
'SELECT EXISTS(SELECT 1 FROM content WHERE slug = $1 AND deleted_at IS NULL) as exists',
|
|
134
|
+
{ rows: [{ exists: false }], rowCount: 1 }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
dbAdapter.setQueryResult(
|
|
138
|
+
'INSERT INTO content',
|
|
139
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const result = await adapter.create({
|
|
143
|
+
userId: 'user1',
|
|
144
|
+
title: 'Test Title',
|
|
145
|
+
body: 'Test body'
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.type).toBe(ContentType.BLOG);
|
|
149
|
+
expect(result.format).toBe(ContentFormat.MARKDOWN);
|
|
150
|
+
expect(result.status).toBe(ContentStatus.DRAFT);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('getById', () => {
|
|
155
|
+
it('should get content by ID', async () => {
|
|
156
|
+
const mockContent = {
|
|
157
|
+
id: '123',
|
|
158
|
+
user_id: 'user1',
|
|
159
|
+
title: 'Test Title',
|
|
160
|
+
slug: 'test-title',
|
|
161
|
+
body: 'Test body',
|
|
162
|
+
type: ContentType.BLOG,
|
|
163
|
+
format: ContentFormat.MARKDOWN,
|
|
164
|
+
status: ContentStatus.DRAFT,
|
|
165
|
+
ai_reviewed: false,
|
|
166
|
+
view_count: 0,
|
|
167
|
+
share_count: 0,
|
|
168
|
+
generated_from_commit: false,
|
|
169
|
+
created_at: new Date(),
|
|
170
|
+
updated_at: new Date(),
|
|
171
|
+
deleted_at: null
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
dbAdapter.setQueryResult(
|
|
175
|
+
'SELECT * FROM content WHERE id = $1 AND deleted_at IS NULL',
|
|
176
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const result = await adapter.getById('123');
|
|
180
|
+
|
|
181
|
+
expect(result).not.toBeNull();
|
|
182
|
+
expect(result?.id).toBe('123');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should exclude soft-deleted content by default', async () => {
|
|
186
|
+
dbAdapter.setQueryResult(
|
|
187
|
+
'SELECT * FROM content WHERE id = $1 AND deleted_at IS NULL',
|
|
188
|
+
{ rows: [], rowCount: 0 }
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const result = await adapter.getById('123');
|
|
192
|
+
|
|
193
|
+
expect(result).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should include deleted content when requested', async () => {
|
|
197
|
+
const mockContent = {
|
|
198
|
+
id: '123',
|
|
199
|
+
user_id: 'user1',
|
|
200
|
+
title: 'Test Title',
|
|
201
|
+
slug: 'test-title',
|
|
202
|
+
body: 'Test body',
|
|
203
|
+
type: ContentType.BLOG,
|
|
204
|
+
format: ContentFormat.MARKDOWN,
|
|
205
|
+
status: ContentStatus.DRAFT,
|
|
206
|
+
ai_reviewed: false,
|
|
207
|
+
view_count: 0,
|
|
208
|
+
share_count: 0,
|
|
209
|
+
generated_from_commit: false,
|
|
210
|
+
created_at: new Date(),
|
|
211
|
+
updated_at: new Date(),
|
|
212
|
+
deleted_at: new Date()
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
dbAdapter.setQueryResult(
|
|
216
|
+
'SELECT * FROM content WHERE id = $1',
|
|
217
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const result = await adapter.getById('123', { includeDeleted: true });
|
|
221
|
+
|
|
222
|
+
expect(result).not.toBeNull();
|
|
223
|
+
expect(result?.deletedAt).toBeDefined();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('getBySlug', () => {
|
|
228
|
+
it('should get content by slug', async () => {
|
|
229
|
+
const mockContent = {
|
|
230
|
+
id: '123',
|
|
231
|
+
user_id: 'user1',
|
|
232
|
+
title: 'Test Title',
|
|
233
|
+
slug: 'test-title',
|
|
234
|
+
body: 'Test body',
|
|
235
|
+
type: ContentType.BLOG,
|
|
236
|
+
format: ContentFormat.MARKDOWN,
|
|
237
|
+
status: ContentStatus.DRAFT,
|
|
238
|
+
ai_reviewed: false,
|
|
239
|
+
view_count: 0,
|
|
240
|
+
share_count: 0,
|
|
241
|
+
generated_from_commit: false,
|
|
242
|
+
created_at: new Date(),
|
|
243
|
+
updated_at: new Date()
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
dbAdapter.setQueryResult(
|
|
247
|
+
'SELECT * FROM content WHERE slug = $1 AND deleted_at IS NULL',
|
|
248
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const result = await adapter.getBySlug('test-title');
|
|
252
|
+
|
|
253
|
+
expect(result).not.toBeNull();
|
|
254
|
+
expect(result?.slug).toBe('test-title');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('update', () => {
|
|
259
|
+
it('should update content fields', async () => {
|
|
260
|
+
const mockContent = {
|
|
261
|
+
id: '123',
|
|
262
|
+
user_id: 'user1',
|
|
263
|
+
title: 'Updated Title',
|
|
264
|
+
slug: 'test-title',
|
|
265
|
+
body: 'Updated body',
|
|
266
|
+
type: ContentType.BLOG,
|
|
267
|
+
format: ContentFormat.MARKDOWN,
|
|
268
|
+
status: ContentStatus.IN_REVIEW,
|
|
269
|
+
ai_reviewed: false,
|
|
270
|
+
view_count: 0,
|
|
271
|
+
share_count: 0,
|
|
272
|
+
generated_from_commit: false,
|
|
273
|
+
created_at: new Date(),
|
|
274
|
+
updated_at: new Date()
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
dbAdapter.setQueryResult(
|
|
278
|
+
'UPDATE content',
|
|
279
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const result = await adapter.update('123', {
|
|
283
|
+
title: 'Updated Title',
|
|
284
|
+
body: 'Updated body',
|
|
285
|
+
status: ContentStatus.IN_REVIEW
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.title).toBe('Updated Title');
|
|
289
|
+
expect(result.status).toBe(ContentStatus.IN_REVIEW);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should update timestamps automatically', async () => {
|
|
293
|
+
const now = new Date();
|
|
294
|
+
const mockContent = {
|
|
295
|
+
id: '123',
|
|
296
|
+
user_id: 'user1',
|
|
297
|
+
title: 'Test Title',
|
|
298
|
+
slug: 'test-title',
|
|
299
|
+
body: 'Test body',
|
|
300
|
+
type: ContentType.BLOG,
|
|
301
|
+
format: ContentFormat.MARKDOWN,
|
|
302
|
+
status: ContentStatus.DRAFT,
|
|
303
|
+
ai_reviewed: false,
|
|
304
|
+
view_count: 0,
|
|
305
|
+
share_count: 0,
|
|
306
|
+
generated_from_commit: false,
|
|
307
|
+
created_at: new Date(now.getTime() - 10000),
|
|
308
|
+
updated_at: now
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
dbAdapter.setQueryResult(
|
|
312
|
+
'UPDATE content',
|
|
313
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const result = await adapter.update('123', { title: 'Updated' });
|
|
317
|
+
|
|
318
|
+
expect(result.updatedAt).toBeDefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should enforce optimistic locking', async () => {
|
|
322
|
+
dbAdapter.setQueryResult(
|
|
323
|
+
'UPDATE content',
|
|
324
|
+
{ rows: [], rowCount: 0 }
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const expectedUpdatedAt = new Date();
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
adapter.update('123', { title: 'Updated' }, expectedUpdatedAt)
|
|
331
|
+
).rejects.toThrow(OptimisticLockError);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should throw error on version conflict', async () => {
|
|
335
|
+
dbAdapter.setQueryResult(
|
|
336
|
+
'UPDATE content',
|
|
337
|
+
{ rows: [], rowCount: 0 }
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await expect(
|
|
341
|
+
adapter.update('123', { title: 'Updated' }, new Date())
|
|
342
|
+
).rejects.toThrow(OptimisticLockError);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should throw error when no fields to update', async () => {
|
|
346
|
+
await expect(
|
|
347
|
+
adapter.update('123', {})
|
|
348
|
+
).rejects.toThrow('No fields to update');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('delete', () => {
|
|
353
|
+
it('should soft delete content', async () => {
|
|
354
|
+
dbAdapter.setQueryResult(
|
|
355
|
+
'UPDATE content SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL',
|
|
356
|
+
{ rows: [], rowCount: 1 }
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await expect(adapter.delete('123')).resolves.not.toThrow();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should not return deleted content in queries', async () => {
|
|
363
|
+
dbAdapter.setQueryResult(
|
|
364
|
+
'SELECT * FROM content WHERE id = $1 AND deleted_at IS NULL',
|
|
365
|
+
{ rows: [], rowCount: 0 }
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const result = await adapter.getById('123');
|
|
369
|
+
expect(result).toBeNull();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should throw error when content not found', async () => {
|
|
373
|
+
dbAdapter.setQueryResult(
|
|
374
|
+
'UPDATE content SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL',
|
|
375
|
+
{ rows: [], rowCount: 0 }
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
await expect(adapter.delete('999')).rejects.toThrow(ContentNotFoundError);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('restore', () => {
|
|
383
|
+
it('should restore deleted content', async () => {
|
|
384
|
+
const mockContent = {
|
|
385
|
+
id: '123',
|
|
386
|
+
user_id: 'user1',
|
|
387
|
+
title: 'Test Title',
|
|
388
|
+
slug: 'test-title',
|
|
389
|
+
body: 'Test body',
|
|
390
|
+
type: ContentType.BLOG,
|
|
391
|
+
format: ContentFormat.MARKDOWN,
|
|
392
|
+
status: ContentStatus.DRAFT,
|
|
393
|
+
ai_reviewed: false,
|
|
394
|
+
view_count: 0,
|
|
395
|
+
share_count: 0,
|
|
396
|
+
generated_from_commit: false,
|
|
397
|
+
created_at: new Date(),
|
|
398
|
+
updated_at: new Date(),
|
|
399
|
+
deleted_at: null
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
dbAdapter.setQueryResult(
|
|
403
|
+
'UPDATE content SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL RETURNING *',
|
|
404
|
+
{ rows: [mockContent], rowCount: 1 }
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const result = await adapter.restore('123');
|
|
408
|
+
|
|
409
|
+
expect(result.id).toBe('123');
|
|
410
|
+
expect(result.deletedAt).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('hardDelete', () => {
|
|
415
|
+
it('should permanently delete content', async () => {
|
|
416
|
+
dbAdapter.setQueryResult(
|
|
417
|
+
'DELETE FROM content WHERE id = $1',
|
|
418
|
+
{ rows: [], rowCount: 1 }
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await expect(adapter.hardDelete('123')).resolves.not.toThrow();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('list', () => {
|
|
426
|
+
it('should filter by user ID', async () => {
|
|
427
|
+
const mockContents = [
|
|
428
|
+
{
|
|
429
|
+
id: '123',
|
|
430
|
+
user_id: 'user1',
|
|
431
|
+
title: 'Test 1',
|
|
432
|
+
slug: 'test-1',
|
|
433
|
+
body: 'Body 1',
|
|
434
|
+
type: ContentType.BLOG,
|
|
435
|
+
format: ContentFormat.MARKDOWN,
|
|
436
|
+
status: ContentStatus.DRAFT,
|
|
437
|
+
ai_reviewed: false,
|
|
438
|
+
view_count: 0,
|
|
439
|
+
share_count: 0,
|
|
440
|
+
generated_from_commit: false,
|
|
441
|
+
created_at: new Date(),
|
|
442
|
+
updated_at: new Date()
|
|
443
|
+
}
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
dbAdapter.setQueryResult(
|
|
447
|
+
'SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL AND user_id = $1',
|
|
448
|
+
{ rows: [{ count: '1' }], rowCount: 1 }
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
dbAdapter.setQueryResult(
|
|
452
|
+
'SELECT * FROM content WHERE deleted_at IS NULL AND user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3',
|
|
453
|
+
{ rows: mockContents, rowCount: 1 }
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const result = await adapter.list({ userId: 'user1' });
|
|
457
|
+
|
|
458
|
+
expect(result.data.length).toBe(1);
|
|
459
|
+
expect(result.total).toBe(1);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should filter by status', async () => {
|
|
463
|
+
dbAdapter.setQueryResult(
|
|
464
|
+
'SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL AND status = $1',
|
|
465
|
+
{ rows: [{ count: '0' }], rowCount: 1 }
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
dbAdapter.setQueryResult(
|
|
469
|
+
'SELECT * FROM content WHERE deleted_at IS NULL AND status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3',
|
|
470
|
+
{ rows: [], rowCount: 0 }
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const result = await adapter.list({ status: ContentStatus.PUBLISHED });
|
|
474
|
+
|
|
475
|
+
expect(result.data.length).toBe(0);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should support pagination', async () => {
|
|
479
|
+
dbAdapter.setQueryResult(
|
|
480
|
+
'SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL',
|
|
481
|
+
{ rows: [{ count: '100' }], rowCount: 1 }
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
dbAdapter.setQueryResult(
|
|
485
|
+
'SELECT * FROM content WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2',
|
|
486
|
+
{ rows: [], rowCount: 0 }
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const result = await adapter.list({ limit: 10, offset: 20 });
|
|
490
|
+
|
|
491
|
+
expect(result.limit).toBe(10);
|
|
492
|
+
expect(result.offset).toBe(20);
|
|
493
|
+
expect(result.hasMore).toBe(true);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should order by specified field', async () => {
|
|
497
|
+
dbAdapter.setQueryResult(
|
|
498
|
+
'SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL',
|
|
499
|
+
{ rows: [{ count: '0' }], rowCount: 1 }
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
dbAdapter.setQueryResult(
|
|
503
|
+
'SELECT * FROM content WHERE deleted_at IS NULL ORDER BY title ASC LIMIT $1 OFFSET $2',
|
|
504
|
+
{ rows: [], rowCount: 0 }
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const result = await adapter.list({
|
|
508
|
+
orderBy: 'title',
|
|
509
|
+
orderDirection: 'ASC'
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(result.data).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should search content text', async () => {
|
|
516
|
+
dbAdapter.setQueryResult(
|
|
517
|
+
'SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL',
|
|
518
|
+
{ rows: [{ count: '0' }], rowCount: 1 }
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
dbAdapter.setQueryResult(
|
|
522
|
+
'SELECT * FROM content WHERE deleted_at IS NULL',
|
|
523
|
+
{ rows: [], rowCount: 0 }
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const result = await adapter.list({ searchQuery: 'test' });
|
|
527
|
+
|
|
528
|
+
expect(result.data).toEqual([]);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('getVersions', () => {
|
|
533
|
+
it('should retrieve all versions', async () => {
|
|
534
|
+
const mockVersions = [
|
|
535
|
+
{
|
|
536
|
+
id: 'v1',
|
|
537
|
+
content_id: '123',
|
|
538
|
+
version_number: 2,
|
|
539
|
+
title: 'Version 2',
|
|
540
|
+
body: 'Body v2',
|
|
541
|
+
is_autosave: false,
|
|
542
|
+
created_at: new Date(),
|
|
543
|
+
created_by: 'user1'
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: 'v2',
|
|
547
|
+
content_id: '123',
|
|
548
|
+
version_number: 1,
|
|
549
|
+
title: 'Version 1',
|
|
550
|
+
body: 'Body v1',
|
|
551
|
+
is_autosave: false,
|
|
552
|
+
created_at: new Date(),
|
|
553
|
+
created_by: 'user1'
|
|
554
|
+
}
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
dbAdapter.setQueryResult(
|
|
558
|
+
'SELECT * FROM content_versions WHERE content_id = $1 ORDER BY version_number DESC',
|
|
559
|
+
{ rows: mockVersions, rowCount: 2 }
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const result = await adapter.getVersions('123');
|
|
563
|
+
|
|
564
|
+
expect(result.length).toBe(2);
|
|
565
|
+
expect(result[0].versionNumber).toBe(2);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should order versions by number descending', async () => {
|
|
569
|
+
dbAdapter.setQueryResult(
|
|
570
|
+
'SELECT * FROM content_versions WHERE content_id = $1 ORDER BY version_number DESC',
|
|
571
|
+
{ rows: [], rowCount: 0 }
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const result = await adapter.getVersions('123');
|
|
575
|
+
|
|
576
|
+
expect(result).toEqual([]);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe('getVersion', () => {
|
|
581
|
+
it('should retrieve specific version', async () => {
|
|
582
|
+
const mockVersion = {
|
|
583
|
+
id: 'v1',
|
|
584
|
+
content_id: '123',
|
|
585
|
+
version_number: 1,
|
|
586
|
+
title: 'Version 1',
|
|
587
|
+
body: 'Body v1',
|
|
588
|
+
is_autosave: false,
|
|
589
|
+
created_at: new Date(),
|
|
590
|
+
created_by: 'user1'
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
dbAdapter.setQueryResult(
|
|
594
|
+
'SELECT * FROM content_versions WHERE content_id = $1 AND version_number = $2',
|
|
595
|
+
{ rows: [mockVersion], rowCount: 1 }
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const result = await adapter.getVersion('123', 1);
|
|
599
|
+
|
|
600
|
+
expect(result).not.toBeNull();
|
|
601
|
+
expect(result?.versionNumber).toBe(1);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MockDatabaseAdapter } from './MockDatabaseAdapter';
|
|
10
|
+
|
|
11
|
+
describe('MockDatabaseAdapter validation', () => {
|
|
12
|
+
let adapter: MockDatabaseAdapter;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
adapter = new MockDatabaseAdapter();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
adapter.clearMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should implement IDatabaseAdapter interface', () => {
|
|
23
|
+
expect(adapter.connect).toBeDefined();
|
|
24
|
+
expect(adapter.disconnect).toBeDefined();
|
|
25
|
+
expect(adapter.query).toBeDefined();
|
|
26
|
+
expect(adapter.beginTransaction).toBeDefined();
|
|
27
|
+
expect(adapter.commit).toBeDefined();
|
|
28
|
+
expect(adapter.rollback).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should track connection state', async () => {
|
|
32
|
+
await adapter.connect();
|
|
33
|
+
// Connection is established, queries should work
|
|
34
|
+
const result = await adapter.query('SELECT 1');
|
|
35
|
+
expect(result).toBeDefined();
|
|
36
|
+
|
|
37
|
+
await adapter.disconnect();
|
|
38
|
+
// After disconnect, should throw error
|
|
39
|
+
await expect(adapter.query('SELECT 1')).rejects.toThrow('Database not connected');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return mocked query results', async () => {
|
|
43
|
+
await adapter.connect();
|
|
44
|
+
|
|
45
|
+
adapter.setQueryResult('SELECT * FROM test', {
|
|
46
|
+
rows: [{ id: 1, name: 'test' }],
|
|
47
|
+
rowCount: 1
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await adapter.query('SELECT * FROM test');
|
|
51
|
+
expect(result.rows).toEqual([{ id: 1, name: 'test' }]);
|
|
52
|
+
expect(result.rowCount).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should normalize SQL whitespace for matching', async () => {
|
|
56
|
+
await adapter.connect();
|
|
57
|
+
|
|
58
|
+
adapter.setQueryResult('SELECT * FROM test', {
|
|
59
|
+
rows: [{ id: 1 }],
|
|
60
|
+
rowCount: 1
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await adapter.query('SELECT * FROM test');
|
|
64
|
+
expect(result.rows).toEqual([{ id: 1 }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should clear mocks when requested', async () => {
|
|
68
|
+
await adapter.connect();
|
|
69
|
+
|
|
70
|
+
adapter.setQueryResult('SELECT * FROM test', {
|
|
71
|
+
rows: [{ id: 1 }],
|
|
72
|
+
rowCount: 1
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
adapter.clearMocks();
|
|
76
|
+
|
|
77
|
+
const result = await adapter.query('SELECT * FROM test');
|
|
78
|
+
expect(result.rows).toEqual([]);
|
|
79
|
+
expect(result.rowCount).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should return empty result for unregistered queries', async () => {
|
|
83
|
+
await adapter.connect();
|
|
84
|
+
|
|
85
|
+
const result = await adapter.query('SELECT * FROM unknown');
|
|
86
|
+
expect(result.rows).toEqual([]);
|
|
87
|
+
expect(result.rowCount).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle transaction methods', async () => {
|
|
91
|
+
await adapter.connect();
|
|
92
|
+
|
|
93
|
+
await expect(adapter.beginTransaction()).resolves.not.toThrow();
|
|
94
|
+
await expect(adapter.commit()).resolves.not.toThrow();
|
|
95
|
+
await expect(adapter.rollback()).resolves.not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|