@dismissible/nestjs-dismissible 0.0.2-canary.8976e84.0 → 0.0.2-canary.d2f56d7.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 +51 -67
- package/package.json +4 -4
- package/src/api/dismissible-item-response.dto.ts +0 -8
- package/src/api/dismissible-item.mapper.spec.ts +0 -12
- package/src/api/dismissible-item.mapper.ts +2 -8
- package/src/api/index.ts +3 -0
- package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +1 -2
- package/src/api/use-cases/dismiss/dismiss.controller.ts +8 -8
- package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +2 -42
- package/src/api/use-cases/get-or-create/get-or-create.controller.ts +10 -56
- package/src/api/use-cases/get-or-create/index.ts +0 -1
- package/src/api/use-cases/restore/restore.controller.spec.ts +1 -2
- package/src/api/use-cases/restore/restore.controller.ts +8 -8
- package/src/api/validation/index.ts +2 -0
- package/src/api/validation/param-validation.pipe.spec.ts +317 -0
- package/src/api/validation/param-validation.pipe.ts +42 -0
- package/src/api/validation/param.decorators.ts +32 -0
- package/src/core/dismissible-core.service.spec.ts +75 -29
- package/src/core/dismissible-core.service.ts +40 -24
- package/src/core/dismissible.service.spec.ts +111 -25
- package/src/core/dismissible.service.ts +115 -49
- package/src/core/hook-runner.service.spec.ts +486 -53
- package/src/core/hook-runner.service.ts +144 -18
- package/src/core/index.ts +0 -1
- package/src/core/lifecycle-hook.interface.ts +56 -10
- package/src/core/service-responses.interface.ts +9 -9
- package/src/dismissible.module.integration.spec.ts +685 -0
- package/src/dismissible.module.ts +6 -10
- package/src/events/dismissible.events.ts +16 -39
- package/src/index.ts +1 -0
- package/src/request/request-context.decorator.ts +1 -0
- package/src/request/request-context.interface.ts +6 -0
- package/src/response/http-exception-filter.spec.ts +213 -0
- package/src/response/http-exception-filter.ts +3 -3
- package/src/testing/factories.ts +5 -8
- package/src/utils/dismissible.helper.ts +2 -2
- package/src/validation/dismissible-input.dto.ts +47 -0
- package/src/validation/index.ts +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.spec.json +12 -0
- package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
- package/src/core/create-options.ts +0 -9
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
// Empty string is already handled, but we test with a value that would pass empty check
|
|
122
|
+
// but fail length check (though in practice, empty string is caught first)
|
|
123
|
+
expect(() => pipe.transform('', metadata)).toThrow(BadRequestException);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should throw BadRequestException for value exceeding maximum length', () => {
|
|
127
|
+
const metadata: ArgumentMetadata = {
|
|
128
|
+
type: 'param',
|
|
129
|
+
data: 'itemId',
|
|
130
|
+
};
|
|
131
|
+
const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
|
|
132
|
+
expect(() => pipe.transform(tooLong, metadata)).toThrow(BadRequestException);
|
|
133
|
+
expect(() => pipe.transform(tooLong, metadata)).toThrow(
|
|
134
|
+
`itemId must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('pattern validation', () => {
|
|
140
|
+
it('should throw BadRequestException for string with spaces', () => {
|
|
141
|
+
const metadata: ArgumentMetadata = {
|
|
142
|
+
type: 'param',
|
|
143
|
+
data: 'userId',
|
|
144
|
+
};
|
|
145
|
+
expect(() => pipe.transform('user 123', metadata)).toThrow(BadRequestException);
|
|
146
|
+
expect(() => pipe.transform('user 123', metadata)).toThrow(
|
|
147
|
+
`userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should throw BadRequestException for string with special characters', () => {
|
|
152
|
+
const metadata: ArgumentMetadata = {
|
|
153
|
+
type: 'param',
|
|
154
|
+
data: 'itemId',
|
|
155
|
+
};
|
|
156
|
+
expect(() => pipe.transform('item@123', metadata)).toThrow(BadRequestException);
|
|
157
|
+
expect(() => pipe.transform('item@123', metadata)).toThrow(
|
|
158
|
+
`itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should throw BadRequestException for string with dots', () => {
|
|
163
|
+
const metadata: ArgumentMetadata = {
|
|
164
|
+
type: 'param',
|
|
165
|
+
data: 'userId',
|
|
166
|
+
};
|
|
167
|
+
expect(() => pipe.transform('user.123', metadata)).toThrow(BadRequestException);
|
|
168
|
+
expect(() => pipe.transform('user.123', metadata)).toThrow(
|
|
169
|
+
`userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should throw BadRequestException for string with slashes', () => {
|
|
174
|
+
const metadata: ArgumentMetadata = {
|
|
175
|
+
type: 'param',
|
|
176
|
+
data: 'itemId',
|
|
177
|
+
};
|
|
178
|
+
expect(() => pipe.transform('item/123', metadata)).toThrow(BadRequestException);
|
|
179
|
+
expect(() => pipe.transform('item/123', metadata)).toThrow(
|
|
180
|
+
`itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should throw BadRequestException for string with unicode characters', () => {
|
|
185
|
+
const metadata: ArgumentMetadata = {
|
|
186
|
+
type: 'param',
|
|
187
|
+
data: 'userId',
|
|
188
|
+
};
|
|
189
|
+
expect(() => pipe.transform('userñ123', metadata)).toThrow(BadRequestException);
|
|
190
|
+
expect(() => pipe.transform('userñ123', metadata)).toThrow(
|
|
191
|
+
`userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should throw BadRequestException for string starting with invalid character', () => {
|
|
196
|
+
const metadata: ArgumentMetadata = {
|
|
197
|
+
type: 'param',
|
|
198
|
+
data: 'itemId',
|
|
199
|
+
};
|
|
200
|
+
expect(() => pipe.transform('@item123', metadata)).toThrow(BadRequestException);
|
|
201
|
+
expect(() => pipe.transform('@item123', metadata)).toThrow(
|
|
202
|
+
`itemId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should throw BadRequestException for string ending with invalid character', () => {
|
|
207
|
+
const metadata: ArgumentMetadata = {
|
|
208
|
+
type: 'param',
|
|
209
|
+
data: 'userId',
|
|
210
|
+
};
|
|
211
|
+
expect(() => pipe.transform('user123#', metadata)).toThrow(BadRequestException);
|
|
212
|
+
expect(() => pipe.transform('user123#', metadata)).toThrow(
|
|
213
|
+
`userId ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('error messages', () => {
|
|
219
|
+
it('should include parameter name in required error message', () => {
|
|
220
|
+
const metadata: ArgumentMetadata = {
|
|
221
|
+
type: 'param',
|
|
222
|
+
data: 'customParam',
|
|
223
|
+
};
|
|
224
|
+
try {
|
|
225
|
+
pipe.transform('', metadata);
|
|
226
|
+
fail('Should have thrown BadRequestException');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
229
|
+
expect((error as BadRequestException).message).toBe('customParam is required');
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should include parameter name in length error message', () => {
|
|
234
|
+
const metadata: ArgumentMetadata = {
|
|
235
|
+
type: 'param',
|
|
236
|
+
data: 'testParam',
|
|
237
|
+
};
|
|
238
|
+
const tooLong = 'a'.repeat(VALIDATION_CONSTANTS.ID_MAX_LENGTH + 1);
|
|
239
|
+
try {
|
|
240
|
+
pipe.transform(tooLong, metadata);
|
|
241
|
+
fail('Should have thrown BadRequestException');
|
|
242
|
+
} catch (error) {
|
|
243
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
244
|
+
expect((error as BadRequestException).message).toContain('testParam');
|
|
245
|
+
expect((error as BadRequestException).message).toContain(
|
|
246
|
+
`must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should include parameter name in pattern error message', () => {
|
|
252
|
+
const metadata: ArgumentMetadata = {
|
|
253
|
+
type: 'param',
|
|
254
|
+
data: 'myParam',
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
pipe.transform('invalid@value', metadata);
|
|
258
|
+
fail('Should have thrown BadRequestException');
|
|
259
|
+
} catch (error) {
|
|
260
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
261
|
+
expect((error as BadRequestException).message).toContain('myParam');
|
|
262
|
+
expect((error as BadRequestException).message).toContain(
|
|
263
|
+
VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('edge cases', () => {
|
|
270
|
+
it('should handle numeric-only strings', () => {
|
|
271
|
+
const metadata: ArgumentMetadata = {
|
|
272
|
+
type: 'param',
|
|
273
|
+
data: 'itemId',
|
|
274
|
+
};
|
|
275
|
+
const result = pipe.transform('123456', metadata);
|
|
276
|
+
expect(result).toBe('123456');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should handle uppercase letters', () => {
|
|
280
|
+
const metadata: ArgumentMetadata = {
|
|
281
|
+
type: 'param',
|
|
282
|
+
data: 'userId',
|
|
283
|
+
};
|
|
284
|
+
const result = pipe.transform('USER123', metadata);
|
|
285
|
+
expect(result).toBe('USER123');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should handle lowercase letters', () => {
|
|
289
|
+
const metadata: ArgumentMetadata = {
|
|
290
|
+
type: 'param',
|
|
291
|
+
data: 'itemId',
|
|
292
|
+
};
|
|
293
|
+
const result = pipe.transform('user123', metadata);
|
|
294
|
+
expect(result).toBe('user123');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should handle mixed case letters', () => {
|
|
298
|
+
const metadata: ArgumentMetadata = {
|
|
299
|
+
type: 'param',
|
|
300
|
+
data: 'userId',
|
|
301
|
+
};
|
|
302
|
+
const result = pipe.transform('User123Item', metadata);
|
|
303
|
+
expect(result).toBe('User123Item');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should handle string with only dashes and underscores', () => {
|
|
307
|
+
const metadata: ArgumentMetadata = {
|
|
308
|
+
type: 'param',
|
|
309
|
+
data: 'itemId',
|
|
310
|
+
};
|
|
311
|
+
// This should pass pattern validation but might fail length if too short
|
|
312
|
+
// Since min length is 1, a single dash or underscore should pass
|
|
313
|
+
const result = pipe.transform('_-', metadata);
|
|
314
|
+
expect(result).toBe('_-');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
// Check if value exists
|
|
17
|
+
if (!value || value.trim() === '') {
|
|
18
|
+
throw new BadRequestException(`${paramName} is required`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check minimum length
|
|
22
|
+
if (value.length < VALIDATION_CONSTANTS.ID_MIN_LENGTH) {
|
|
23
|
+
throw new BadRequestException(
|
|
24
|
+
`${paramName} must be at least ${VALIDATION_CONSTANTS.ID_MIN_LENGTH} character`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check maximum length
|
|
29
|
+
if (value.length > VALIDATION_CONSTANTS.ID_MAX_LENGTH) {
|
|
30
|
+
throw new BadRequestException(
|
|
31
|
+
`${paramName} must be at most ${VALIDATION_CONSTANTS.ID_MAX_LENGTH} characters`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check pattern
|
|
36
|
+
if (!VALIDATION_CONSTANTS.ID_PATTERN.test(value)) {
|
|
37
|
+
throw new BadRequestException(`${paramName} ${VALIDATION_CONSTANTS.ID_PATTERN_MESSAGE}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
|
20
|
-
let storage: Mock<IDismissibleStorage
|
|
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
|
|
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
|
|
|
@@ -14,17 +14,16 @@ import {
|
|
|
14
14
|
ItemNotDismissedException,
|
|
15
15
|
} from '../exceptions';
|
|
16
16
|
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
17
|
-
import {
|
|
18
|
-
import { ICreateItemOptions } from './create-options';
|
|
17
|
+
import { DismissibleItemDto, DismissibleItemFactory } from '@dismissible/nestjs-dismissible-item';
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Core business logic service for dismissible operations.
|
|
22
21
|
* Handles pure CRUD operations without side effects (hooks, events).
|
|
23
22
|
*/
|
|
24
23
|
@Injectable()
|
|
25
|
-
export class DismissibleCoreService
|
|
24
|
+
export class DismissibleCoreService {
|
|
26
25
|
constructor(
|
|
27
|
-
@Inject(DISMISSIBLE_STORAGE_ADAPTER) private readonly storage: IDismissibleStorage
|
|
26
|
+
@Inject(DISMISSIBLE_STORAGE_ADAPTER) private readonly storage: IDismissibleStorage,
|
|
28
27
|
private readonly dateService: DateService,
|
|
29
28
|
@Inject(DISMISSIBLE_LOGGER) private readonly logger: IDismissibleLogger,
|
|
30
29
|
private readonly itemFactory: DismissibleItemFactory,
|
|
@@ -33,41 +32,38 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
33
32
|
) {}
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
* Get an existing item
|
|
35
|
+
* Get an existing item by user ID and item ID.
|
|
37
36
|
* @param itemId The item identifier
|
|
38
37
|
* @param userId The user identifier (required)
|
|
39
|
-
* @
|
|
38
|
+
* @returns The item or null if not found
|
|
40
39
|
*/
|
|
41
|
-
async
|
|
42
|
-
itemId: string,
|
|
43
|
-
userId: string,
|
|
44
|
-
options?: ICreateItemOptions<TMetadata>,
|
|
45
|
-
): Promise<IGetOrCreateServiceResponse<TMetadata>> {
|
|
40
|
+
async get(itemId: string, userId: string): Promise<DismissibleItemDto | null> {
|
|
46
41
|
this.logger.debug(`Looking up item in storage`, { itemId, userId });
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (existingItem) {
|
|
42
|
+
const item = await this.storage.get(userId, itemId);
|
|
43
|
+
if (item) {
|
|
51
44
|
this.logger.debug(`Found existing item`, { itemId, userId });
|
|
52
|
-
return {
|
|
53
|
-
item: existingItem,
|
|
54
|
-
created: false,
|
|
55
|
-
};
|
|
56
45
|
}
|
|
46
|
+
return item;
|
|
47
|
+
}
|
|
57
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Create a new item.
|
|
51
|
+
* @param itemId The item identifier
|
|
52
|
+
* @param userId The user identifier (required)
|
|
53
|
+
* @returns The created item
|
|
54
|
+
*/
|
|
55
|
+
async create(itemId: string, userId: string): Promise<DismissibleItemDto> {
|
|
58
56
|
this.logger.debug(`Creating new item`, {
|
|
59
57
|
itemId,
|
|
60
58
|
userId,
|
|
61
|
-
hasMetadata: !!options?.metadata,
|
|
62
59
|
});
|
|
63
60
|
|
|
64
61
|
// Create new item
|
|
65
62
|
const now = this.dateService.getNow();
|
|
66
|
-
const newItem = this.itemFactory.create
|
|
63
|
+
const newItem = this.itemFactory.create({
|
|
67
64
|
id: itemId,
|
|
68
65
|
createdAt: now,
|
|
69
66
|
userId,
|
|
70
|
-
metadata: options?.metadata,
|
|
71
67
|
});
|
|
72
68
|
|
|
73
69
|
// Validate the item before storage
|
|
@@ -77,6 +73,26 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
77
73
|
|
|
78
74
|
this.logger.info(`Created new dismissible item`, { itemId, userId });
|
|
79
75
|
|
|
76
|
+
return createdItem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get an existing item or create a new one.
|
|
81
|
+
* @param itemId The item identifier
|
|
82
|
+
* @param userId The user identifier (required)
|
|
83
|
+
*/
|
|
84
|
+
async getOrCreate(itemId: string, userId: string): Promise<IGetOrCreateServiceResponse> {
|
|
85
|
+
const existingItem = await this.get(itemId, userId);
|
|
86
|
+
|
|
87
|
+
if (existingItem) {
|
|
88
|
+
return {
|
|
89
|
+
item: existingItem,
|
|
90
|
+
created: false,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const createdItem = await this.create(itemId, userId);
|
|
95
|
+
|
|
80
96
|
return {
|
|
81
97
|
item: createdItem,
|
|
82
98
|
created: true,
|
|
@@ -90,7 +106,7 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
90
106
|
* @throws ItemNotFoundException if item doesn't exist
|
|
91
107
|
* @throws ItemAlreadyDismissedException if item is already dismissed
|
|
92
108
|
*/
|
|
93
|
-
async dismiss(itemId: string, userId: string): Promise<IDismissServiceResponse
|
|
109
|
+
async dismiss(itemId: string, userId: string): Promise<IDismissServiceResponse> {
|
|
94
110
|
this.logger.debug(`Attempting to dismiss item`, { itemId, userId });
|
|
95
111
|
|
|
96
112
|
const existingItem = await this.storage.get(userId, itemId);
|
|
@@ -128,7 +144,7 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
128
144
|
* @throws ItemNotFoundException if item doesn't exist
|
|
129
145
|
* @throws ItemNotDismissedException if item is not dismissed
|
|
130
146
|
*/
|
|
131
|
-
async restore(itemId: string, userId: string): Promise<IRestoreServiceResponse
|
|
147
|
+
async restore(itemId: string, userId: string): Promise<IRestoreServiceResponse> {
|
|
132
148
|
this.logger.debug(`Attempting to restore item`, { itemId, userId });
|
|
133
149
|
|
|
134
150
|
const existingItem = await this.storage.get(userId, itemId);
|