@dismissible/nestjs-dismissible 0.0.2-canary.738340d.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 +506 -0
- package/jest.config.ts +29 -0
- package/package.json +63 -0
- package/project.json +42 -0
- package/src/api/dismissible-item-response.dto.ts +38 -0
- package/src/api/dismissible-item.mapper.spec.ts +63 -0
- package/src/api/dismissible-item.mapper.ts +33 -0
- package/src/api/index.ts +7 -0
- package/src/api/use-cases/api-tags.constants.ts +4 -0
- package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +42 -0
- package/src/api/use-cases/dismiss/dismiss.controller.ts +63 -0
- package/src/api/use-cases/dismiss/dismiss.response.dto.ts +7 -0
- package/src/api/use-cases/dismiss/index.ts +2 -0
- package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +76 -0
- package/src/api/use-cases/get-or-create/get-or-create.controller.ts +106 -0
- package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +17 -0
- package/src/api/use-cases/get-or-create/get-or-create.response.dto.ts +7 -0
- package/src/api/use-cases/get-or-create/index.ts +3 -0
- package/src/api/use-cases/index.ts +3 -0
- package/src/api/use-cases/restore/index.ts +2 -0
- package/src/api/use-cases/restore/restore.controller.spec.ts +42 -0
- package/src/api/use-cases/restore/restore.controller.ts +63 -0
- package/src/api/use-cases/restore/restore.response.dto.ts +7 -0
- package/src/core/create-options.ts +9 -0
- package/src/core/dismissible-core.service.spec.ts +357 -0
- package/src/core/dismissible-core.service.ts +161 -0
- package/src/core/dismissible.service.spec.ts +144 -0
- package/src/core/dismissible.service.ts +188 -0
- package/src/core/hook-runner.service.spec.ts +304 -0
- package/src/core/hook-runner.service.ts +267 -0
- package/src/core/index.ts +6 -0
- package/src/core/lifecycle-hook.interface.ts +122 -0
- package/src/core/service-responses.interface.ts +34 -0
- package/src/dismissible.module.ts +83 -0
- package/src/events/dismissible.events.ts +105 -0
- package/src/events/events.constants.ts +21 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/dismissible.exceptions.spec.ts +50 -0
- package/src/exceptions/dismissible.exceptions.ts +69 -0
- package/src/exceptions/index.ts +1 -0
- package/src/index.ts +8 -0
- package/src/request/index.ts +2 -0
- package/src/request/request-context.decorator.ts +14 -0
- package/src/request/request-context.interface.ts +6 -0
- package/src/response/dtos/base-response.dto.ts +11 -0
- package/src/response/dtos/error-response.dto.ts +36 -0
- package/src/response/dtos/index.ts +3 -0
- package/src/response/dtos/success-response.dto.ts +34 -0
- package/src/response/http-exception-filter.ts +21 -0
- package/src/response/index.ts +4 -0
- package/src/response/response.module.ts +9 -0
- package/src/response/response.service.spec.ts +86 -0
- package/src/response/response.service.ts +20 -0
- package/src/testing/factories.ts +45 -0
- package/src/testing/index.ts +1 -0
- package/src/utils/date/date.service.spec.ts +104 -0
- package/src/utils/date/date.service.ts +19 -0
- package/src/utils/date/index.ts +1 -0
- package/src/utils/dismissible.helper.ts +9 -0
- package/src/utils/index.ts +3 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +14 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { Mock, mock } from 'ts-jest-mocker';
|
|
2
|
+
import { DismissibleCoreService } from './dismissible-core.service';
|
|
3
|
+
import { IDismissibleStorage } from '@dismissible/nestjs-storage';
|
|
4
|
+
import { createTestItem, createDismissedTestItem } from '../testing/factories';
|
|
5
|
+
import {
|
|
6
|
+
ItemNotFoundException,
|
|
7
|
+
ItemAlreadyDismissedException,
|
|
8
|
+
ItemNotDismissedException,
|
|
9
|
+
} from '../exceptions';
|
|
10
|
+
import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
|
|
11
|
+
import { DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
|
|
12
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
13
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
14
|
+
import { BadRequestException } from '@nestjs/common';
|
|
15
|
+
import { DismissibleHelper } from '../utils/dismissible.helper';
|
|
16
|
+
import { DateService } from '../utils/date/date.service';
|
|
17
|
+
|
|
18
|
+
describe('DismissibleCoreService', () => {
|
|
19
|
+
let service: DismissibleCoreService<BaseMetadata>;
|
|
20
|
+
let storage: Mock<IDismissibleStorage<BaseMetadata>>;
|
|
21
|
+
let mockDateService: Mock<DateService>;
|
|
22
|
+
let mockLogger: Mock<IDismissibleLogger>;
|
|
23
|
+
let itemFactory: Mock<DismissibleItemFactory>;
|
|
24
|
+
let validationService: Mock<ValidationService>;
|
|
25
|
+
let dismissibleHelper: Mock<DismissibleHelper>;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockDateService = mock(DateService);
|
|
29
|
+
mockDateService.getNow.mockReturnValue(new Date('2024-01-15T10:00:00.000Z'));
|
|
30
|
+
mockLogger = mock<IDismissibleLogger>({
|
|
31
|
+
failIfMockNotProvided: false,
|
|
32
|
+
});
|
|
33
|
+
storage = mock<IDismissibleStorage<BaseMetadata>>({
|
|
34
|
+
failIfMockNotProvided: false,
|
|
35
|
+
});
|
|
36
|
+
dismissibleHelper = mock(DismissibleHelper, { failIfMockNotProvided: false });
|
|
37
|
+
itemFactory = mock(DismissibleItemFactory);
|
|
38
|
+
validationService = mock(ValidationService, { failIfMockNotProvided: false });
|
|
39
|
+
validationService.validateInstance.mockResolvedValue(undefined);
|
|
40
|
+
service = new DismissibleCoreService(
|
|
41
|
+
storage,
|
|
42
|
+
mockDateService,
|
|
43
|
+
mockLogger,
|
|
44
|
+
itemFactory,
|
|
45
|
+
validationService,
|
|
46
|
+
dismissibleHelper,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
jest.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getOrCreate', () => {
|
|
55
|
+
it('should create a new item when it does not exist', async () => {
|
|
56
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
57
|
+
const userId = 'user-123';
|
|
58
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
59
|
+
|
|
60
|
+
storage.get.mockResolvedValue(null);
|
|
61
|
+
storage.create.mockResolvedValue(newItem);
|
|
62
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
63
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
64
|
+
|
|
65
|
+
const result = await service.getOrCreate('new-item', userId);
|
|
66
|
+
|
|
67
|
+
expect(result.created).toBe(true);
|
|
68
|
+
expect(result.item.id).toBe('new-item');
|
|
69
|
+
expect(result.item.userId).toBe(userId);
|
|
70
|
+
expect(result.item.createdAt).toBeInstanceOf(Date);
|
|
71
|
+
expect(result.item.dismissedAt).toBeUndefined();
|
|
72
|
+
expect(storage.create).toHaveBeenCalledWith(newItem);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return existing item when it exists', async () => {
|
|
76
|
+
const userId = 'user-123';
|
|
77
|
+
const existingItem = createTestItem({ id: 'existing-item', userId });
|
|
78
|
+
storage.get.mockResolvedValue(existingItem);
|
|
79
|
+
|
|
80
|
+
const result = await service.getOrCreate('existing-item', userId);
|
|
81
|
+
|
|
82
|
+
expect(result.created).toBe(false);
|
|
83
|
+
expect(result.item).toEqual(existingItem);
|
|
84
|
+
expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should create item with metadata when provided', async () => {
|
|
88
|
+
const userId = 'user-123';
|
|
89
|
+
const metadata = { version: 2, category: 'test' };
|
|
90
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
91
|
+
const newItem = createTestItem({ id: 'new-item', userId, metadata, createdAt: testDate });
|
|
92
|
+
|
|
93
|
+
storage.get.mockResolvedValue(null);
|
|
94
|
+
storage.create.mockResolvedValue(newItem);
|
|
95
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
96
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
97
|
+
|
|
98
|
+
const result = await service.getOrCreate('new-item', userId, { metadata });
|
|
99
|
+
|
|
100
|
+
expect(result.item.metadata).toEqual(metadata);
|
|
101
|
+
expect(storage.create).toHaveBeenCalledWith(newItem);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('dismiss', () => {
|
|
106
|
+
it('should dismiss an existing item', async () => {
|
|
107
|
+
const userId = 'user-123';
|
|
108
|
+
const item = createTestItem({ id: 'test-item', userId });
|
|
109
|
+
const previousItem = createTestItem({ id: 'test-item', userId });
|
|
110
|
+
const dismissedItem = createDismissedTestItem({ id: 'test-item', userId });
|
|
111
|
+
const testDate = new Date('2024-01-15T12:00:00.000Z');
|
|
112
|
+
|
|
113
|
+
storage.get.mockResolvedValue(item);
|
|
114
|
+
storage.update.mockResolvedValue(dismissedItem);
|
|
115
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
116
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
117
|
+
itemFactory.createDismissed.mockReturnValue(dismissedItem);
|
|
118
|
+
dismissibleHelper.isDismissed.mockReturnValue(false);
|
|
119
|
+
|
|
120
|
+
const result = await service.dismiss('test-item', userId);
|
|
121
|
+
|
|
122
|
+
expect(result.item.dismissedAt).toBeDefined();
|
|
123
|
+
expect(result.previousItem.dismissedAt).toBeUndefined();
|
|
124
|
+
expect(storage.update).toHaveBeenCalledWith(dismissedItem);
|
|
125
|
+
expect(storage.get).toHaveBeenCalledWith(userId, 'test-item');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should throw ItemNotFoundException for non-existent item', async () => {
|
|
129
|
+
const userId = 'user-123';
|
|
130
|
+
storage.get.mockResolvedValue(null);
|
|
131
|
+
|
|
132
|
+
await expect(service.dismiss('non-existent', userId)).rejects.toThrow(ItemNotFoundException);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should throw ItemAlreadyDismissedException for already dismissed item', async () => {
|
|
136
|
+
const userId = 'user-123';
|
|
137
|
+
const dismissedItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
138
|
+
storage.get.mockResolvedValue(dismissedItem);
|
|
139
|
+
dismissibleHelper.isDismissed.mockReturnValue(true);
|
|
140
|
+
|
|
141
|
+
await expect(service.dismiss('dismissed-item', userId)).rejects.toThrow(
|
|
142
|
+
ItemAlreadyDismissedException,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return the previous item state', async () => {
|
|
147
|
+
const userId = 'user-123';
|
|
148
|
+
const item = createTestItem({
|
|
149
|
+
id: 'test-item',
|
|
150
|
+
userId,
|
|
151
|
+
metadata: { key: 'value' },
|
|
152
|
+
});
|
|
153
|
+
const previousItem = createTestItem({
|
|
154
|
+
id: 'test-item',
|
|
155
|
+
userId,
|
|
156
|
+
metadata: { key: 'value' },
|
|
157
|
+
});
|
|
158
|
+
const dismissedItem = createDismissedTestItem({
|
|
159
|
+
id: 'test-item',
|
|
160
|
+
userId,
|
|
161
|
+
metadata: { key: 'value' },
|
|
162
|
+
});
|
|
163
|
+
const testDate = new Date('2024-01-15T12:00:00.000Z');
|
|
164
|
+
|
|
165
|
+
storage.get.mockResolvedValue(item);
|
|
166
|
+
storage.update.mockResolvedValue(dismissedItem);
|
|
167
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
168
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
169
|
+
itemFactory.createDismissed.mockReturnValue(dismissedItem);
|
|
170
|
+
dismissibleHelper.isDismissed.mockReturnValue(false);
|
|
171
|
+
|
|
172
|
+
const result = await service.dismiss('test-item', userId);
|
|
173
|
+
|
|
174
|
+
expect(result.previousItem.id).toBe(item.id);
|
|
175
|
+
expect(result.previousItem.dismissedAt).toBeUndefined();
|
|
176
|
+
expect(result.previousItem.metadata).toEqual({ key: 'value' });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('restore', () => {
|
|
181
|
+
it('should restore a dismissed item', async () => {
|
|
182
|
+
const userId = 'user-123';
|
|
183
|
+
const dismissedItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
184
|
+
const previousItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
185
|
+
const restoredItem = createTestItem({ id: 'dismissed-item', userId });
|
|
186
|
+
|
|
187
|
+
storage.get.mockResolvedValue(dismissedItem);
|
|
188
|
+
storage.update.mockResolvedValue(restoredItem);
|
|
189
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
190
|
+
itemFactory.createRestored.mockReturnValue(restoredItem);
|
|
191
|
+
dismissibleHelper.isDismissed.mockReturnValue(true);
|
|
192
|
+
|
|
193
|
+
const result = await service.restore('dismissed-item', userId);
|
|
194
|
+
|
|
195
|
+
expect(result.item.dismissedAt).toBeUndefined();
|
|
196
|
+
expect(result.previousItem.dismissedAt).toBeDefined();
|
|
197
|
+
expect(storage.update).toHaveBeenCalledWith(restoredItem);
|
|
198
|
+
expect(storage.get).toHaveBeenCalledWith(userId, 'dismissed-item');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should throw ItemNotFoundException for non-existent item', async () => {
|
|
202
|
+
const userId = 'user-123';
|
|
203
|
+
storage.get.mockResolvedValue(null);
|
|
204
|
+
|
|
205
|
+
await expect(service.restore('non-existent', userId)).rejects.toThrow(ItemNotFoundException);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should throw ItemNotDismissedException for non-dismissed item', async () => {
|
|
209
|
+
const userId = 'user-123';
|
|
210
|
+
const item = createTestItem({ id: 'active-item', userId });
|
|
211
|
+
storage.get.mockResolvedValue(item);
|
|
212
|
+
dismissibleHelper.isDismissed.mockReturnValue(false);
|
|
213
|
+
|
|
214
|
+
await expect(service.restore('active-item', userId)).rejects.toThrow(
|
|
215
|
+
ItemNotDismissedException,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should return the previous item state', async () => {
|
|
220
|
+
const userId = 'user-123';
|
|
221
|
+
const dismissedItem = createDismissedTestItem({
|
|
222
|
+
id: 'dismissed-item',
|
|
223
|
+
userId,
|
|
224
|
+
metadata: { key: 'value' },
|
|
225
|
+
});
|
|
226
|
+
const previousItem = createDismissedTestItem({
|
|
227
|
+
id: 'dismissed-item',
|
|
228
|
+
userId,
|
|
229
|
+
metadata: { key: 'value' },
|
|
230
|
+
});
|
|
231
|
+
const restoredItem = createTestItem({
|
|
232
|
+
id: 'dismissed-item',
|
|
233
|
+
userId,
|
|
234
|
+
metadata: { key: 'value' },
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
storage.get.mockResolvedValue(dismissedItem);
|
|
238
|
+
storage.update.mockResolvedValue(restoredItem);
|
|
239
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
240
|
+
itemFactory.createRestored.mockReturnValue(restoredItem);
|
|
241
|
+
dismissibleHelper.isDismissed.mockReturnValue(true);
|
|
242
|
+
|
|
243
|
+
const result = await service.restore('dismissed-item', userId);
|
|
244
|
+
|
|
245
|
+
expect(result.previousItem.id).toBe(dismissedItem.id);
|
|
246
|
+
expect(result.previousItem.dismissedAt).toBeDefined();
|
|
247
|
+
expect(result.previousItem.metadata).toEqual({ key: 'value' });
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('validation', () => {
|
|
252
|
+
it('should throw BadRequestException when validation fails on create', async () => {
|
|
253
|
+
const userId = 'user-123';
|
|
254
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
255
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
256
|
+
|
|
257
|
+
storage.get.mockResolvedValue(null);
|
|
258
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
259
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
260
|
+
validationService.validateInstance.mockRejectedValue(
|
|
261
|
+
new BadRequestException('id must be a string'),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await expect(service.getOrCreate('new-item', userId)).rejects.toThrow(BadRequestException);
|
|
265
|
+
expect(storage.create).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should throw BadRequestException when validation fails on dismiss', async () => {
|
|
269
|
+
const userId = 'user-123';
|
|
270
|
+
const item = createTestItem({ id: 'test-item', userId });
|
|
271
|
+
const previousItem = createTestItem({ id: 'test-item', userId });
|
|
272
|
+
const dismissedItem = createDismissedTestItem({ id: 'test-item', userId });
|
|
273
|
+
const testDate = new Date('2024-01-15T12:00:00.000Z');
|
|
274
|
+
|
|
275
|
+
storage.get.mockResolvedValue(item);
|
|
276
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
277
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
278
|
+
itemFactory.createDismissed.mockReturnValue(dismissedItem);
|
|
279
|
+
dismissibleHelper.isDismissed.mockReturnValue(false);
|
|
280
|
+
validationService.validateInstance.mockRejectedValue(
|
|
281
|
+
new BadRequestException('dismissedAt must be a date'),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await expect(service.dismiss('test-item', userId)).rejects.toThrow(BadRequestException);
|
|
285
|
+
expect(storage.update).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should throw BadRequestException when validation fails on restore', async () => {
|
|
289
|
+
const userId = 'user-123';
|
|
290
|
+
const dismissedItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
291
|
+
const previousItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
292
|
+
const restoredItem = createTestItem({ id: 'dismissed-item', userId });
|
|
293
|
+
|
|
294
|
+
storage.get.mockResolvedValue(dismissedItem);
|
|
295
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
296
|
+
itemFactory.createRestored.mockReturnValue(restoredItem);
|
|
297
|
+
dismissibleHelper.isDismissed.mockReturnValue(true);
|
|
298
|
+
validationService.validateInstance.mockRejectedValue(
|
|
299
|
+
new BadRequestException('id must be a string'),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await expect(service.restore('dismissed-item', userId)).rejects.toThrow(BadRequestException);
|
|
303
|
+
expect(storage.update).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should validate item when creating', async () => {
|
|
307
|
+
const userId = 'user-123';
|
|
308
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
309
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
310
|
+
|
|
311
|
+
storage.get.mockResolvedValue(null);
|
|
312
|
+
storage.create.mockResolvedValue(newItem);
|
|
313
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
314
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
315
|
+
|
|
316
|
+
await service.getOrCreate('new-item', userId);
|
|
317
|
+
|
|
318
|
+
expect(validationService.validateInstance).toHaveBeenCalledWith(newItem);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should validate item when dismissing', async () => {
|
|
322
|
+
const userId = 'user-123';
|
|
323
|
+
const item = createTestItem({ id: 'test-item', userId });
|
|
324
|
+
const previousItem = createTestItem({ id: 'test-item', userId });
|
|
325
|
+
const dismissedItem = createDismissedTestItem({ id: 'test-item', userId });
|
|
326
|
+
const testDate = new Date('2024-01-15T12:00:00.000Z');
|
|
327
|
+
|
|
328
|
+
storage.get.mockResolvedValue(item);
|
|
329
|
+
storage.update.mockResolvedValue(dismissedItem);
|
|
330
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
331
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
332
|
+
itemFactory.createDismissed.mockReturnValue(dismissedItem);
|
|
333
|
+
dismissibleHelper.isDismissed.mockReturnValue(false);
|
|
334
|
+
|
|
335
|
+
await service.dismiss('test-item', userId);
|
|
336
|
+
|
|
337
|
+
expect(validationService.validateInstance).toHaveBeenCalledWith(dismissedItem);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should validate item when restoring', async () => {
|
|
341
|
+
const userId = 'user-123';
|
|
342
|
+
const dismissedItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
343
|
+
const previousItem = createDismissedTestItem({ id: 'dismissed-item', userId });
|
|
344
|
+
const restoredItem = createTestItem({ id: 'dismissed-item', userId });
|
|
345
|
+
|
|
346
|
+
storage.get.mockResolvedValue(dismissedItem);
|
|
347
|
+
storage.update.mockResolvedValue(restoredItem);
|
|
348
|
+
itemFactory.clone.mockReturnValue(previousItem);
|
|
349
|
+
itemFactory.createRestored.mockReturnValue(restoredItem);
|
|
350
|
+
dismissibleHelper.isDismissed.mockReturnValue(true);
|
|
351
|
+
|
|
352
|
+
await service.restore('dismissed-item', userId);
|
|
353
|
+
|
|
354
|
+
expect(validationService.validateInstance).toHaveBeenCalledWith(restoredItem);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
2
|
+
import { DISMISSIBLE_STORAGE_ADAPTER, IDismissibleStorage } from '@dismissible/nestjs-storage';
|
|
3
|
+
import {
|
|
4
|
+
IGetOrCreateServiceResponse,
|
|
5
|
+
IDismissServiceResponse,
|
|
6
|
+
IRestoreServiceResponse,
|
|
7
|
+
} from './service-responses.interface';
|
|
8
|
+
import { DismissibleHelper } from '../utils/dismissible.helper';
|
|
9
|
+
import { DateService } from '../utils/date/date.service';
|
|
10
|
+
import { DISMISSIBLE_LOGGER, IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
11
|
+
import {
|
|
12
|
+
ItemNotFoundException,
|
|
13
|
+
ItemAlreadyDismissedException,
|
|
14
|
+
ItemNotDismissedException,
|
|
15
|
+
} from '../exceptions';
|
|
16
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
17
|
+
import { BaseMetadata, DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
|
|
18
|
+
import { ICreateItemOptions } from './create-options';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Core business logic service for dismissible operations.
|
|
22
|
+
* Handles pure CRUD operations without side effects (hooks, events).
|
|
23
|
+
*/
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
26
|
+
constructor(
|
|
27
|
+
@Inject(DISMISSIBLE_STORAGE_ADAPTER) private readonly storage: IDismissibleStorage<TMetadata>,
|
|
28
|
+
private readonly dateService: DateService,
|
|
29
|
+
@Inject(DISMISSIBLE_LOGGER) private readonly logger: IDismissibleLogger,
|
|
30
|
+
private readonly itemFactory: DismissibleItemFactory,
|
|
31
|
+
private readonly validationService: ValidationService,
|
|
32
|
+
private readonly dismissibleHelper: DismissibleHelper,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get an existing item or create a new one.
|
|
37
|
+
* @param itemId The item identifier
|
|
38
|
+
* @param userId The user identifier (required)
|
|
39
|
+
* @param options Optional creation options (metadata)
|
|
40
|
+
*/
|
|
41
|
+
async getOrCreate(
|
|
42
|
+
itemId: string,
|
|
43
|
+
userId: string,
|
|
44
|
+
options?: ICreateItemOptions<TMetadata>,
|
|
45
|
+
): Promise<IGetOrCreateServiceResponse<TMetadata>> {
|
|
46
|
+
this.logger.debug(`Looking up item in storage`, { itemId, userId });
|
|
47
|
+
|
|
48
|
+
const existingItem = await this.storage.get(userId, itemId);
|
|
49
|
+
|
|
50
|
+
if (existingItem) {
|
|
51
|
+
this.logger.debug(`Found existing item`, { itemId, userId });
|
|
52
|
+
return {
|
|
53
|
+
item: existingItem,
|
|
54
|
+
created: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.logger.debug(`Creating new item`, {
|
|
59
|
+
itemId,
|
|
60
|
+
userId,
|
|
61
|
+
hasMetadata: !!options?.metadata,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Create new item
|
|
65
|
+
const now = this.dateService.getNow();
|
|
66
|
+
const newItem = this.itemFactory.create<TMetadata>({
|
|
67
|
+
id: itemId,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
userId,
|
|
70
|
+
metadata: options?.metadata,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Validate the item before storage
|
|
74
|
+
await this.validationService.validateInstance(newItem);
|
|
75
|
+
|
|
76
|
+
const createdItem = await this.storage.create(newItem);
|
|
77
|
+
|
|
78
|
+
this.logger.info(`Created new dismissible item`, { itemId, userId });
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
item: createdItem,
|
|
82
|
+
created: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dismiss an item.
|
|
88
|
+
* @param itemId The item identifier
|
|
89
|
+
* @param userId The user identifier (required)
|
|
90
|
+
* @throws ItemNotFoundException if item doesn't exist
|
|
91
|
+
* @throws ItemAlreadyDismissedException if item is already dismissed
|
|
92
|
+
*/
|
|
93
|
+
async dismiss(itemId: string, userId: string): Promise<IDismissServiceResponse<TMetadata>> {
|
|
94
|
+
this.logger.debug(`Attempting to dismiss item`, { itemId, userId });
|
|
95
|
+
|
|
96
|
+
const existingItem = await this.storage.get(userId, itemId);
|
|
97
|
+
|
|
98
|
+
if (!existingItem) {
|
|
99
|
+
this.logger.warn(`Cannot dismiss: item not found`, { itemId, userId });
|
|
100
|
+
throw new ItemNotFoundException(itemId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.dismissibleHelper.isDismissed(existingItem)) {
|
|
104
|
+
this.logger.warn(`Cannot dismiss: item already dismissed`, { itemId, userId });
|
|
105
|
+
throw new ItemAlreadyDismissedException(itemId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const previousItem = this.itemFactory.clone(existingItem);
|
|
109
|
+
const dismissedItem = this.itemFactory.createDismissed(existingItem, this.dateService.getNow());
|
|
110
|
+
|
|
111
|
+
// Validate the item before storage
|
|
112
|
+
await this.validationService.validateInstance(dismissedItem);
|
|
113
|
+
|
|
114
|
+
const updatedItem = await this.storage.update(dismissedItem);
|
|
115
|
+
|
|
116
|
+
this.logger.info(`Item dismissed`, { itemId, userId });
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
item: updatedItem,
|
|
120
|
+
previousItem,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Restore a dismissed item.
|
|
126
|
+
* @param itemId The item identifier
|
|
127
|
+
* @param userId The user identifier (required)
|
|
128
|
+
* @throws ItemNotFoundException if item doesn't exist
|
|
129
|
+
* @throws ItemNotDismissedException if item is not dismissed
|
|
130
|
+
*/
|
|
131
|
+
async restore(itemId: string, userId: string): Promise<IRestoreServiceResponse<TMetadata>> {
|
|
132
|
+
this.logger.debug(`Attempting to restore item`, { itemId, userId });
|
|
133
|
+
|
|
134
|
+
const existingItem = await this.storage.get(userId, itemId);
|
|
135
|
+
|
|
136
|
+
if (!existingItem) {
|
|
137
|
+
this.logger.warn(`Cannot restore: item not found`, { itemId, userId });
|
|
138
|
+
throw new ItemNotFoundException(itemId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!this.dismissibleHelper.isDismissed(existingItem)) {
|
|
142
|
+
this.logger.warn(`Cannot restore: item not dismissed`, { itemId, userId });
|
|
143
|
+
throw new ItemNotDismissedException(itemId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const previousItem = this.itemFactory.clone(existingItem);
|
|
147
|
+
const restoredItem = this.itemFactory.createRestored(existingItem);
|
|
148
|
+
|
|
149
|
+
// Validate the item before storage
|
|
150
|
+
await this.validationService.validateInstance(restoredItem);
|
|
151
|
+
|
|
152
|
+
const updatedItem = await this.storage.update(restoredItem);
|
|
153
|
+
|
|
154
|
+
this.logger.info(`Item restored`, { itemId, userId });
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
item: updatedItem,
|
|
158
|
+
previousItem,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { mock } from 'ts-jest-mocker';
|
|
2
|
+
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
3
|
+
import { DismissibleService } from './dismissible.service';
|
|
4
|
+
import { DismissibleCoreService } from './dismissible-core.service';
|
|
5
|
+
import { HookRunner, IHookRunResult } from './hook-runner.service';
|
|
6
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
7
|
+
import { DismissibleEvents } from '../events';
|
|
8
|
+
import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
|
|
9
|
+
import { createTestItem, createTestContext } from '../testing/factories';
|
|
10
|
+
|
|
11
|
+
describe('DismissibleService', () => {
|
|
12
|
+
let service: DismissibleService<BaseMetadata>;
|
|
13
|
+
let mockCoreService: jest.Mocked<DismissibleCoreService<BaseMetadata>>;
|
|
14
|
+
let mockHookRunner: jest.Mocked<HookRunner<BaseMetadata>>;
|
|
15
|
+
let mockEventEmitter: jest.Mocked<EventEmitter2>;
|
|
16
|
+
let mockLogger: jest.Mocked<IDismissibleLogger>;
|
|
17
|
+
|
|
18
|
+
const testUserId = 'test-user-id';
|
|
19
|
+
|
|
20
|
+
const createHookResult = (id: string, userId = testUserId): IHookRunResult => ({
|
|
21
|
+
proceed: true,
|
|
22
|
+
id,
|
|
23
|
+
userId,
|
|
24
|
+
context: createTestContext(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockCoreService = mock(DismissibleCoreService, { failIfMockNotProvided: false });
|
|
29
|
+
mockHookRunner = mock(HookRunner, { failIfMockNotProvided: false });
|
|
30
|
+
mockEventEmitter = mock(EventEmitter2, { failIfMockNotProvided: false });
|
|
31
|
+
mockLogger = mock<IDismissibleLogger>({ failIfMockNotProvided: false });
|
|
32
|
+
|
|
33
|
+
service = new DismissibleService(mockCoreService, mockHookRunner, mockEventEmitter, mockLogger);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('getOrCreate', () => {
|
|
37
|
+
it('should run hooks, call core service, and emit ITEM_RETRIEVED event for existing item', async () => {
|
|
38
|
+
const item = createTestItem({ id: 'existing-item' });
|
|
39
|
+
const context = createTestContext();
|
|
40
|
+
|
|
41
|
+
mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('existing-item'));
|
|
42
|
+
mockCoreService.getOrCreate.mockResolvedValue({ item, created: false });
|
|
43
|
+
|
|
44
|
+
const result = await service.getOrCreate('existing-item', testUserId, undefined, context);
|
|
45
|
+
|
|
46
|
+
expect(mockHookRunner.runPreGetOrCreate).toHaveBeenCalled();
|
|
47
|
+
expect(mockCoreService.getOrCreate).toHaveBeenCalledWith(
|
|
48
|
+
'existing-item',
|
|
49
|
+
testUserId,
|
|
50
|
+
undefined,
|
|
51
|
+
);
|
|
52
|
+
expect(mockHookRunner.runPostGetOrCreate).toHaveBeenCalled();
|
|
53
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
54
|
+
DismissibleEvents.ITEM_RETRIEVED,
|
|
55
|
+
expect.anything(),
|
|
56
|
+
);
|
|
57
|
+
expect(result.created).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should run create hooks and emit ITEM_CREATED event for new item', async () => {
|
|
61
|
+
const item = createTestItem({ id: 'new-item' });
|
|
62
|
+
const context = createTestContext();
|
|
63
|
+
|
|
64
|
+
mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('new-item'));
|
|
65
|
+
mockHookRunner.runPreCreate.mockResolvedValue(createHookResult('new-item'));
|
|
66
|
+
mockCoreService.getOrCreate.mockResolvedValue({ item, created: true });
|
|
67
|
+
|
|
68
|
+
const result = await service.getOrCreate('new-item', testUserId, undefined, context);
|
|
69
|
+
|
|
70
|
+
expect(mockHookRunner.runPreCreate).toHaveBeenCalled();
|
|
71
|
+
expect(mockHookRunner.runPostCreate).toHaveBeenCalled();
|
|
72
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
73
|
+
DismissibleEvents.ITEM_CREATED,
|
|
74
|
+
expect.anything(),
|
|
75
|
+
);
|
|
76
|
+
expect(result.created).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('dismiss', () => {
|
|
81
|
+
it('should run hooks, call core service, and emit ITEM_DISMISSED event', async () => {
|
|
82
|
+
const item = createTestItem({ id: 'test-item' });
|
|
83
|
+
const previousItem = createTestItem({ id: 'test-item' });
|
|
84
|
+
const context = createTestContext();
|
|
85
|
+
|
|
86
|
+
mockHookRunner.runPreDismiss.mockResolvedValue(createHookResult('test-item'));
|
|
87
|
+
mockCoreService.dismiss.mockResolvedValue({ item, previousItem });
|
|
88
|
+
|
|
89
|
+
const result = await service.dismiss('test-item', testUserId, context);
|
|
90
|
+
|
|
91
|
+
expect(mockHookRunner.runPreDismiss).toHaveBeenCalled();
|
|
92
|
+
expect(mockCoreService.dismiss).toHaveBeenCalledWith('test-item', testUserId);
|
|
93
|
+
expect(mockHookRunner.runPostDismiss).toHaveBeenCalled();
|
|
94
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
95
|
+
DismissibleEvents.ITEM_DISMISSED,
|
|
96
|
+
expect.anything(),
|
|
97
|
+
);
|
|
98
|
+
expect(result.item).toEqual(item);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('restore', () => {
|
|
103
|
+
it('should run hooks, call core service, and emit ITEM_RESTORED event', async () => {
|
|
104
|
+
const item = createTestItem({ id: 'test-item' });
|
|
105
|
+
const previousItem = createTestItem({ id: 'test-item' });
|
|
106
|
+
const context = createTestContext();
|
|
107
|
+
|
|
108
|
+
mockHookRunner.runPreRestore.mockResolvedValue(createHookResult('test-item'));
|
|
109
|
+
mockCoreService.restore.mockResolvedValue({ item, previousItem });
|
|
110
|
+
|
|
111
|
+
const result = await service.restore('test-item', testUserId, context);
|
|
112
|
+
|
|
113
|
+
expect(mockHookRunner.runPreRestore).toHaveBeenCalled();
|
|
114
|
+
expect(mockCoreService.restore).toHaveBeenCalledWith('test-item', testUserId);
|
|
115
|
+
expect(mockHookRunner.runPostRestore).toHaveBeenCalled();
|
|
116
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
117
|
+
DismissibleEvents.ITEM_RESTORED,
|
|
118
|
+
expect.anything(),
|
|
119
|
+
);
|
|
120
|
+
expect(result.item).toEqual(item);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('logging', () => {
|
|
125
|
+
it('should log debug messages for operations', async () => {
|
|
126
|
+
const item = createTestItem({ id: 'test-item' });
|
|
127
|
+
const context = createTestContext();
|
|
128
|
+
|
|
129
|
+
mockHookRunner.runPreGetOrCreate.mockResolvedValue(createHookResult('test-item'));
|
|
130
|
+
mockCoreService.getOrCreate.mockResolvedValue({ item, created: false });
|
|
131
|
+
|
|
132
|
+
await service.getOrCreate('test-item', testUserId, undefined, context);
|
|
133
|
+
|
|
134
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
135
|
+
expect.stringContaining('getOrCreate called'),
|
|
136
|
+
expect.any(Object),
|
|
137
|
+
);
|
|
138
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
139
|
+
expect.stringContaining('getOrCreate completed'),
|
|
140
|
+
expect.any(Object),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|