@dismissible/nestjs-dismissible 0.0.2-canary.738340d.0 → 0.0.2-canary.c91edbc.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/package.json +8 -5
- package/src/core/dismissible-core.service.spec.ts +88 -0
- package/src/core/dismissible-core.service.ts +49 -16
- package/src/core/dismissible.service.spec.ts +93 -14
- package/src/core/dismissible.service.ts +88 -39
- package/src/core/hook-runner.service.spec.ts +121 -38
- package/src/core/hook-runner.service.ts +132 -6
- package/src/core/lifecycle-hook.interface.ts +50 -4
- package/src/response/http-exception-filter.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dismissible/nestjs-dismissible",
|
|
3
|
-
"version": "0.0.2-canary.
|
|
3
|
+
"version": "0.0.2-canary.c91edbc.0",
|
|
4
4
|
"description": "Dismissible state management library for NestJS applications",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"@nestjs/common": "^11.0.0",
|
|
20
20
|
"@nestjs/core": "^11.0.0",
|
|
21
21
|
"@nestjs/swagger": "^11.0.0",
|
|
22
|
-
"@dismissible/nestjs-dismissible-item": "^0.0.2-canary.
|
|
23
|
-
"@dismissible/nestjs-storage": "^0.0.2-canary.
|
|
22
|
+
"@dismissible/nestjs-dismissible-item": "^0.0.2-canary.c91edbc.0",
|
|
23
|
+
"@dismissible/nestjs-storage": "^0.0.2-canary.c91edbc.0",
|
|
24
24
|
"class-validator": "^0.14.0",
|
|
25
25
|
"class-transformer": "^0.5.0"
|
|
26
26
|
},
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@nestjs/swagger": {
|
|
35
35
|
"optional": false
|
|
36
36
|
},
|
|
37
|
-
"dismissible/nestjs-dismissible-item": {
|
|
37
|
+
"@dismissible/nestjs-dismissible-item": {
|
|
38
38
|
"optional": false
|
|
39
39
|
},
|
|
40
40
|
"@dismissible/nestjs-storage": {
|
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
"license": "MIT",
|
|
59
59
|
"repository": {
|
|
60
60
|
"type": "git",
|
|
61
|
-
"url": ""
|
|
61
|
+
"url": "https://github.com/DismissibleIo/dismissible-api"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -51,6 +51,94 @@ describe('DismissibleCoreService', () => {
|
|
|
51
51
|
jest.clearAllMocks();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
describe('get', () => {
|
|
55
|
+
it('should return item when it exists', async () => {
|
|
56
|
+
const userId = 'user-123';
|
|
57
|
+
const existingItem = createTestItem({ id: 'existing-item', userId });
|
|
58
|
+
storage.get.mockResolvedValue(existingItem);
|
|
59
|
+
|
|
60
|
+
const result = await service.get('existing-item', userId);
|
|
61
|
+
|
|
62
|
+
expect(result).toEqual(existingItem);
|
|
63
|
+
expect(storage.get).toHaveBeenCalledWith(userId, 'existing-item');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return null when item does not exist', async () => {
|
|
67
|
+
const userId = 'user-123';
|
|
68
|
+
storage.get.mockResolvedValue(null);
|
|
69
|
+
|
|
70
|
+
const result = await service.get('non-existent', userId);
|
|
71
|
+
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
expect(storage.get).toHaveBeenCalledWith(userId, 'non-existent');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('create', () => {
|
|
78
|
+
it('should create a new item', async () => {
|
|
79
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
80
|
+
const userId = 'user-123';
|
|
81
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
82
|
+
|
|
83
|
+
storage.create.mockResolvedValue(newItem);
|
|
84
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
85
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
86
|
+
|
|
87
|
+
const result = await service.create('new-item', userId);
|
|
88
|
+
|
|
89
|
+
expect(result.id).toBe('new-item');
|
|
90
|
+
expect(result.userId).toBe(userId);
|
|
91
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
92
|
+
expect(result.dismissedAt).toBeUndefined();
|
|
93
|
+
expect(storage.create).toHaveBeenCalledWith(newItem);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should create item with metadata when provided', async () => {
|
|
97
|
+
const userId = 'user-123';
|
|
98
|
+
const metadata = { version: 2, category: 'test' };
|
|
99
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
100
|
+
const newItem = createTestItem({ id: 'new-item', userId, metadata, createdAt: testDate });
|
|
101
|
+
|
|
102
|
+
storage.create.mockResolvedValue(newItem);
|
|
103
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
104
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
105
|
+
|
|
106
|
+
const result = await service.create('new-item', userId, { metadata });
|
|
107
|
+
|
|
108
|
+
expect(result.metadata).toEqual(metadata);
|
|
109
|
+
expect(storage.create).toHaveBeenCalledWith(newItem);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should validate item before storage', async () => {
|
|
113
|
+
const userId = 'user-123';
|
|
114
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
115
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
116
|
+
|
|
117
|
+
storage.create.mockResolvedValue(newItem);
|
|
118
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
119
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
120
|
+
|
|
121
|
+
await service.create('new-item', userId);
|
|
122
|
+
|
|
123
|
+
expect(validationService.validateInstance).toHaveBeenCalledWith(newItem);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should throw BadRequestException when validation fails', async () => {
|
|
127
|
+
const userId = 'user-123';
|
|
128
|
+
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
129
|
+
const newItem = createTestItem({ id: 'new-item', userId, createdAt: testDate });
|
|
130
|
+
|
|
131
|
+
mockDateService.getNow.mockReturnValue(testDate);
|
|
132
|
+
itemFactory.create.mockReturnValue(newItem);
|
|
133
|
+
validationService.validateInstance.mockRejectedValue(
|
|
134
|
+
new BadRequestException('id must be a string'),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await expect(service.create('new-item', userId)).rejects.toThrow(BadRequestException);
|
|
138
|
+
expect(storage.create).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
54
142
|
describe('getOrCreate', () => {
|
|
55
143
|
it('should create a new item when it does not exist', async () => {
|
|
56
144
|
const testDate = new Date('2024-01-15T10:00:00.000Z');
|
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
ItemNotDismissedException,
|
|
15
15
|
} from '../exceptions';
|
|
16
16
|
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BaseMetadata,
|
|
19
|
+
DismissibleItemDto,
|
|
20
|
+
DismissibleItemFactory,
|
|
21
|
+
} from '@dismissible/nestjs-dismissible-item';
|
|
18
22
|
import { ICreateItemOptions } from './create-options';
|
|
19
23
|
|
|
20
24
|
/**
|
|
@@ -33,28 +37,32 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
33
37
|
) {}
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
* Get an existing item
|
|
40
|
+
* Get an existing item by user ID and item ID.
|
|
37
41
|
* @param itemId The item identifier
|
|
38
42
|
* @param userId The user identifier (required)
|
|
39
|
-
* @
|
|
43
|
+
* @returns The item or null if not found
|
|
40
44
|
*/
|
|
41
|
-
async
|
|
42
|
-
itemId: string,
|
|
43
|
-
userId: string,
|
|
44
|
-
options?: ICreateItemOptions<TMetadata>,
|
|
45
|
-
): Promise<IGetOrCreateServiceResponse<TMetadata>> {
|
|
45
|
+
async get(itemId: string, userId: string): Promise<DismissibleItemDto<TMetadata> | null> {
|
|
46
46
|
this.logger.debug(`Looking up item in storage`, { itemId, userId });
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (existingItem) {
|
|
47
|
+
const item = await this.storage.get(userId, itemId);
|
|
48
|
+
if (item) {
|
|
51
49
|
this.logger.debug(`Found existing item`, { itemId, userId });
|
|
52
|
-
return {
|
|
53
|
-
item: existingItem,
|
|
54
|
-
created: false,
|
|
55
|
-
};
|
|
56
50
|
}
|
|
51
|
+
return item;
|
|
52
|
+
}
|
|
57
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Create a new item.
|
|
56
|
+
* @param itemId The item identifier
|
|
57
|
+
* @param userId The user identifier (required)
|
|
58
|
+
* @param options Optional creation options (metadata)
|
|
59
|
+
* @returns The created item
|
|
60
|
+
*/
|
|
61
|
+
async create(
|
|
62
|
+
itemId: string,
|
|
63
|
+
userId: string,
|
|
64
|
+
options?: ICreateItemOptions<TMetadata>,
|
|
65
|
+
): Promise<DismissibleItemDto<TMetadata>> {
|
|
58
66
|
this.logger.debug(`Creating new item`, {
|
|
59
67
|
itemId,
|
|
60
68
|
userId,
|
|
@@ -77,6 +85,31 @@ export class DismissibleCoreService<TMetadata extends BaseMetadata = BaseMetadat
|
|
|
77
85
|
|
|
78
86
|
this.logger.info(`Created new dismissible item`, { itemId, userId });
|
|
79
87
|
|
|
88
|
+
return createdItem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get an existing item or create a new one.
|
|
93
|
+
* @param itemId The item identifier
|
|
94
|
+
* @param userId The user identifier (required)
|
|
95
|
+
* @param options Optional creation options (metadata)
|
|
96
|
+
*/
|
|
97
|
+
async getOrCreate(
|
|
98
|
+
itemId: string,
|
|
99
|
+
userId: string,
|
|
100
|
+
options?: ICreateItemOptions<TMetadata>,
|
|
101
|
+
): Promise<IGetOrCreateServiceResponse<TMetadata>> {
|
|
102
|
+
const existingItem = await this.get(itemId, userId);
|
|
103
|
+
|
|
104
|
+
if (existingItem) {
|
|
105
|
+
return {
|
|
106
|
+
item: existingItem,
|
|
107
|
+
created: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const createdItem = await this.create(itemId, userId, options);
|
|
112
|
+
|
|
80
113
|
return {
|
|
81
114
|
item: createdItem,
|
|
82
115
|
created: true,
|
|
@@ -34,22 +34,28 @@ describe('DismissibleService', () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
describe('getOrCreate', () => {
|
|
37
|
-
it('should run
|
|
37
|
+
it('should run request and get hooks for existing item', async () => {
|
|
38
38
|
const item = createTestItem({ id: 'existing-item' });
|
|
39
39
|
const context = createTestContext();
|
|
40
40
|
|
|
41
|
-
mockHookRunner.
|
|
42
|
-
|
|
41
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('existing-item'));
|
|
42
|
+
mockHookRunner.runPreGet.mockResolvedValue(createHookResult('existing-item'));
|
|
43
|
+
mockCoreService.get.mockResolvedValue(item);
|
|
43
44
|
|
|
44
45
|
const result = await service.getOrCreate('existing-item', testUserId, undefined, context);
|
|
45
46
|
|
|
46
|
-
expect(mockHookRunner.
|
|
47
|
-
expect(mockCoreService.
|
|
47
|
+
expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
|
|
48
|
+
expect(mockCoreService.get).toHaveBeenCalledWith('existing-item', testUserId);
|
|
49
|
+
expect(mockHookRunner.runPreGet).toHaveBeenCalledWith(
|
|
48
50
|
'existing-item',
|
|
51
|
+
item,
|
|
49
52
|
testUserId,
|
|
50
|
-
|
|
53
|
+
expect.anything(),
|
|
51
54
|
);
|
|
52
|
-
expect(mockHookRunner.
|
|
55
|
+
expect(mockHookRunner.runPostGet).toHaveBeenCalled();
|
|
56
|
+
expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
|
|
57
|
+
expect(mockCoreService.create).not.toHaveBeenCalled();
|
|
58
|
+
expect(mockHookRunner.runPreCreate).not.toHaveBeenCalled();
|
|
53
59
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
54
60
|
DismissibleEvents.ITEM_RETRIEVED,
|
|
55
61
|
expect.anything(),
|
|
@@ -57,24 +63,90 @@ describe('DismissibleService', () => {
|
|
|
57
63
|
expect(result.created).toBe(false);
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
it('should run create hooks
|
|
66
|
+
it('should run pre-create hooks BEFORE creating for new item', async () => {
|
|
61
67
|
const item = createTestItem({ id: 'new-item' });
|
|
62
68
|
const context = createTestContext();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
mockHookRunner.
|
|
66
|
-
mockCoreService.
|
|
69
|
+
const callOrder: string[] = [];
|
|
70
|
+
|
|
71
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('new-item'));
|
|
72
|
+
mockCoreService.get.mockResolvedValue(null);
|
|
73
|
+
mockHookRunner.runPreCreate.mockImplementation(async () => {
|
|
74
|
+
callOrder.push('runPreCreate');
|
|
75
|
+
return createHookResult('new-item');
|
|
76
|
+
});
|
|
77
|
+
mockCoreService.create.mockImplementation(async () => {
|
|
78
|
+
callOrder.push('create');
|
|
79
|
+
return item;
|
|
80
|
+
});
|
|
67
81
|
|
|
68
82
|
const result = await service.getOrCreate('new-item', testUserId, undefined, context);
|
|
69
83
|
|
|
84
|
+
// Verify pre-create hooks run BEFORE create
|
|
85
|
+
expect(callOrder).toEqual(['runPreCreate', 'create']);
|
|
86
|
+
expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
|
|
70
87
|
expect(mockHookRunner.runPreCreate).toHaveBeenCalled();
|
|
88
|
+
expect(mockCoreService.create).toHaveBeenCalledWith('new-item', testUserId, undefined);
|
|
71
89
|
expect(mockHookRunner.runPostCreate).toHaveBeenCalled();
|
|
90
|
+
expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
|
|
72
91
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
73
92
|
DismissibleEvents.ITEM_CREATED,
|
|
74
93
|
expect.anything(),
|
|
75
94
|
);
|
|
76
95
|
expect(result.created).toBe(true);
|
|
77
96
|
});
|
|
97
|
+
|
|
98
|
+
it('should NOT create item when pre-create hook blocks the operation', async () => {
|
|
99
|
+
const context = createTestContext();
|
|
100
|
+
|
|
101
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('new-item'));
|
|
102
|
+
mockCoreService.get.mockResolvedValue(null);
|
|
103
|
+
mockHookRunner.runPreCreate.mockResolvedValue({
|
|
104
|
+
proceed: false,
|
|
105
|
+
reason: 'Plan limit reached',
|
|
106
|
+
id: 'new-item',
|
|
107
|
+
userId: testUserId,
|
|
108
|
+
context: createTestContext(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
service.getOrCreate('new-item', testUserId, undefined, context),
|
|
113
|
+
).rejects.toThrow();
|
|
114
|
+
|
|
115
|
+
// Verify create was NOT called because pre-create hook blocked it
|
|
116
|
+
expect(mockCoreService.create).not.toHaveBeenCalled();
|
|
117
|
+
expect(mockHookRunner.runPostCreate).not.toHaveBeenCalled();
|
|
118
|
+
expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
|
|
119
|
+
DismissibleEvents.ITEM_CREATED,
|
|
120
|
+
expect.anything(),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should NOT return existing item when pre-get hook blocks the operation', async () => {
|
|
125
|
+
const item = createTestItem({ id: 'existing-item' });
|
|
126
|
+
const context = createTestContext();
|
|
127
|
+
|
|
128
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('existing-item'));
|
|
129
|
+
mockCoreService.get.mockResolvedValue(item);
|
|
130
|
+
mockHookRunner.runPreGet.mockResolvedValue({
|
|
131
|
+
proceed: false,
|
|
132
|
+
reason: 'Item access denied',
|
|
133
|
+
id: 'existing-item',
|
|
134
|
+
userId: testUserId,
|
|
135
|
+
context: createTestContext(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
service.getOrCreate('existing-item', testUserId, undefined, context),
|
|
140
|
+
).rejects.toThrow();
|
|
141
|
+
|
|
142
|
+
// Verify post-get hooks were NOT called because pre-get hook blocked
|
|
143
|
+
expect(mockHookRunner.runPostGet).not.toHaveBeenCalled();
|
|
144
|
+
expect(mockHookRunner.runPostRequest).not.toHaveBeenCalled();
|
|
145
|
+
expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
|
|
146
|
+
DismissibleEvents.ITEM_RETRIEVED,
|
|
147
|
+
expect.anything(),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
78
150
|
});
|
|
79
151
|
|
|
80
152
|
describe('dismiss', () => {
|
|
@@ -83,14 +155,17 @@ describe('DismissibleService', () => {
|
|
|
83
155
|
const previousItem = createTestItem({ id: 'test-item' });
|
|
84
156
|
const context = createTestContext();
|
|
85
157
|
|
|
158
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
|
|
86
159
|
mockHookRunner.runPreDismiss.mockResolvedValue(createHookResult('test-item'));
|
|
87
160
|
mockCoreService.dismiss.mockResolvedValue({ item, previousItem });
|
|
88
161
|
|
|
89
162
|
const result = await service.dismiss('test-item', testUserId, context);
|
|
90
163
|
|
|
164
|
+
expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
|
|
91
165
|
expect(mockHookRunner.runPreDismiss).toHaveBeenCalled();
|
|
92
166
|
expect(mockCoreService.dismiss).toHaveBeenCalledWith('test-item', testUserId);
|
|
93
167
|
expect(mockHookRunner.runPostDismiss).toHaveBeenCalled();
|
|
168
|
+
expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
|
|
94
169
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
95
170
|
DismissibleEvents.ITEM_DISMISSED,
|
|
96
171
|
expect.anything(),
|
|
@@ -105,14 +180,17 @@ describe('DismissibleService', () => {
|
|
|
105
180
|
const previousItem = createTestItem({ id: 'test-item' });
|
|
106
181
|
const context = createTestContext();
|
|
107
182
|
|
|
183
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
|
|
108
184
|
mockHookRunner.runPreRestore.mockResolvedValue(createHookResult('test-item'));
|
|
109
185
|
mockCoreService.restore.mockResolvedValue({ item, previousItem });
|
|
110
186
|
|
|
111
187
|
const result = await service.restore('test-item', testUserId, context);
|
|
112
188
|
|
|
189
|
+
expect(mockHookRunner.runPreRequest).toHaveBeenCalled();
|
|
113
190
|
expect(mockHookRunner.runPreRestore).toHaveBeenCalled();
|
|
114
191
|
expect(mockCoreService.restore).toHaveBeenCalledWith('test-item', testUserId);
|
|
115
192
|
expect(mockHookRunner.runPostRestore).toHaveBeenCalled();
|
|
193
|
+
expect(mockHookRunner.runPostRequest).toHaveBeenCalled();
|
|
116
194
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
117
195
|
DismissibleEvents.ITEM_RESTORED,
|
|
118
196
|
expect.anything(),
|
|
@@ -126,8 +204,9 @@ describe('DismissibleService', () => {
|
|
|
126
204
|
const item = createTestItem({ id: 'test-item' });
|
|
127
205
|
const context = createTestContext();
|
|
128
206
|
|
|
129
|
-
mockHookRunner.
|
|
130
|
-
|
|
207
|
+
mockHookRunner.runPreRequest.mockResolvedValue(createHookResult('test-item'));
|
|
208
|
+
mockHookRunner.runPreGet.mockResolvedValue(createHookResult('test-item'));
|
|
209
|
+
mockCoreService.get.mockResolvedValue(item);
|
|
131
210
|
|
|
132
211
|
await service.getOrCreate('test-item', testUserId, undefined, context);
|
|
133
212
|
|
|
@@ -48,54 +48,81 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
48
48
|
): Promise<IGetOrCreateServiceResponse<TMetadata>> {
|
|
49
49
|
this.logger.debug(`getOrCreate called`, { itemId, userId });
|
|
50
50
|
|
|
51
|
-
// Run pre-
|
|
52
|
-
const preResult = await this.hookRunner.
|
|
51
|
+
// Run global pre-request hooks (auth, rate limiting, validation)
|
|
52
|
+
const preResult = await this.hookRunner.runPreRequest(itemId, userId, context);
|
|
53
53
|
HookRunner.throwIfBlocked(preResult);
|
|
54
54
|
|
|
55
55
|
const resolvedId = preResult.id;
|
|
56
56
|
const resolvedUserId = preResult.userId;
|
|
57
57
|
const resolvedContext = preResult.context;
|
|
58
58
|
|
|
59
|
-
// Check if
|
|
60
|
-
const
|
|
59
|
+
// Check if item already exists
|
|
60
|
+
const existingItem = await this.coreService.get(resolvedId, resolvedUserId);
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const preCreateResult = await this.hookRunner.runPreCreate(
|
|
62
|
+
if (existingItem) {
|
|
63
|
+
// Item exists - run pre-get hooks (access control based on item state)
|
|
64
|
+
const preGetResult = await this.hookRunner.runPreGet(
|
|
66
65
|
resolvedId,
|
|
66
|
+
existingItem,
|
|
67
67
|
resolvedUserId,
|
|
68
68
|
resolvedContext,
|
|
69
69
|
);
|
|
70
|
-
HookRunner.throwIfBlocked(
|
|
70
|
+
HookRunner.throwIfBlocked(preGetResult);
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
await this.hookRunner.runPostCreate(resolvedId, result.item, resolvedUserId, resolvedContext);
|
|
74
|
-
|
|
75
|
-
// Emit created event (async)
|
|
76
|
-
this.eventEmitter.emit(
|
|
77
|
-
DismissibleEvents.ITEM_CREATED,
|
|
78
|
-
new ItemCreatedEvent(resolvedId, result.item, resolvedUserId, resolvedContext),
|
|
79
|
-
);
|
|
80
|
-
} else {
|
|
81
|
-
// Emit retrieved event (async)
|
|
72
|
+
// Emit retrieved event
|
|
82
73
|
this.eventEmitter.emit(
|
|
83
74
|
DismissibleEvents.ITEM_RETRIEVED,
|
|
84
|
-
new ItemRetrievedEvent(resolvedId,
|
|
75
|
+
new ItemRetrievedEvent(resolvedId, existingItem, resolvedUserId, resolvedContext),
|
|
85
76
|
);
|
|
77
|
+
|
|
78
|
+
// Run post-get hooks
|
|
79
|
+
await this.hookRunner.runPostGet(resolvedId, existingItem, resolvedUserId, resolvedContext);
|
|
80
|
+
|
|
81
|
+
// Run global post-request hooks
|
|
82
|
+
await this.hookRunner.runPostRequest(
|
|
83
|
+
resolvedId,
|
|
84
|
+
existingItem,
|
|
85
|
+
resolvedUserId,
|
|
86
|
+
resolvedContext,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
this.logger.debug(`getOrCreate completed`, { itemId, created: false });
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
item: existingItem,
|
|
93
|
+
created: false,
|
|
94
|
+
};
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
//
|
|
89
|
-
await this.hookRunner.
|
|
97
|
+
// Item doesn't exist - run pre-create hooks BEFORE creating
|
|
98
|
+
const preCreateResult = await this.hookRunner.runPreCreate(
|
|
90
99
|
resolvedId,
|
|
91
|
-
result.item,
|
|
92
100
|
resolvedUserId,
|
|
93
101
|
resolvedContext,
|
|
94
102
|
);
|
|
103
|
+
HookRunner.throwIfBlocked(preCreateResult);
|
|
95
104
|
|
|
96
|
-
|
|
105
|
+
// Now create the item
|
|
106
|
+
const createdItem = await this.coreService.create(resolvedId, resolvedUserId, options);
|
|
97
107
|
|
|
98
|
-
|
|
108
|
+
// Run post-create hooks
|
|
109
|
+
await this.hookRunner.runPostCreate(resolvedId, createdItem, resolvedUserId, resolvedContext);
|
|
110
|
+
|
|
111
|
+
// Emit created event
|
|
112
|
+
this.eventEmitter.emit(
|
|
113
|
+
DismissibleEvents.ITEM_CREATED,
|
|
114
|
+
new ItemCreatedEvent(resolvedId, createdItem, resolvedUserId, resolvedContext),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Run global post-request hooks
|
|
118
|
+
await this.hookRunner.runPostRequest(resolvedId, createdItem, resolvedUserId, resolvedContext);
|
|
119
|
+
|
|
120
|
+
this.logger.debug(`getOrCreate completed`, { itemId, created: true });
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
item: createdItem,
|
|
124
|
+
created: true,
|
|
125
|
+
};
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
/**
|
|
@@ -111,13 +138,21 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
111
138
|
): Promise<IDismissServiceResponse<TMetadata>> {
|
|
112
139
|
this.logger.debug(`dismiss called`, { itemId, userId });
|
|
113
140
|
|
|
114
|
-
// Run pre-
|
|
115
|
-
const
|
|
116
|
-
HookRunner.throwIfBlocked(
|
|
141
|
+
// Run global pre-request hooks (auth, rate limiting, validation)
|
|
142
|
+
const preRequestResult = await this.hookRunner.runPreRequest(itemId, userId, context);
|
|
143
|
+
HookRunner.throwIfBlocked(preRequestResult);
|
|
117
144
|
|
|
118
|
-
const resolvedId =
|
|
119
|
-
const resolvedUserId =
|
|
120
|
-
const resolvedContext =
|
|
145
|
+
const resolvedId = preRequestResult.id;
|
|
146
|
+
const resolvedUserId = preRequestResult.userId;
|
|
147
|
+
const resolvedContext = preRequestResult.context;
|
|
148
|
+
|
|
149
|
+
// Run pre-dismiss hooks
|
|
150
|
+
const preDismissResult = await this.hookRunner.runPreDismiss(
|
|
151
|
+
resolvedId,
|
|
152
|
+
resolvedUserId,
|
|
153
|
+
resolvedContext,
|
|
154
|
+
);
|
|
155
|
+
HookRunner.throwIfBlocked(preDismissResult);
|
|
121
156
|
|
|
122
157
|
// Execute core dismiss operation
|
|
123
158
|
const result = await this.coreService.dismiss(resolvedId, resolvedUserId);
|
|
@@ -125,7 +160,7 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
125
160
|
// Run post-dismiss hooks
|
|
126
161
|
await this.hookRunner.runPostDismiss(resolvedId, result.item, resolvedUserId, resolvedContext);
|
|
127
162
|
|
|
128
|
-
// Emit dismissed event
|
|
163
|
+
// Emit dismissed event
|
|
129
164
|
this.eventEmitter.emit(
|
|
130
165
|
DismissibleEvents.ITEM_DISMISSED,
|
|
131
166
|
new ItemDismissedEvent(
|
|
@@ -137,6 +172,9 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
137
172
|
),
|
|
138
173
|
);
|
|
139
174
|
|
|
175
|
+
// Run global post-request hooks
|
|
176
|
+
await this.hookRunner.runPostRequest(resolvedId, result.item, resolvedUserId, resolvedContext);
|
|
177
|
+
|
|
140
178
|
this.logger.debug(`dismiss completed`, { itemId });
|
|
141
179
|
|
|
142
180
|
return result;
|
|
@@ -155,13 +193,21 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
155
193
|
): Promise<IRestoreServiceResponse<TMetadata>> {
|
|
156
194
|
this.logger.debug(`restore called`, { itemId, userId });
|
|
157
195
|
|
|
158
|
-
// Run pre-
|
|
159
|
-
const
|
|
160
|
-
HookRunner.throwIfBlocked(
|
|
196
|
+
// Run global pre-request hooks (auth, rate limiting, validation)
|
|
197
|
+
const preRequestResult = await this.hookRunner.runPreRequest(itemId, userId, context);
|
|
198
|
+
HookRunner.throwIfBlocked(preRequestResult);
|
|
161
199
|
|
|
162
|
-
const resolvedId =
|
|
163
|
-
const resolvedUserId =
|
|
164
|
-
const resolvedContext =
|
|
200
|
+
const resolvedId = preRequestResult.id;
|
|
201
|
+
const resolvedUserId = preRequestResult.userId;
|
|
202
|
+
const resolvedContext = preRequestResult.context;
|
|
203
|
+
|
|
204
|
+
// Run pre-restore hooks
|
|
205
|
+
const preRestoreResult = await this.hookRunner.runPreRestore(
|
|
206
|
+
resolvedId,
|
|
207
|
+
resolvedUserId,
|
|
208
|
+
resolvedContext,
|
|
209
|
+
);
|
|
210
|
+
HookRunner.throwIfBlocked(preRestoreResult);
|
|
165
211
|
|
|
166
212
|
// Execute core restore operation
|
|
167
213
|
const result = await this.coreService.restore(resolvedId, resolvedUserId);
|
|
@@ -169,7 +215,7 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
169
215
|
// Run post-restore hooks
|
|
170
216
|
await this.hookRunner.runPostRestore(resolvedId, result.item, resolvedUserId, resolvedContext);
|
|
171
217
|
|
|
172
|
-
// Emit restored event
|
|
218
|
+
// Emit restored event
|
|
173
219
|
this.eventEmitter.emit(
|
|
174
220
|
DismissibleEvents.ITEM_RESTORED,
|
|
175
221
|
new ItemRestoredEvent(
|
|
@@ -181,6 +227,9 @@ export class DismissibleService<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
181
227
|
),
|
|
182
228
|
);
|
|
183
229
|
|
|
230
|
+
// Run global post-request hooks
|
|
231
|
+
await this.hookRunner.runPostRequest(resolvedId, result.item, resolvedUserId, resolvedContext);
|
|
232
|
+
|
|
184
233
|
this.logger.debug(`restore completed`, { itemId });
|
|
185
234
|
|
|
186
235
|
return result;
|
|
@@ -21,9 +21,9 @@ describe('HookRunner', () => {
|
|
|
21
21
|
hookRunner = new HookRunner([], mockLogger);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it('should return proceed: true for pre-
|
|
24
|
+
it('should return proceed: true for pre-request', async () => {
|
|
25
25
|
const context = createTestContext();
|
|
26
|
-
const result = await hookRunner.
|
|
26
|
+
const result = await hookRunner.runPreRequest('test-id', testUserId, context);
|
|
27
27
|
|
|
28
28
|
expect(result.proceed).toBe(true);
|
|
29
29
|
expect(result.id).toBe('test-id');
|
|
@@ -31,12 +31,12 @@ describe('HookRunner', () => {
|
|
|
31
31
|
expect(result.context).toEqual(context);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it('should complete post-
|
|
34
|
+
it('should complete post-request without error', async () => {
|
|
35
35
|
const item = createTestItem();
|
|
36
36
|
const context = createTestContext();
|
|
37
37
|
|
|
38
38
|
await expect(
|
|
39
|
-
hookRunner.
|
|
39
|
+
hookRunner.runPostRequest('test-id', item, testUserId, context),
|
|
40
40
|
).resolves.not.toThrow();
|
|
41
41
|
});
|
|
42
42
|
});
|
|
@@ -47,7 +47,7 @@ describe('HookRunner', () => {
|
|
|
47
47
|
|
|
48
48
|
const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
49
49
|
priority: 10,
|
|
50
|
-
|
|
50
|
+
onBeforeRequest: jest.fn(async () => {
|
|
51
51
|
executionOrder.push(10);
|
|
52
52
|
return { proceed: true };
|
|
53
53
|
}),
|
|
@@ -55,7 +55,7 @@ describe('HookRunner', () => {
|
|
|
55
55
|
|
|
56
56
|
const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
57
57
|
priority: 5,
|
|
58
|
-
|
|
58
|
+
onBeforeRequest: jest.fn(async () => {
|
|
59
59
|
executionOrder.push(5);
|
|
60
60
|
return { proceed: true };
|
|
61
61
|
}),
|
|
@@ -63,14 +63,14 @@ describe('HookRunner', () => {
|
|
|
63
63
|
|
|
64
64
|
const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
65
65
|
priority: 15,
|
|
66
|
-
|
|
66
|
+
onBeforeRequest: jest.fn(async () => {
|
|
67
67
|
executionOrder.push(15);
|
|
68
68
|
return { proceed: true };
|
|
69
69
|
}),
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
|
|
73
|
-
await hookRunner.
|
|
73
|
+
await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
|
|
74
74
|
|
|
75
75
|
expect(executionOrder).toEqual([5, 10, 15]);
|
|
76
76
|
});
|
|
@@ -80,46 +80,41 @@ describe('HookRunner', () => {
|
|
|
80
80
|
|
|
81
81
|
const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
82
82
|
priority: 10,
|
|
83
|
-
|
|
83
|
+
onAfterRequest: jest.fn(async () => {
|
|
84
84
|
executionOrder.push(10);
|
|
85
85
|
}),
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
89
89
|
priority: 5,
|
|
90
|
-
|
|
90
|
+
onAfterRequest: jest.fn(async () => {
|
|
91
91
|
executionOrder.push(5);
|
|
92
92
|
}),
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
96
96
|
priority: 15,
|
|
97
|
-
|
|
97
|
+
onAfterRequest: jest.fn(async () => {
|
|
98
98
|
executionOrder.push(15);
|
|
99
99
|
}),
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
|
|
103
|
-
await hookRunner.
|
|
104
|
-
'test-id',
|
|
105
|
-
createTestItem(),
|
|
106
|
-
testUserId,
|
|
107
|
-
createTestContext(),
|
|
108
|
-
);
|
|
103
|
+
await hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext());
|
|
109
104
|
|
|
110
105
|
expect(executionOrder).toEqual([15, 10, 5]);
|
|
111
106
|
});
|
|
112
107
|
|
|
113
108
|
it('should block operation when pre-hook returns proceed: false', async () => {
|
|
114
109
|
const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
115
|
-
|
|
110
|
+
onBeforeRequest: jest.fn(async () => ({
|
|
116
111
|
proceed: false,
|
|
117
112
|
reason: 'Rate limit exceeded',
|
|
118
113
|
})),
|
|
119
114
|
};
|
|
120
115
|
|
|
121
116
|
hookRunner = new HookRunner([blockingHook], mockLogger);
|
|
122
|
-
const result = await hookRunner.
|
|
117
|
+
const result = await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
|
|
123
118
|
|
|
124
119
|
expect(result.proceed).toBe(false);
|
|
125
120
|
expect(result.reason).toBe('Rate limit exceeded');
|
|
@@ -127,7 +122,7 @@ describe('HookRunner', () => {
|
|
|
127
122
|
|
|
128
123
|
it('should apply mutations from pre-hooks', async () => {
|
|
129
124
|
const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
130
|
-
|
|
125
|
+
onBeforeRequest: jest.fn(async () => ({
|
|
131
126
|
proceed: true,
|
|
132
127
|
mutations: {
|
|
133
128
|
id: 'mutated-id',
|
|
@@ -137,11 +132,7 @@ describe('HookRunner', () => {
|
|
|
137
132
|
};
|
|
138
133
|
|
|
139
134
|
hookRunner = new HookRunner([mutatingHook], mockLogger);
|
|
140
|
-
const result = await hookRunner.
|
|
141
|
-
'original-id',
|
|
142
|
-
testUserId,
|
|
143
|
-
createTestContext(),
|
|
144
|
-
);
|
|
135
|
+
const result = await hookRunner.runPreRequest('original-id', testUserId, createTestContext());
|
|
145
136
|
|
|
146
137
|
expect(result.id).toBe('mutated-id');
|
|
147
138
|
expect(result.userId).toBe('mutated-user');
|
|
@@ -150,7 +141,7 @@ describe('HookRunner', () => {
|
|
|
150
141
|
it('should pass mutations through multiple hooks', async () => {
|
|
151
142
|
const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
152
143
|
priority: 1,
|
|
153
|
-
|
|
144
|
+
onBeforeRequest: jest.fn(async (itemId) => ({
|
|
154
145
|
proceed: true,
|
|
155
146
|
mutations: { id: `${itemId}-hook1` },
|
|
156
147
|
})),
|
|
@@ -158,18 +149,14 @@ describe('HookRunner', () => {
|
|
|
158
149
|
|
|
159
150
|
const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
160
151
|
priority: 2,
|
|
161
|
-
|
|
152
|
+
onBeforeRequest: jest.fn(async (itemId) => ({
|
|
162
153
|
proceed: true,
|
|
163
154
|
mutations: { id: `${itemId}-hook2` },
|
|
164
155
|
})),
|
|
165
156
|
};
|
|
166
157
|
|
|
167
158
|
hookRunner = new HookRunner([hook1, hook2], mockLogger);
|
|
168
|
-
const result = await hookRunner.
|
|
169
|
-
'original',
|
|
170
|
-
testUserId,
|
|
171
|
-
createTestContext(),
|
|
172
|
-
);
|
|
159
|
+
const result = await hookRunner.runPreRequest('original', testUserId, createTestContext());
|
|
173
160
|
|
|
174
161
|
expect(result.id).toBe('original-hook1-hook2');
|
|
175
162
|
});
|
|
@@ -178,7 +165,7 @@ describe('HookRunner', () => {
|
|
|
178
165
|
describe('error handling', () => {
|
|
179
166
|
it('should throw error from pre-hook', async () => {
|
|
180
167
|
const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
181
|
-
|
|
168
|
+
onBeforeRequest: jest.fn(async () => {
|
|
182
169
|
throw new Error('Hook error');
|
|
183
170
|
}),
|
|
184
171
|
};
|
|
@@ -186,7 +173,7 @@ describe('HookRunner', () => {
|
|
|
186
173
|
hookRunner = new HookRunner([errorHook], mockLogger);
|
|
187
174
|
|
|
188
175
|
await expect(
|
|
189
|
-
hookRunner.
|
|
176
|
+
hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
|
|
190
177
|
).rejects.toThrow('Hook error');
|
|
191
178
|
|
|
192
179
|
expect(mockLogger.error).toHaveBeenCalled();
|
|
@@ -194,7 +181,7 @@ describe('HookRunner', () => {
|
|
|
194
181
|
|
|
195
182
|
it('should log but not throw errors from post-hooks', async () => {
|
|
196
183
|
const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
197
|
-
|
|
184
|
+
onAfterRequest: jest.fn(async () => {
|
|
198
185
|
throw new Error('Post-hook error');
|
|
199
186
|
}),
|
|
200
187
|
};
|
|
@@ -202,7 +189,7 @@ describe('HookRunner', () => {
|
|
|
202
189
|
hookRunner = new HookRunner([errorHook], mockLogger);
|
|
203
190
|
|
|
204
191
|
await expect(
|
|
205
|
-
hookRunner.
|
|
192
|
+
hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
|
|
206
193
|
).resolves.not.toThrow();
|
|
207
194
|
|
|
208
195
|
expect(mockLogger.error).toHaveBeenCalled();
|
|
@@ -234,14 +221,83 @@ describe('HookRunner', () => {
|
|
|
234
221
|
});
|
|
235
222
|
});
|
|
236
223
|
|
|
224
|
+
describe('runPreGet and runPostGet', () => {
|
|
225
|
+
it('should pass item to onBeforeGet hook', async () => {
|
|
226
|
+
const item = createTestItem();
|
|
227
|
+
const context = createTestContext();
|
|
228
|
+
|
|
229
|
+
const hook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
230
|
+
onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
hookRunner = new HookRunner([hook], mockLogger);
|
|
234
|
+
await hookRunner.runPreGet('test-id', item, testUserId, context);
|
|
235
|
+
|
|
236
|
+
expect(hook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should block operation when onBeforeGet returns proceed: false', async () => {
|
|
240
|
+
const item = createTestItem();
|
|
241
|
+
const context = createTestContext();
|
|
242
|
+
|
|
243
|
+
const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
244
|
+
onBeforeGet: jest.fn().mockResolvedValue({
|
|
245
|
+
proceed: false,
|
|
246
|
+
reason: 'Item is in invalid state',
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
hookRunner = new HookRunner([blockingHook], mockLogger);
|
|
251
|
+
const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
|
|
252
|
+
|
|
253
|
+
expect(result.proceed).toBe(false);
|
|
254
|
+
expect(result.reason).toBe('Item is in invalid state');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should run onAfterGet hook', async () => {
|
|
258
|
+
const item = createTestItem();
|
|
259
|
+
const context = createTestContext();
|
|
260
|
+
|
|
261
|
+
const hook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
262
|
+
onAfterGet: jest.fn(),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
hookRunner = new HookRunner([hook], mockLogger);
|
|
266
|
+
await hookRunner.runPostGet('test-id', item, testUserId, context);
|
|
267
|
+
|
|
268
|
+
expect(hook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should apply mutations from onBeforeGet hook', async () => {
|
|
272
|
+
const item = createTestItem();
|
|
273
|
+
const context = createTestContext();
|
|
274
|
+
|
|
275
|
+
const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
|
|
276
|
+
onBeforeGet: jest.fn().mockResolvedValue({
|
|
277
|
+
proceed: true,
|
|
278
|
+
mutations: {
|
|
279
|
+
id: 'mutated-id',
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
hookRunner = new HookRunner([mutatingHook], mockLogger);
|
|
285
|
+
const result = await hookRunner.runPreGet('original-id', item, testUserId, context);
|
|
286
|
+
|
|
287
|
+
expect(result.id).toBe('mutated-id');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
237
291
|
describe('all hook methods', () => {
|
|
238
292
|
let allMethodsHook: IDismissibleLifecycleHook<BaseMetadata>;
|
|
239
293
|
|
|
240
294
|
beforeEach(() => {
|
|
241
295
|
allMethodsHook = {
|
|
242
296
|
priority: 0,
|
|
243
|
-
|
|
244
|
-
|
|
297
|
+
onBeforeRequest: jest.fn().mockResolvedValue({ proceed: true }),
|
|
298
|
+
onAfterRequest: jest.fn(),
|
|
299
|
+
onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
|
|
300
|
+
onAfterGet: jest.fn(),
|
|
245
301
|
onBeforeCreate: jest.fn().mockResolvedValue({ proceed: true }),
|
|
246
302
|
onAfterCreate: jest.fn(),
|
|
247
303
|
onBeforeDismiss: jest.fn().mockResolvedValue({ proceed: true }),
|
|
@@ -253,6 +309,33 @@ describe('HookRunner', () => {
|
|
|
253
309
|
hookRunner = new HookRunner([allMethodsHook], mockLogger);
|
|
254
310
|
});
|
|
255
311
|
|
|
312
|
+
it('should run pre and post request hooks', async () => {
|
|
313
|
+
const context = createTestContext();
|
|
314
|
+
const item = createTestItem();
|
|
315
|
+
|
|
316
|
+
await hookRunner.runPreRequest('test-id', testUserId, context);
|
|
317
|
+
await hookRunner.runPostRequest('test-id', item, testUserId, context);
|
|
318
|
+
|
|
319
|
+
expect(allMethodsHook.onBeforeRequest).toHaveBeenCalledWith('test-id', testUserId, context);
|
|
320
|
+
expect(allMethodsHook.onAfterRequest).toHaveBeenCalledWith(
|
|
321
|
+
'test-id',
|
|
322
|
+
item,
|
|
323
|
+
testUserId,
|
|
324
|
+
context,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should run pre and post get hooks', async () => {
|
|
329
|
+
const context = createTestContext();
|
|
330
|
+
const item = createTestItem();
|
|
331
|
+
|
|
332
|
+
await hookRunner.runPreGet('test-id', item, testUserId, context);
|
|
333
|
+
await hookRunner.runPostGet('test-id', item, testUserId, context);
|
|
334
|
+
|
|
335
|
+
expect(allMethodsHook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
|
|
336
|
+
expect(allMethodsHook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
|
|
337
|
+
});
|
|
338
|
+
|
|
256
339
|
it('should run pre and post create hooks', async () => {
|
|
257
340
|
const context = createTestContext();
|
|
258
341
|
const item = createTestItem();
|
|
@@ -46,29 +46,68 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
46
46
|
this.sortedHooks = [...hooks].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────
|
|
50
|
+
// Global Request Hooks
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
49
53
|
/**
|
|
50
|
-
* Run pre-
|
|
54
|
+
* Run pre-request hooks (global - runs at start of any operation).
|
|
55
|
+
* Use for authentication, rate limiting, request validation.
|
|
51
56
|
*/
|
|
52
|
-
async
|
|
57
|
+
async runPreRequest(
|
|
53
58
|
itemId: string,
|
|
54
59
|
userId: string,
|
|
55
60
|
context?: IRequestContext,
|
|
56
61
|
): Promise<IHookRunResult> {
|
|
57
|
-
return this.runPreHooks('
|
|
62
|
+
return this.runPreHooks('onBeforeRequest', itemId, userId, context);
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
|
-
* Run post-
|
|
66
|
+
* Run post-request hooks (global - runs at end of any operation).
|
|
67
|
+
* Use for audit logging, metrics, cleanup.
|
|
62
68
|
*/
|
|
63
|
-
async
|
|
69
|
+
async runPostRequest(
|
|
64
70
|
itemId: string,
|
|
65
71
|
item: DismissibleItemDto<TMetadata>,
|
|
66
72
|
userId: string,
|
|
67
73
|
context?: IRequestContext,
|
|
68
74
|
): Promise<void> {
|
|
69
|
-
await this.runPostHooks('
|
|
75
|
+
await this.runPostHooks('onAfterRequest', itemId, item, userId, context);
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────
|
|
79
|
+
// Get Hooks
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run pre-get hooks (when item exists and is about to be returned).
|
|
84
|
+
* Receives the item for access control based on item state.
|
|
85
|
+
*/
|
|
86
|
+
async runPreGet(
|
|
87
|
+
itemId: string,
|
|
88
|
+
item: DismissibleItemDto<TMetadata>,
|
|
89
|
+
userId: string,
|
|
90
|
+
context?: IRequestContext,
|
|
91
|
+
): Promise<IHookRunResult> {
|
|
92
|
+
return this.runPreHooksWithItem('onBeforeGet', itemId, item, userId, context);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run post-get hooks (after item is returned).
|
|
97
|
+
*/
|
|
98
|
+
async runPostGet(
|
|
99
|
+
itemId: string,
|
|
100
|
+
item: DismissibleItemDto<TMetadata>,
|
|
101
|
+
userId: string,
|
|
102
|
+
context?: IRequestContext,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
await this.runPostHooks('onAfterGet', itemId, item, userId, context);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────
|
|
108
|
+
// Create Hooks
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
72
111
|
/**
|
|
73
112
|
* Run pre-create hooks.
|
|
74
113
|
*/
|
|
@@ -92,6 +131,10 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
92
131
|
await this.runPostHooks('onAfterCreate', itemId, item, userId, context);
|
|
93
132
|
}
|
|
94
133
|
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────
|
|
135
|
+
// Dismiss Hooks
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
95
138
|
/**
|
|
96
139
|
* Run pre-dismiss hooks.
|
|
97
140
|
*/
|
|
@@ -115,6 +158,10 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
115
158
|
await this.runPostHooks('onAfterDismiss', itemId, item, userId, context);
|
|
116
159
|
}
|
|
117
160
|
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────
|
|
162
|
+
// Restore Hooks
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
118
165
|
/**
|
|
119
166
|
* Run pre-restore hooks.
|
|
120
167
|
*/
|
|
@@ -214,6 +261,85 @@ export class HookRunner<TMetadata extends BaseMetadata = BaseMetadata> {
|
|
|
214
261
|
};
|
|
215
262
|
}
|
|
216
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Internal method to run pre-hooks that receive the item (e.g., onBeforeGet).
|
|
266
|
+
* Unlike standard pre-hooks, these receive the item for inspection/access control.
|
|
267
|
+
*/
|
|
268
|
+
private async runPreHooksWithItem(
|
|
269
|
+
hookName: keyof IDismissibleLifecycleHook<TMetadata>,
|
|
270
|
+
itemId: string,
|
|
271
|
+
item: DismissibleItemDto<TMetadata>,
|
|
272
|
+
userId: string,
|
|
273
|
+
context?: IRequestContext,
|
|
274
|
+
): Promise<IHookRunResult> {
|
|
275
|
+
let currentId = itemId;
|
|
276
|
+
let currentUserId = userId;
|
|
277
|
+
let currentContext = context ? { ...context } : undefined;
|
|
278
|
+
|
|
279
|
+
for (const hook of this.sortedHooks) {
|
|
280
|
+
const hookFn = hook[hookName] as
|
|
281
|
+
| ((
|
|
282
|
+
itemId: string,
|
|
283
|
+
item: DismissibleItemDto<TMetadata>,
|
|
284
|
+
userId: string,
|
|
285
|
+
context?: IRequestContext,
|
|
286
|
+
) => Promise<IHookResult> | IHookResult)
|
|
287
|
+
| undefined;
|
|
288
|
+
|
|
289
|
+
if (hookFn) {
|
|
290
|
+
try {
|
|
291
|
+
const result = await hookFn.call(hook, currentId, item, currentUserId, currentContext);
|
|
292
|
+
|
|
293
|
+
if (!result.proceed) {
|
|
294
|
+
this.logger.debug(`Hook ${hook.constructor.name}.${hookName} blocked operation`, {
|
|
295
|
+
itemId: currentId,
|
|
296
|
+
userId: currentUserId,
|
|
297
|
+
reason: result.reason,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
proceed: false,
|
|
302
|
+
id: currentId,
|
|
303
|
+
userId: currentUserId,
|
|
304
|
+
context: currentContext,
|
|
305
|
+
reason: result.reason,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Apply mutations if present
|
|
310
|
+
if (result.mutations) {
|
|
311
|
+
if (result.mutations.id !== undefined) {
|
|
312
|
+
currentId = result.mutations.id;
|
|
313
|
+
}
|
|
314
|
+
if (result.mutations.userId !== undefined) {
|
|
315
|
+
currentUserId = result.mutations.userId;
|
|
316
|
+
}
|
|
317
|
+
if (result.mutations.context && currentContext) {
|
|
318
|
+
currentContext = { ...currentContext, ...result.mutations.context };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
this.logger.error(
|
|
323
|
+
`Error in hook ${hook.constructor.name}.${hookName}`,
|
|
324
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
325
|
+
{
|
|
326
|
+
itemId: currentId,
|
|
327
|
+
userId: currentUserId,
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
proceed: true,
|
|
337
|
+
id: currentId,
|
|
338
|
+
userId: currentUserId,
|
|
339
|
+
context: currentContext,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
217
343
|
/**
|
|
218
344
|
* Internal method to run post-hooks.
|
|
219
345
|
* Post-hooks run in reverse priority order.
|
|
@@ -44,27 +44,65 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
|
|
|
44
44
|
*/
|
|
45
45
|
readonly priority?: number;
|
|
46
46
|
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────
|
|
48
|
+
// Global Request Hooks (run on ALL operations)
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
47
51
|
/**
|
|
48
|
-
* Called
|
|
52
|
+
* Called at the start of any operation (getOrCreate, dismiss, restore).
|
|
53
|
+
* Use for global concerns like authentication, rate limiting, request validation.
|
|
49
54
|
*/
|
|
50
|
-
|
|
55
|
+
onBeforeRequest?(
|
|
51
56
|
itemId: string,
|
|
52
57
|
userId: string,
|
|
53
58
|
context?: IRequestContext,
|
|
54
59
|
): Promise<IHookResult> | IHookResult;
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
|
-
* Called
|
|
62
|
+
* Called at the end of any operation (getOrCreate, dismiss, restore).
|
|
63
|
+
* Use for global concerns like audit logging, metrics, cleanup.
|
|
58
64
|
*/
|
|
59
|
-
|
|
65
|
+
onAfterRequest?(
|
|
60
66
|
itemId: string,
|
|
61
67
|
item: DismissibleItemDto<TMetadata>,
|
|
62
68
|
userId: string,
|
|
63
69
|
context?: IRequestContext,
|
|
64
70
|
): Promise<void> | void;
|
|
65
71
|
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────
|
|
73
|
+
// Get Hooks (when retrieving existing item)
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Called before returning an existing item.
|
|
78
|
+
* Only called when item exists in storage.
|
|
79
|
+
* Use for access control based on item state (e.g., block dismissed items).
|
|
80
|
+
*/
|
|
81
|
+
onBeforeGet?(
|
|
82
|
+
itemId: string,
|
|
83
|
+
item: DismissibleItemDto<TMetadata>,
|
|
84
|
+
userId: string,
|
|
85
|
+
context?: IRequestContext,
|
|
86
|
+
): Promise<IHookResult> | IHookResult;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Called after returning an existing item.
|
|
90
|
+
* Only called when item exists in storage.
|
|
91
|
+
*/
|
|
92
|
+
onAfterGet?(
|
|
93
|
+
itemId: string,
|
|
94
|
+
item: DismissibleItemDto<TMetadata>,
|
|
95
|
+
userId: string,
|
|
96
|
+
context?: IRequestContext,
|
|
97
|
+
): Promise<void> | void;
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────
|
|
100
|
+
// Create Hooks (when creating new item)
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
66
103
|
/**
|
|
67
104
|
* Called before creating a new item.
|
|
105
|
+
* Use for plan limits, quota checks, etc.
|
|
68
106
|
*/
|
|
69
107
|
onBeforeCreate?(
|
|
70
108
|
itemId: string,
|
|
@@ -82,6 +120,10 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
|
|
|
82
120
|
context?: IRequestContext,
|
|
83
121
|
): Promise<void> | void;
|
|
84
122
|
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────
|
|
124
|
+
// Dismiss Hooks
|
|
125
|
+
// ─────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
85
127
|
/**
|
|
86
128
|
* Called before dismissing an item.
|
|
87
129
|
*/
|
|
@@ -101,6 +143,10 @@ export interface IDismissibleLifecycleHook<TMetadata extends BaseMetadata = Base
|
|
|
101
143
|
context?: IRequestContext,
|
|
102
144
|
): Promise<void> | void;
|
|
103
145
|
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────
|
|
147
|
+
// Restore Hooks
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
104
150
|
/**
|
|
105
151
|
* Called before restoring an item.
|
|
106
152
|
*/
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
|
|
2
|
-
import {
|
|
2
|
+
import { FastifyReply } from 'fastify';
|
|
3
3
|
import { IErrorResponseDto } from './dtos';
|
|
4
4
|
|
|
5
5
|
@Catch(HttpException)
|
|
6
6
|
export class HttpExceptionFilter implements ExceptionFilter {
|
|
7
7
|
catch(exception: HttpException, host: ArgumentsHost) {
|
|
8
8
|
const ctx = host.switchToHttp();
|
|
9
|
-
const response = ctx.getResponse<
|
|
9
|
+
const response = ctx.getResponse<FastifyReply>();
|
|
10
10
|
const status = exception.getStatus();
|
|
11
11
|
|
|
12
12
|
const errorResponse: IErrorResponseDto = {
|
|
@@ -16,6 +16,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|
|
16
16
|
},
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
response.status(status).
|
|
19
|
+
response.status(status).send(errorResponse);
|
|
20
20
|
}
|
|
21
21
|
}
|