@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
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"parser": "@typescript-eslint/parser",
|
|
3
|
+
"extends": [
|
|
4
|
+
"eslint:recommended",
|
|
5
|
+
"plugin:@typescript-eslint/recommended"
|
|
6
|
+
],
|
|
7
|
+
"plugins": ["@typescript-eslint"],
|
|
8
|
+
"parserOptions": {
|
|
9
|
+
"ecmaVersion": 2020,
|
|
10
|
+
"sourceType": "module"
|
|
11
|
+
},
|
|
12
|
+
"rules": {
|
|
13
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
14
|
+
"@typescript-eslint/explicit-function-return-type": "warn",
|
|
15
|
+
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
|
16
|
+
"no-console": "warn"
|
|
17
|
+
},
|
|
18
|
+
"env": {
|
|
19
|
+
"node": true,
|
|
20
|
+
"jest": true
|
|
21
|
+
}
|
|
22
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# @bernierllc/content-storage-adapter
|
|
2
|
+
|
|
3
|
+
Type-safe CRUD operations for content management with automatic soft-delete handling, version tracking, and optimistic locking.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-Safe CRUD Operations** - Fully typed content models with TypeScript strict mode
|
|
8
|
+
- **Automatic Soft-Delete** - All deletes are logical (deleted_at timestamp)
|
|
9
|
+
- **Optimistic Locking** - Concurrent edit detection via updated_at
|
|
10
|
+
- **Version History** - Track content changes over time
|
|
11
|
+
- **Unique Slug Generation** - SEO-friendly URLs with automatic uniqueness
|
|
12
|
+
- **Advanced Filtering** - Search by status, type, date range, and more
|
|
13
|
+
- **Batch Operations** - Bulk update, delete, and restore
|
|
14
|
+
- **Pagination Support** - Efficient list operations with hasMore indicator
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @bernierllc/content-storage-adapter
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Dependencies
|
|
23
|
+
|
|
24
|
+
- `@bernierllc/database-adapter-core` - Abstract database interface
|
|
25
|
+
- `@bernierllc/slug-generator` - SEO-friendly URL generation
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Basic CRUD Operations
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { ContentStorageAdapter, ContentType, ContentStatus } from '@bernierllc/content-storage-adapter';
|
|
33
|
+
import { PostgreSQLAdapter } from '@bernierllc/database-adapter-postgresql';
|
|
34
|
+
|
|
35
|
+
// Initialize database adapter
|
|
36
|
+
const dbAdapter = new PostgreSQLAdapter(config);
|
|
37
|
+
await dbAdapter.connect();
|
|
38
|
+
|
|
39
|
+
// Create storage adapter
|
|
40
|
+
const storage = new ContentStorageAdapter(dbAdapter);
|
|
41
|
+
|
|
42
|
+
// Create content
|
|
43
|
+
const content = await storage.create({
|
|
44
|
+
userId: 'user-123',
|
|
45
|
+
title: 'My First Post',
|
|
46
|
+
body: 'Hello, world!',
|
|
47
|
+
excerpt: 'A brief introduction',
|
|
48
|
+
type: ContentType.BLOG,
|
|
49
|
+
status: ContentStatus.DRAFT
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Read content
|
|
53
|
+
const retrieved = await storage.getById(content.id);
|
|
54
|
+
const bySlug = await storage.getBySlug('my-first-post');
|
|
55
|
+
|
|
56
|
+
// Update content
|
|
57
|
+
const updated = await storage.update(content.id, {
|
|
58
|
+
status: ContentStatus.PUBLISHED,
|
|
59
|
+
publishedAt: new Date()
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Soft delete
|
|
63
|
+
await storage.delete(content.id);
|
|
64
|
+
|
|
65
|
+
// Restore deleted content
|
|
66
|
+
const restored = await storage.restore(content.id);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Optimistic Locking
|
|
70
|
+
|
|
71
|
+
Prevent concurrent edit conflicts:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const content = await storage.getById('content-id');
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Update with expected timestamp
|
|
78
|
+
const updated = await storage.update(
|
|
79
|
+
content.id,
|
|
80
|
+
{ title: 'Updated Title' },
|
|
81
|
+
content.updatedAt
|
|
82
|
+
);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof OptimisticLockError) {
|
|
85
|
+
// Content was modified by another user
|
|
86
|
+
console.error('Conflict detected! Reload and try again.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Advanced Filtering
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { IContentFilters } from '@bernierllc/content-storage-adapter';
|
|
95
|
+
|
|
96
|
+
const filters: IContentFilters = {
|
|
97
|
+
userId: 'user-123',
|
|
98
|
+
status: [ContentStatus.PUBLISHED, ContentStatus.SCHEDULED],
|
|
99
|
+
type: ContentType.BLOG,
|
|
100
|
+
publishedAfter: new Date('2024-01-01'),
|
|
101
|
+
searchQuery: 'TypeScript',
|
|
102
|
+
limit: 20,
|
|
103
|
+
offset: 0,
|
|
104
|
+
orderBy: 'publishedAt',
|
|
105
|
+
orderDirection: 'DESC'
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const result = await storage.list(filters);
|
|
109
|
+
|
|
110
|
+
console.log(`Found ${result.total} items`);
|
|
111
|
+
console.log(`Has more: ${result.hasMore}`);
|
|
112
|
+
result.data.forEach(item => console.log(item.title));
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Batch Operations
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { ContentBatchOperations } from '@bernierllc/content-storage-adapter';
|
|
119
|
+
|
|
120
|
+
const batchOps = new ContentBatchOperations(storage);
|
|
121
|
+
|
|
122
|
+
// Bulk update status
|
|
123
|
+
const updated = await batchOps.bulkUpdateStatus(
|
|
124
|
+
['id1', 'id2', 'id3'],
|
|
125
|
+
ContentStatus.ARCHIVED
|
|
126
|
+
);
|
|
127
|
+
console.log(`Updated ${updated} items`);
|
|
128
|
+
|
|
129
|
+
// Bulk delete
|
|
130
|
+
const deleted = await batchOps.bulkDelete(['id4', 'id5']);
|
|
131
|
+
|
|
132
|
+
// Bulk restore
|
|
133
|
+
const restored = await batchOps.bulkRestore(['id4', 'id5']);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Version History
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// Get all versions
|
|
140
|
+
const versions = await storage.getVersions('content-id');
|
|
141
|
+
versions.forEach(v => {
|
|
142
|
+
console.log(`Version ${v.versionNumber}: ${v.title}`);
|
|
143
|
+
console.log(`Changed by ${v.createdBy} at ${v.createdAt}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Get specific version
|
|
147
|
+
const version2 = await storage.getVersion('content-id', 2);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## API
|
|
151
|
+
|
|
152
|
+
### ContentStorageAdapter
|
|
153
|
+
|
|
154
|
+
#### `create(data: IContentCreateData): Promise<IContent>`
|
|
155
|
+
|
|
156
|
+
Create new content with automatic slug generation.
|
|
157
|
+
|
|
158
|
+
#### `getById(id: string, options?: { includeDeleted?: boolean }): Promise<IContent | null>`
|
|
159
|
+
|
|
160
|
+
Get content by ID. Excludes soft-deleted content by default.
|
|
161
|
+
|
|
162
|
+
#### `getBySlug(slug: string): Promise<IContent | null>`
|
|
163
|
+
|
|
164
|
+
Get content by URL slug.
|
|
165
|
+
|
|
166
|
+
#### `update(id: string, data: IContentUpdateData, expectedUpdatedAt?: Date): Promise<IContent>`
|
|
167
|
+
|
|
168
|
+
Update content. Optional `expectedUpdatedAt` enables optimistic locking.
|
|
169
|
+
|
|
170
|
+
#### `delete(id: string): Promise<void>`
|
|
171
|
+
|
|
172
|
+
Soft delete content (sets deleted_at timestamp).
|
|
173
|
+
|
|
174
|
+
#### `hardDelete(id: string): Promise<void>`
|
|
175
|
+
|
|
176
|
+
Permanently delete content. Use sparingly!
|
|
177
|
+
|
|
178
|
+
#### `restore(id: string): Promise<IContent>`
|
|
179
|
+
|
|
180
|
+
Restore soft-deleted content.
|
|
181
|
+
|
|
182
|
+
#### `list(filters?: IContentFilters): Promise<IPaginatedResult<IContent>>`
|
|
183
|
+
|
|
184
|
+
List content with filtering, pagination, and ordering.
|
|
185
|
+
|
|
186
|
+
#### `getVersions(contentId: string): Promise<IContentVersion[]>`
|
|
187
|
+
|
|
188
|
+
Get all versions of content, ordered by version number descending.
|
|
189
|
+
|
|
190
|
+
#### `getVersion(contentId: string, versionNumber: number): Promise<IContentVersion | null>`
|
|
191
|
+
|
|
192
|
+
Get specific content version.
|
|
193
|
+
|
|
194
|
+
### ContentBatchOperations
|
|
195
|
+
|
|
196
|
+
#### `bulkUpdateStatus(ids: string[], status: ContentStatus): Promise<number>`
|
|
197
|
+
|
|
198
|
+
Update status for multiple content items. Returns count of updated items.
|
|
199
|
+
|
|
200
|
+
#### `bulkDelete(ids: string[]): Promise<number>`
|
|
201
|
+
|
|
202
|
+
Soft delete multiple items. Returns count of deleted items.
|
|
203
|
+
|
|
204
|
+
#### `bulkRestore(ids: string[]): Promise<number>`
|
|
205
|
+
|
|
206
|
+
Restore multiple soft-deleted items. Returns count of restored items.
|
|
207
|
+
|
|
208
|
+
## Types
|
|
209
|
+
|
|
210
|
+
### ContentType
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
enum ContentType {
|
|
214
|
+
BLOG = 'blog',
|
|
215
|
+
ARTICLE = 'article',
|
|
216
|
+
PAGE = 'page',
|
|
217
|
+
SOCIAL = 'social'
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### ContentFormat
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
enum ContentFormat {
|
|
225
|
+
MARKDOWN = 'markdown',
|
|
226
|
+
HTML = 'html',
|
|
227
|
+
PLAINTEXT = 'plaintext'
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### ContentStatus
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
enum ContentStatus {
|
|
235
|
+
DRAFT = 'draft',
|
|
236
|
+
IN_REVIEW = 'in_review',
|
|
237
|
+
APPROVED = 'approved',
|
|
238
|
+
SCHEDULED = 'scheduled',
|
|
239
|
+
PUBLISHED = 'published',
|
|
240
|
+
ARCHIVED = 'archived'
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Error Handling
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import {
|
|
248
|
+
ContentNotFoundError,
|
|
249
|
+
OptimisticLockError,
|
|
250
|
+
DuplicateSlugError
|
|
251
|
+
} from '@bernierllc/content-storage-adapter';
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await storage.update('invalid-id', { title: 'New Title' });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof ContentNotFoundError) {
|
|
257
|
+
console.error('Content not found or already deleted');
|
|
258
|
+
} else if (error instanceof OptimisticLockError) {
|
|
259
|
+
console.error('Content was modified by another user');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Database Schema
|
|
265
|
+
|
|
266
|
+
The adapter expects the following PostgreSQL schema:
|
|
267
|
+
|
|
268
|
+
```sql
|
|
269
|
+
CREATE TABLE content (
|
|
270
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
271
|
+
user_id VARCHAR(255) NOT NULL,
|
|
272
|
+
title TEXT NOT NULL,
|
|
273
|
+
slug VARCHAR(255) UNIQUE NOT NULL,
|
|
274
|
+
body TEXT NOT NULL,
|
|
275
|
+
excerpt TEXT,
|
|
276
|
+
type VARCHAR(50) NOT NULL,
|
|
277
|
+
format VARCHAR(50) NOT NULL,
|
|
278
|
+
status VARCHAR(50) NOT NULL,
|
|
279
|
+
workflow_stage VARCHAR(50),
|
|
280
|
+
assigned_to VARCHAR(255),
|
|
281
|
+
ai_reviewed BOOLEAN DEFAULT FALSE,
|
|
282
|
+
ai_review_score FLOAT,
|
|
283
|
+
ai_provider VARCHAR(100),
|
|
284
|
+
published_at TIMESTAMP,
|
|
285
|
+
scheduled_for TIMESTAMP,
|
|
286
|
+
deleted_at TIMESTAMP,
|
|
287
|
+
meta_title TEXT,
|
|
288
|
+
meta_description TEXT,
|
|
289
|
+
keywords TEXT[],
|
|
290
|
+
view_count INTEGER DEFAULT 0,
|
|
291
|
+
share_count INTEGER DEFAULT 0,
|
|
292
|
+
generated_from_commit BOOLEAN DEFAULT FALSE,
|
|
293
|
+
source_commit_sha VARCHAR(255),
|
|
294
|
+
source_repository VARCHAR(500),
|
|
295
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
296
|
+
updated_at TIMESTAMP DEFAULT NOW()
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
CREATE TABLE content_versions (
|
|
300
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
301
|
+
content_id UUID NOT NULL REFERENCES content(id),
|
|
302
|
+
version_number INTEGER NOT NULL,
|
|
303
|
+
title TEXT NOT NULL,
|
|
304
|
+
body TEXT NOT NULL,
|
|
305
|
+
change_summary TEXT,
|
|
306
|
+
is_autosave BOOLEAN DEFAULT FALSE,
|
|
307
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
308
|
+
created_by VARCHAR(255) NOT NULL,
|
|
309
|
+
UNIQUE(content_id, version_number)
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
CREATE INDEX idx_content_user_id ON content(user_id);
|
|
313
|
+
CREATE INDEX idx_content_slug ON content(slug);
|
|
314
|
+
CREATE INDEX idx_content_status ON content(status);
|
|
315
|
+
CREATE INDEX idx_content_deleted_at ON content(deleted_at);
|
|
316
|
+
CREATE INDEX idx_content_versions_content_id ON content_versions(content_id);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Testing
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
# Run tests
|
|
323
|
+
npm test
|
|
324
|
+
|
|
325
|
+
# Run tests without watch
|
|
326
|
+
npm run test:run
|
|
327
|
+
|
|
328
|
+
# Run tests with coverage
|
|
329
|
+
npm run test:coverage
|
|
330
|
+
|
|
331
|
+
# Build TypeScript
|
|
332
|
+
npm run build
|
|
333
|
+
|
|
334
|
+
# Lint code
|
|
335
|
+
npm run lint
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
Copyright (c) 2025 Bernier LLC
|
|
341
|
+
|
|
342
|
+
This file is licensed to the client under a limited-use license.
|
|
343
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
344
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
345
|
+
|
|
346
|
+
## Related Packages
|
|
347
|
+
|
|
348
|
+
- [@bernierllc/database-adapter-core](../database-adapter-core) - Abstract database interface
|
|
349
|
+
- [@bernierllc/slug-generator](../slug-generator) - SEO-friendly URL generation
|
|
350
|
+
- [@bernierllc/content-storage-service](../../service/content-storage-service) - Higher-level storage orchestration
|
|
@@ -0,0 +1,130 @@
|
|
|
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 { ContentBatchOperations } from '../src/ContentBatchOperations';
|
|
11
|
+
import { ContentStatus, ContentType, ContentFormat } from '../src/models/IContent';
|
|
12
|
+
import { MockDatabaseAdapter } from './mocks/MockDatabaseAdapter';
|
|
13
|
+
import { IDatabaseAdapter } from '@bernierllc/database-adapter-core';
|
|
14
|
+
|
|
15
|
+
describe('ContentBatchOperations', () => {
|
|
16
|
+
let batchOps: ContentBatchOperations;
|
|
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
|
+
batchOps = new ContentBatchOperations(adapter);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await dbAdapter.disconnect();
|
|
29
|
+
dbAdapter.clearMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('bulkUpdateStatus', () => {
|
|
33
|
+
it('should bulk update status', async () => {
|
|
34
|
+
const mockContent1 = {
|
|
35
|
+
id: '123',
|
|
36
|
+
user_id: 'user1',
|
|
37
|
+
title: 'Test Title',
|
|
38
|
+
slug: 'test-title',
|
|
39
|
+
body: 'Test body',
|
|
40
|
+
type: ContentType.BLOG,
|
|
41
|
+
format: ContentFormat.MARKDOWN,
|
|
42
|
+
status: ContentStatus.PUBLISHED,
|
|
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 each individual update query
|
|
52
|
+
dbAdapter.setQueryResult(
|
|
53
|
+
'update content set status = $1, updated_at = now() where id = $2 and deleted_at is null returning *',
|
|
54
|
+
{ rows: [mockContent1], rowCount: 1 }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const result = await batchOps.bulkUpdateStatus(
|
|
58
|
+
['123', '456', '789'],
|
|
59
|
+
ContentStatus.PUBLISHED
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(result).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should handle partial failures gracefully', async () => {
|
|
66
|
+
// First update succeeds
|
|
67
|
+
dbAdapter.setQueryResult(
|
|
68
|
+
'update content set status = $1, updated_at = now() where id = $2 and deleted_at is null returning *',
|
|
69
|
+
{ rows: [{ id: '123' }], rowCount: 1 }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const result = await batchOps.bulkUpdateStatus(
|
|
73
|
+
['123', '456'],
|
|
74
|
+
ContentStatus.PUBLISHED
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Should continue even if some fail
|
|
78
|
+
expect(result).toBeGreaterThanOrEqual(0);
|
|
79
|
+
expect(result).toBeLessThanOrEqual(2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('bulkDelete', () => {
|
|
84
|
+
it('should bulk delete', async () => {
|
|
85
|
+
dbAdapter.setQueryResult(
|
|
86
|
+
'UPDATE content SET deleted_at = NOW() WHERE id = ANY($1) AND deleted_at IS NULL',
|
|
87
|
+
{ rows: [], rowCount: 3 }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result = await batchOps.bulkDelete(['123', '456', '789']);
|
|
91
|
+
|
|
92
|
+
expect(result).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return count of deleted items', async () => {
|
|
96
|
+
dbAdapter.setQueryResult(
|
|
97
|
+
'UPDATE content SET deleted_at = NOW() WHERE id = ANY($1) AND deleted_at IS NULL',
|
|
98
|
+
{ rows: [], rowCount: 2 }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const result = await batchOps.bulkDelete(['123', '456']);
|
|
102
|
+
|
|
103
|
+
expect(result).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('bulkRestore', () => {
|
|
108
|
+
it('should bulk restore', async () => {
|
|
109
|
+
dbAdapter.setQueryResult(
|
|
110
|
+
'UPDATE content SET deleted_at = NULL WHERE id = ANY($1) AND deleted_at IS NOT NULL',
|
|
111
|
+
{ rows: [], rowCount: 3 }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const result = await batchOps.bulkRestore(['123', '456', '789']);
|
|
115
|
+
|
|
116
|
+
expect(result).toBe(3);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return count of restored items', async () => {
|
|
120
|
+
dbAdapter.setQueryResult(
|
|
121
|
+
'UPDATE content SET deleted_at = NULL WHERE id = ANY($1) AND deleted_at IS NOT NULL',
|
|
122
|
+
{ rows: [], rowCount: 1 }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = await batchOps.bulkRestore(['123']);
|
|
126
|
+
|
|
127
|
+
expect(result).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|