@dismissible/nestjs-dismissible 0.0.2-canary.8976e84.0 → 0.0.2-canary.b0d8bfe.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.
Files changed (46) hide show
  1. package/README.md +58 -74
  2. package/jest.config.ts +1 -1
  3. package/package.json +8 -11
  4. package/project.json +1 -1
  5. package/src/api/dismissible-item-response.dto.ts +0 -8
  6. package/src/api/dismissible-item.mapper.spec.ts +0 -12
  7. package/src/api/dismissible-item.mapper.ts +2 -8
  8. package/src/api/index.ts +2 -3
  9. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +1 -2
  10. package/src/api/use-cases/dismiss/dismiss.controller.ts +9 -10
  11. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +2 -42
  12. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +11 -58
  13. package/src/api/use-cases/get-or-create/index.ts +0 -1
  14. package/src/api/use-cases/restore/restore.controller.spec.ts +1 -2
  15. package/src/api/use-cases/restore/restore.controller.ts +9 -10
  16. package/src/api/validation/index.ts +2 -0
  17. package/src/api/validation/param-validation.pipe.spec.ts +313 -0
  18. package/src/api/validation/param-validation.pipe.ts +38 -0
  19. package/src/api/validation/param.decorators.ts +32 -0
  20. package/src/core/dismissible-core.service.spec.ts +75 -29
  21. package/src/core/dismissible-core.service.ts +40 -28
  22. package/src/core/dismissible.service.spec.ts +106 -24
  23. package/src/core/dismissible.service.ts +93 -54
  24. package/src/core/hook-runner.service.spec.ts +495 -54
  25. package/src/core/hook-runner.service.ts +125 -24
  26. package/src/core/index.ts +0 -1
  27. package/src/core/lifecycle-hook.interface.ts +7 -122
  28. package/src/core/service-responses.interface.ts +9 -9
  29. package/src/dismissible.module.integration.spec.ts +704 -0
  30. package/src/dismissible.module.ts +10 -11
  31. package/src/events/dismissible.events.ts +17 -40
  32. package/src/index.ts +1 -1
  33. package/src/response/http-exception-filter.spec.ts +179 -0
  34. package/src/response/http-exception-filter.ts +3 -3
  35. package/src/response/response.service.spec.ts +0 -14
  36. package/src/testing/factories.ts +24 -9
  37. package/src/utils/dismissible.helper.ts +2 -2
  38. package/src/validation/dismissible-input.dto.ts +47 -0
  39. package/src/validation/index.ts +1 -0
  40. package/tsconfig.json +3 -0
  41. package/tsconfig.spec.json +12 -0
  42. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
  43. package/src/core/create-options.ts +0 -9
  44. package/src/request/index.ts +0 -2
  45. package/src/request/request-context.decorator.ts +0 -14
  46. package/src/request/request-context.interface.ts +0 -6
@@ -1,23 +1,22 @@
1
- import { Controller, Post, Param, UseFilters } from '@nestjs/common';
1
+ import { Controller, Post, UseFilters } from '@nestjs/common';
2
2
  import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
3
3
  import { DismissibleService } from '../../../core/dismissible.service';
4
4
  import { DismissibleItemMapper } from '../../dismissible-item.mapper';
5
- import { RequestContext } from '../../../request/request-context.decorator';
6
- import { IRequestContext } from '../../../request/request-context.interface';
7
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
5
+ import { RequestContext, IRequestContext } from '@dismissible/nestjs-dismissible-request';
8
6
  import { RestoreResponseDto } from './restore.response.dto';
9
7
  import { ResponseService } from '../../../response/response.service';
10
8
  import { HttpExceptionFilter } from '../../../response/http-exception-filter';
11
9
  import { API_TAG_DISMISSIBLE } from '../api-tags.constants';
10
+ import { UserId, ItemId } from '../../validation';
12
11
 
13
12
  /**
14
13
  * Controller for restore dismissible item operations.
15
14
  */
16
15
  @ApiTags(API_TAG_DISMISSIBLE)
17
- @Controller('v1/user/:userId/dismissible-item')
16
+ @Controller('v1/users/:userId/items')
18
17
  export class RestoreController {
19
18
  constructor(
20
- private readonly dismissibleService: DismissibleService<BaseMetadata>,
19
+ private readonly dismissibleService: DismissibleService,
21
20
  private readonly mapper: DismissibleItemMapper,
22
21
  private readonly responseService: ResponseService,
23
22
  ) {}
@@ -29,12 +28,12 @@ export class RestoreController {
29
28
  })
30
29
  @ApiParam({
31
30
  name: 'userId',
32
- description: 'User identifier',
31
+ description: 'User identifier (max length: 32 characters)',
33
32
  example: 'user-123',
34
33
  })
35
34
  @ApiParam({
36
35
  name: 'itemId',
37
- description: 'Unique identifier for the dismissible item',
36
+ description: 'Unique identifier for the dismissible item (max length: 32 characters)',
38
37
  example: 'welcome-banner-v2',
39
38
  })
40
39
  @ApiResponse({
@@ -52,8 +51,8 @@ export class RestoreController {
52
51
  })
53
52
  @UseFilters(HttpExceptionFilter)
54
53
  async restore(
55
- @Param('userId') userId: string,
56
- @Param('itemId') itemId: string,
54
+ @UserId() userId: string,
55
+ @ItemId() itemId: string,
57
56
  @RequestContext() context: IRequestContext,
58
57
  ): Promise<RestoreResponseDto> {
59
58
  const result = await this.dismissibleService.restore(itemId, userId, context);
@@ -0,0 +1,2 @@
1
+ export * from './param-validation.pipe';
2
+ export * from './param.decorators';
@@ -0,0 +1,313 @@
1
+ import { BadRequestException, ArgumentMetadata } from '@nestjs/common';
2
+ import { ParamValidationPipe } from './param-validation.pipe';
3
+ import { VALIDATION_CONSTANTS } from '../../validation/dismissible-input.dto';
4
+
5
+ describe('ParamValidationPipe', () => {
6
+ let pipe: ParamValidationPipe;
7
+
8
+ beforeEach(() => {
9
+ pipe = new ParamValidationPipe();
10
+ });
11
+
12
+ describe('valid values', () => {
13
+ it('should pass validation for a valid alphanumeric string', () => {
14
+ const metadata: ArgumentMetadata = {
15
+ type: 'param',
16
+ data: 'userId',
17
+ };
18
+ const result = pipe.transform('user123', metadata);
19
+ expect(result).toBe('user123');
20
+ });
21
+
22
+ it('should pass validation for a string with dashes', () => {
23
+ const metadata: ArgumentMetadata = {
24
+ type: 'param',
25
+ data: 'itemId',
26
+ };
27
+ const result = pipe.transform('item-123', metadata);
28
+ expect(result).toBe('item-123');
29
+ });
30
+
31
+ it('should pass validation for a string with underscores', () => {
32
+ const metadata: ArgumentMetadata = {
33
+ type: 'param',
34
+ data: 'userId',
35
+ };
36
+ const result = pipe.transform('user_123', metadata);
37
+ expect(result).toBe('user_123');
38
+ });
39
+
40
+ it('should pass validation for a string with mixed valid characters', () => {
41
+ const metadata: ArgumentMetadata = {
42
+ type: 'param',
43
+ data: 'itemId',
44
+ };
45
+ const result = pipe.transform('item-123_test', metadata);
46
+ expect(result).toBe('item-123_test');
47
+ });
48
+
49
+ it('should pass validation for minimum length (1 character)', () => {
50
+ const metadata: ArgumentMetadata = {
51
+ type: 'param',
52
+ data: 'userId',
53
+ };
54
+ const result = pipe.transform('a', metadata);
55
+ expect(result).toBe('a');
56
+ });
57
+
58
+ it('should pass validation for maximum length (64 characters)', () => {
59
+ const metadata: ArgumentMetadata = {
60
+ type: 'param',
61
+ data: 'itemId',
62
+ };
63
+ const validMaxLength = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH);
64
+ const result = pipe.transform(validMaxLength, metadata);
65
+ expect(result).toBe(validMaxLength);
66
+ });
67
+ });
68
+
69
+ describe('empty or null values', () => {
70
+ it('should throw BadRequestException for empty string', () => {
71
+ const metadata: ArgumentMetadata = {
72
+ type: 'param',
73
+ data: 'userId',
74
+ };
75
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
76
+ expect(() => pipe.transform('', metadata)).toThrow('userId is required');
77
+ });
78
+
79
+ it('should throw BadRequestException for whitespace-only string', () => {
80
+ const metadata: ArgumentMetadata = {
81
+ type: 'param',
82
+ data: 'itemId',
83
+ };
84
+ expect(() => pipe.transform(' ', metadata)).toThrow(BadRequestException);
85
+ expect(() => pipe.transform(' ', metadata)).toThrow('itemId is required');
86
+ });
87
+
88
+ it('should throw BadRequestException for null value', () => {
89
+ const metadata: ArgumentMetadata = {
90
+ type: 'param',
91
+ data: 'userId',
92
+ };
93
+ expect(() => pipe.transform(null as any, metadata)).toThrow(BadRequestException);
94
+ expect(() => pipe.transform(null as any, metadata)).toThrow('userId is required');
95
+ });
96
+
97
+ it('should throw BadRequestException for undefined value', () => {
98
+ const metadata: ArgumentMetadata = {
99
+ type: 'param',
100
+ data: 'itemId',
101
+ };
102
+ expect(() => pipe.transform(undefined as any, metadata)).toThrow(BadRequestException);
103
+ expect(() => pipe.transform(undefined as any, metadata)).toThrow('itemId is required');
104
+ });
105
+
106
+ it('should use default parameter name when metadata.data is not provided', () => {
107
+ const metadata: ArgumentMetadata = {
108
+ type: 'param',
109
+ };
110
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
111
+ expect(() => pipe.transform('', metadata)).toThrow('parameter is required');
112
+ });
113
+ });
114
+
115
+ describe('length validation', () => {
116
+ it('should throw BadRequestException for value below minimum length', () => {
117
+ const metadata: ArgumentMetadata = {
118
+ type: 'param',
119
+ data: 'userId',
120
+ };
121
+ expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
122
+ });
123
+
124
+ it('should throw BadRequestException for value exceeding maximum length', () => {
125
+ const metadata: ArgumentMetadata = {
126
+ type: 'param',
127
+ data: 'itemId',
128
+ };
129
+ const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
130
+ expect(() => pipe.transform(tooLong, metadata)).toThrow(BadRequestException);
131
+ expect(() => pipe.transform(tooLong, metadata)).toThrow(
132
+ `itemId must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
133
+ );
134
+ });
135
+ });
136
+
137
+ describe('pattern validation', () => {
138
+ it('should throw BadRequestException for string with spaces', () => {
139
+ const metadata: ArgumentMetadata = {
140
+ type: 'param',
141
+ data: 'userId',
142
+ };
143
+ expect(() => pipe.transform('user 123', metadata)).toThrow(BadRequestException);
144
+ expect(() => pipe.transform('user 123', metadata)).toThrow(
145
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
146
+ );
147
+ });
148
+
149
+ it('should throw BadRequestException for string with special characters', () => {
150
+ const metadata: ArgumentMetadata = {
151
+ type: 'param',
152
+ data: 'itemId',
153
+ };
154
+ expect(() => pipe.transform('item@123', metadata)).toThrow(BadRequestException);
155
+ expect(() => pipe.transform('item@123', metadata)).toThrow(
156
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
157
+ );
158
+ });
159
+
160
+ it('should throw BadRequestException for string with dots', () => {
161
+ const metadata: ArgumentMetadata = {
162
+ type: 'param',
163
+ data: 'userId',
164
+ };
165
+ expect(() => pipe.transform('user.123', metadata)).toThrow(BadRequestException);
166
+ expect(() => pipe.transform('user.123', metadata)).toThrow(
167
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
168
+ );
169
+ });
170
+
171
+ it('should throw BadRequestException for string with slashes', () => {
172
+ const metadata: ArgumentMetadata = {
173
+ type: 'param',
174
+ data: 'itemId',
175
+ };
176
+ expect(() => pipe.transform('item/123', metadata)).toThrow(BadRequestException);
177
+ expect(() => pipe.transform('item/123', metadata)).toThrow(
178
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
179
+ );
180
+ });
181
+
182
+ it('should throw BadRequestException for string with unicode characters', () => {
183
+ const metadata: ArgumentMetadata = {
184
+ type: 'param',
185
+ data: 'userId',
186
+ };
187
+ expect(() => pipe.transform('userñ123', metadata)).toThrow(BadRequestException);
188
+ expect(() => pipe.transform('userñ123', metadata)).toThrow(
189
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
190
+ );
191
+ });
192
+
193
+ it('should throw BadRequestException for string starting with invalid character', () => {
194
+ const metadata: ArgumentMetadata = {
195
+ type: 'param',
196
+ data: 'itemId',
197
+ };
198
+ expect(() => pipe.transform('@item123', metadata)).toThrow(BadRequestException);
199
+ expect(() => pipe.transform('@item123', metadata)).toThrow(
200
+ `itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
201
+ );
202
+ });
203
+
204
+ it('should throw BadRequestException for string ending with invalid character', () => {
205
+ const metadata: ArgumentMetadata = {
206
+ type: 'param',
207
+ data: 'userId',
208
+ };
209
+ expect(() => pipe.transform('user123#', metadata)).toThrow(BadRequestException);
210
+ expect(() => pipe.transform('user123#', metadata)).toThrow(
211
+ `userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
212
+ );
213
+ });
214
+ });
215
+
216
+ describe('error messages', () => {
217
+ it('should include parameter name in required error message', () => {
218
+ const metadata: ArgumentMetadata = {
219
+ type: 'param',
220
+ data: 'customParam',
221
+ };
222
+ try {
223
+ pipe.transform('', metadata);
224
+ fail('Should have thrown BadRequestException');
225
+ } catch (error) {
226
+ expect(error).toBeInstanceOf(BadRequestException);
227
+ expect((error as BadRequestException).message).toBe('customParam is required');
228
+ }
229
+ });
230
+
231
+ it('should include parameter name in length error message', () => {
232
+ const metadata: ArgumentMetadata = {
233
+ type: 'param',
234
+ data: 'testParam',
235
+ };
236
+ const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
237
+ try {
238
+ pipe.transform(tooLong, metadata);
239
+ fail('Should have thrown BadRequestException');
240
+ } catch (error) {
241
+ expect(error).toBeInstanceOf(BadRequestException);
242
+ expect((error as BadRequestException).message).toContain('testParam');
243
+ expect((error as BadRequestException).message).toContain(
244
+ `must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
245
+ );
246
+ }
247
+ });
248
+
249
+ it('should include parameter name in pattern error message', () => {
250
+ const metadata: ArgumentMetadata = {
251
+ type: 'param',
252
+ data: 'myParam',
253
+ };
254
+ try {
255
+ pipe.transform('invalid@value', metadata);
256
+ fail('Should have thrown BadRequestException');
257
+ } catch (error) {
258
+ expect(error).toBeInstanceOf(BadRequestException);
259
+ expect((error as BadRequestException).message).toContain('myParam');
260
+ expect((error as BadRequestException).message).toContain(
261
+ VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE,
262
+ );
263
+ }
264
+ });
265
+ });
266
+
267
+ describe('edge cases', () => {
268
+ it('should handle numeric-only strings', () => {
269
+ const metadata: ArgumentMetadata = {
270
+ type: 'param',
271
+ data: 'itemId',
272
+ };
273
+ const result = pipe.transform('123456', metadata);
274
+ expect(result).toBe('123456');
275
+ });
276
+
277
+ it('should handle uppercase letters', () => {
278
+ const metadata: ArgumentMetadata = {
279
+ type: 'param',
280
+ data: 'userId',
281
+ };
282
+ const result = pipe.transform('USER123', metadata);
283
+ expect(result).toBe('USER123');
284
+ });
285
+
286
+ it('should handle lowercase letters', () => {
287
+ const metadata: ArgumentMetadata = {
288
+ type: 'param',
289
+ data: 'itemId',
290
+ };
291
+ const result = pipe.transform('user123', metadata);
292
+ expect(result).toBe('user123');
293
+ });
294
+
295
+ it('should handle mixed case letters', () => {
296
+ const metadata: ArgumentMetadata = {
297
+ type: 'param',
298
+ data: 'userId',
299
+ };
300
+ const result = pipe.transform('User123Item', metadata);
301
+ expect(result).toBe('User123Item');
302
+ });
303
+
304
+ it('should handle string with only dashes and underscores', () => {
305
+ const metadata: ArgumentMetadata = {
306
+ type: 'param',
307
+ data: 'itemId',
308
+ };
309
+ const result = pipe.transform('_-', metadata);
310
+ expect(result).toBe('_-');
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,38 @@
1
+ import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';
2
+ import { VALIDATION_CONSTANTS } from '../../validation/dismissible-input.dto';
3
+
4
+ /**
5
+ * Validation pipe for userId and itemId route parameters.
6
+ * Validates:
7
+ * - Required (non-empty)
8
+ * - Length between 1-64 characters
9
+ * - Contains only alphanumeric characters, dashes, and underscores
10
+ */
11
+ @Injectable()
12
+ export class ParamValidationPipe implements PipeTransform<string, string> {
13
+ transform(value: string, metadata: ArgumentMetadata): string {
14
+ const paramName = metadata.data || 'parameter';
15
+
16
+ if (!value || value.trim() === '') {
17
+ throw new BadRequestException(`${paramName} is required`);
18
+ }
19
+
20
+ if (value.length < VALIDATION_CONSTANTS.ID_MIN_LENGTH) {
21
+ throw new BadRequestException(
22
+ `${paramName} must be at least ${VALIDATION_CONSTANTS.ID_MIN_LENGTH} character`,
23
+ );
24
+ }
25
+
26
+ if (value.length > VALIDATION_CONSTANTS.ID_MAX_LENGTH) {
27
+ throw new BadRequestException(
28
+ `${paramName} must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
29
+ );
30
+ }
31
+
32
+ if (!VALIDATION_CONSTANTS.ID_PATTERN.test(value)) {
33
+ throw new BadRequestException(`${paramName} ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`);
34
+ }
35
+
36
+ return value;
37
+ }
38
+ }
@@ -0,0 +1,32 @@
1
+ import { Param } from '@nestjs/common';
2
+ import { ParamValidationPipe } from './param-validation.pipe';
3
+
4
+ /**
5
+ * Custom parameter decorator for userId.
6
+ * Combines @Param('userId') with ParamValidationPipe for validation.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * @Get(':itemId')
11
+ * async getOrCreate(
12
+ * @UserId() userId: string,
13
+ * @ItemId() itemId: string,
14
+ * )
15
+ * ```
16
+ */
17
+ export const UserId = () => Param('userId', ParamValidationPipe);
18
+
19
+ /**
20
+ * Custom parameter decorator for itemId.
21
+ * Combines @Param('itemId') with ParamValidationPipe for validation.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * @Get(':itemId')
26
+ * async getOrCreate(
27
+ * @UserId() userId: string,
28
+ * @ItemId() itemId: string,
29
+ * )
30
+ * ```
31
+ */
32
+ export const ItemId = () => Param('itemId', ParamValidationPipe);
@@ -7,7 +7,6 @@ import {
7
7
  ItemAlreadyDismissedException,
8
8
  ItemNotDismissedException,
9
9
  } from '../exceptions';
10
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
11
10
  import { DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
12
11
  import { IDismissibleLogger } from '@dismissible/nestjs-logger';
13
12
  import { ValidationService } from '@dismissible/nestjs-validation';
@@ -16,8 +15,8 @@ import { DismissibleHelper } from '../utils/dismissible.helper';
16
15
  import { DateService } from '../utils/date/date.service';
17
16
 
18
17
  describe('DismissibleCoreService', () => {
19
- let service: DismissibleCoreService<BaseMetadata>;
20
- let storage: Mock<IDismissibleStorage<BaseMetadata>>;
18
+ let service: DismissibleCoreService;
19
+ let storage: Mock<IDismissibleStorage>;
21
20
  let mockDateService: Mock<DateService>;
22
21
  let mockLogger: Mock<IDismissibleLogger>;
23
22
  let itemFactory: Mock<DismissibleItemFactory>;
@@ -30,7 +29,7 @@ describe('DismissibleCoreService', () => {
30
29
  mockLogger = mock<IDismissibleLogger>({
31
30
  failIfMockNotProvided: false,
32
31
  });
33
- storage = mock<IDismissibleStorage<BaseMetadata>>({
32
+ storage = mock<IDismissibleStorage>({
34
33
  failIfMockNotProvided: false,
35
34
  });
36
35
  dismissibleHelper = mock(DismissibleHelper, { failIfMockNotProvided: false });
@@ -51,6 +50,78 @@ describe('DismissibleCoreService', () => {
51
50
  jest.clearAllMocks();
52
51
  });
53
52
 
53
+ describe('get', () => {
54
+ it('should return item when it exists', async () => {
55
+ const userId = 'user-123';
56
+ const existingItem = createTestItem({ id: 'existing-item', userId });
57
+ storage.get.mockResolvedValue(existingItem);
58
+
59
+ const result = await service.get('existing-item', userId);
60
+
61
+ expect(result).toEqual(existingItem);
62
+ expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
63
+ });
64
+
65
+ it('should return null when item does not exist', async () => {
66
+ const userId = 'user-123';
67
+ storage.get.mockResolvedValue(null);
68
+
69
+ const result = await service.get('non-existent', userId);
70
+
71
+ expect(result).toBeNull();
72
+ expect(storage.get).toHaveBeenCalledWith(userId, 'non-existent');
73
+ });
74
+ });
75
+
76
+ describe('create', () => {
77
+ it('should create a new item', async () => {
78
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
79
+ const userId = 'user-123';
80
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
81
+
82
+ storage.create.mockResolvedValue(newItem);
83
+ mockDateService.getNow.mockReturnValue(testDate);
84
+ itemFactory.create.mockReturnValue(newItem);
85
+
86
+ const result = await service.create('new-item', userId);
87
+
88
+ expect(result.id).toBe('new-item');
89
+ expect(result.userId).toBe(userId);
90
+ expect(result.createdAt).toBeInstanceOf(Date);
91
+ expect(result.dismissedAt).toBeUndefined();
92
+ expect(storage.create).toHaveBeenCalledWith(newItem);
93
+ });
94
+
95
+ it('should validate item before storage', async () => {
96
+ const userId = 'user-123';
97
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
98
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
99
+
100
+ storage.create.mockResolvedValue(newItem);
101
+ mockDateService.getNow.mockReturnValue(testDate);
102
+ itemFactory.create.mockReturnValue(newItem);
103
+
104
+ await service.create('new-item', userId);
105
+
106
+ expect(validationService.validateInstance).toHaveBeenCalledWith(newItem);
107
+ });
108
+
109
+ it('should throw BadRequestException when validation fails', async () => {
110
+ const userId = 'user-123';
111
+ const testDate = new Date('2024-01-15T10:00:00.000Z');
112
+ const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
113
+
114
+ mockDateService.getNow.mockReturnValue(testDate);
115
+ itemFactory.create.mockReturnValue(newItem);
116
+ validationService.validateInstance.mockRejectedValue(
117
+ new BadRequestException('id must be a string'),
118
+ );
119
+
120
+ await expect(service.create('new-item', userId)).rejects.toThrow(BadRequestException);
121
+ expect(storage.create).not.toHaveBeenCalled();
122
+ });
123
+ });
124
+
54
125
  describe('getOrCreate', () => {
55
126
  it('should create a new item when it does not exist', async () => {
56
127
  const testDate = new Date('2024-01-15T10:00:00.000Z');
@@ -83,23 +154,6 @@ describe('DismissibleCoreService', () => {
83
154
  expect(result.item).toEqual(existingItem);
84
155
  expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
85
156
  });
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
157
  });
104
158
 
105
159
  describe('dismiss', () => {
@@ -148,17 +202,14 @@ describe('DismissibleCoreService', () => {
148
202
  const item = createTestItem({
149
203
  id: 'test-item',
150
204
  userId,
151
- metadata: { key: 'value' },
152
205
  });
153
206
  const previousItem = createTestItem({
154
207
  id: 'test-item',
155
208
  userId,
156
- metadata: { key: 'value' },
157
209
  });
158
210
  const dismissedItem = createDismissedTestItem({
159
211
  id: 'test-item',
160
212
  userId,
161
- metadata: { key: 'value' },
162
213
  });
163
214
  const testDate = new Date('2024-01-15T12:00:00.000Z');
164
215
 
@@ -173,7 +224,6 @@ describe('DismissibleCoreService', () => {
173
224
 
174
225
  expect(result.previousItem.id).toBe(item.id);
175
226
  expect(result.previousItem.dismissedAt).toBeUndefined();
176
- expect(result.previousItem.metadata).toEqual({ key: 'value' });
177
227
  });
178
228
  });
179
229
 
@@ -221,17 +271,14 @@ describe('DismissibleCoreService', () => {
221
271
  const dismissedItem = createDismissedTestItem({
222
272
  id: 'dismissed-item',
223
273
  userId,
224
- metadata: { key: 'value' },
225
274
  });
226
275
  const previousItem = createDismissedTestItem({
227
276
  id: 'dismissed-item',
228
277
  userId,
229
- metadata: { key: 'value' },
230
278
  });
231
279
  const restoredItem = createTestItem({
232
280
  id: 'dismissed-item',
233
281
  userId,
234
- metadata: { key: 'value' },
235
282
  });
236
283
 
237
284
  storage.get.mockResolvedValue(dismissedItem);
@@ -244,7 +291,6 @@ describe('DismissibleCoreService', () => {
244
291
 
245
292
  expect(result.previousItem.id).toBe(dismissedItem.id);
246
293
  expect(result.previousItem.dismissedAt).toBeDefined();
247
- expect(result.previousItem.metadata).toEqual({ key: 'value' });
248
294
  });
249
295
  });
250
296