@axinom/mosaic-graphql-common 0.28.0-rc.15 → 0.28.0-rc.16

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.
@@ -0,0 +1,360 @@
1
+ import { PerformItemChangeCommand } from '@axinom/mosaic-messages';
2
+ import { ClientBase, QueryResult } from 'pg';
3
+ import { handlePerformItemChangeCommand } from './bulk-edit-item-change-handler-helpers';
4
+
5
+ describe('bulk-edit-item-change-handler-helpers', () => {
6
+ let mockClient: ClientBase;
7
+ let mockQuery: jest.MockedFunction<
8
+ (queryText: string, values?: any[]) => Promise<QueryResult>
9
+ >;
10
+
11
+ // Helper to create a proper QueryResult object
12
+ const createQueryResult = (rows: any[] = []): QueryResult => ({
13
+ rows,
14
+ command: 'SELECT',
15
+ rowCount: rows.length,
16
+ oid: 0,
17
+ fields: [],
18
+ });
19
+
20
+ beforeEach(() => {
21
+ mockQuery = jest.fn().mockResolvedValue(createQueryResult([]));
22
+ mockClient = {
23
+ query: mockQuery,
24
+ } as unknown as ClientBase;
25
+ });
26
+
27
+ afterEach(() => {
28
+ jest.restoreAllMocks();
29
+ });
30
+
31
+ describe('handlePerformItemChangeCommand', () => {
32
+ describe('SET_FIELD_VALUES action', () => {
33
+ it('should update main entity fields', async () => {
34
+ // Arrange
35
+ const payload: PerformItemChangeCommand = {
36
+ table_name: 'images',
37
+ action: 'SET_FIELD_VALUES',
38
+ stringified_condition: JSON.stringify({ id: 'test-id-123' }),
39
+ stringified_payload: JSON.stringify({
40
+ title: 'Updated Title',
41
+ description: 'Updated Description',
42
+ }),
43
+ };
44
+
45
+ // Act
46
+ await handlePerformItemChangeCommand(payload, mockClient);
47
+
48
+ // Assert
49
+ expect(mockQuery).toHaveBeenCalledTimes(1);
50
+ expect(mockQuery).toHaveBeenCalledWith(
51
+ 'UPDATE "images" SET "title" = $1, "description" = $2 WHERE "id" = $3 RETURNING *',
52
+ ['Updated Title', 'Updated Description', 'test-id-123'],
53
+ );
54
+ });
55
+
56
+ it('should handle single field update', async () => {
57
+ // Arrange
58
+ const payload: PerformItemChangeCommand = {
59
+ table_name: 'users',
60
+ action: 'SET_FIELD_VALUES',
61
+ stringified_condition: JSON.stringify({ id: 'user-123' }),
62
+ stringified_payload: JSON.stringify({ email: 'new@example.com' }),
63
+ };
64
+
65
+ // Act
66
+ await handlePerformItemChangeCommand(payload, mockClient);
67
+
68
+ // Assert
69
+ expect(mockQuery).toHaveBeenCalledWith(
70
+ 'UPDATE "users" SET "email" = $1 WHERE "id" = $2 RETURNING *',
71
+ ['new@example.com', 'user-123'],
72
+ );
73
+ });
74
+ });
75
+
76
+ describe('CLEAR_RELATED_ENTITIES action', () => {
77
+ it('should delete all related entities matching condition', async () => {
78
+ // Arrange
79
+ const payload: PerformItemChangeCommand = {
80
+ table_name: 'images_tags',
81
+ action: 'CLEAR_RELATED_ENTITIES',
82
+ stringified_condition: JSON.stringify({ image_id: 'image-123' }),
83
+ stringified_payload: JSON.stringify({}),
84
+ };
85
+
86
+ // Act
87
+ await handlePerformItemChangeCommand(payload, mockClient);
88
+
89
+ // Assert
90
+ expect(mockQuery).toHaveBeenCalledTimes(1);
91
+ expect(mockQuery).toHaveBeenCalledWith(
92
+ 'DELETE FROM "images_tags" WHERE "image_id" = $1 RETURNING *',
93
+ ['image-123'],
94
+ );
95
+ });
96
+
97
+ it('should handle multiple condition fields', async () => {
98
+ // Arrange
99
+ const payload: PerformItemChangeCommand = {
100
+ table_name: 'junction_table',
101
+ action: 'CLEAR_RELATED_ENTITIES',
102
+ stringified_condition: JSON.stringify({
103
+ parent_id: 'parent-123',
104
+ tenant_id: 'tenant-456',
105
+ }),
106
+ stringified_payload: JSON.stringify({}),
107
+ };
108
+
109
+ // Act
110
+ await handlePerformItemChangeCommand(payload, mockClient);
111
+
112
+ // Assert
113
+ expect(mockQuery).toHaveBeenCalledWith(
114
+ 'DELETE FROM "junction_table" WHERE "parent_id" = $1 AND "tenant_id" = $2 RETURNING *',
115
+ ['parent-123', 'tenant-456'],
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('REMOVE_RELATED_ENTITY action', () => {
121
+ it('should delete specific related entity', async () => {
122
+ // Arrange
123
+ const payload: PerformItemChangeCommand = {
124
+ table_name: 'images_tags',
125
+ action: 'REMOVE_RELATED_ENTITY',
126
+ stringified_condition: '',
127
+ stringified_payload: JSON.stringify({
128
+ image_id: 'image-123',
129
+ name: 'tag-to-remove',
130
+ }),
131
+ };
132
+
133
+ // Act
134
+ await handlePerformItemChangeCommand(payload, mockClient);
135
+
136
+ // Assert
137
+ expect(mockQuery).toHaveBeenCalledWith(
138
+ 'DELETE FROM "images_tags" WHERE "image_id" = $1 AND "name" = $2 RETURNING *',
139
+ ['image-123', 'tag-to-remove'],
140
+ );
141
+ });
142
+
143
+ it('should handle composite key deletion', async () => {
144
+ // Arrange
145
+ const payload: PerformItemChangeCommand = {
146
+ table_name: 'user_role_assignments',
147
+ action: 'REMOVE_RELATED_ENTITY',
148
+ stringified_condition: '',
149
+ stringified_payload: JSON.stringify({
150
+ user_id: 'user-123',
151
+ user_role_id: 'role-456',
152
+ }),
153
+ };
154
+
155
+ // Act
156
+ await handlePerformItemChangeCommand(payload, mockClient);
157
+
158
+ // Assert
159
+ expect(mockQuery).toHaveBeenCalledWith(
160
+ 'DELETE FROM "user_role_assignments" WHERE "user_id" = $1 AND "user_role_id" = $2 RETURNING *',
161
+ ['user-123', 'role-456'],
162
+ );
163
+ });
164
+ });
165
+
166
+ describe('ADD_RELATED_ENTITY action', () => {
167
+ it('should upsert new related entity', async () => {
168
+ // Arrange
169
+ const payload: PerformItemChangeCommand = {
170
+ table_name: 'images_tags',
171
+ action: 'ADD_RELATED_ENTITY',
172
+ stringified_condition: '',
173
+ stringified_payload: JSON.stringify({
174
+ image_id: 'image-123',
175
+ name: 'new-tag',
176
+ }),
177
+ };
178
+
179
+ // Act
180
+ await handlePerformItemChangeCommand(payload, mockClient);
181
+
182
+ // Assert
183
+ expect(mockQuery).toHaveBeenCalledWith(
184
+ 'INSERT INTO "images_tags" ("image_id", "name") VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *',
185
+ ['image-123', 'new-tag'],
186
+ );
187
+ });
188
+
189
+ it('should handle multiple fields upsert', async () => {
190
+ // Arrange
191
+ const payload: PerformItemChangeCommand = {
192
+ table_name: 'user_role_assignments',
193
+ action: 'ADD_RELATED_ENTITY',
194
+ stringified_condition: '',
195
+ stringified_payload: JSON.stringify({
196
+ user_id: 'user-123',
197
+ user_role_id: 'role-456',
198
+ created_at: '2024-01-01T00:00:00Z',
199
+ }),
200
+ };
201
+
202
+ // Act
203
+ await handlePerformItemChangeCommand(payload, mockClient);
204
+
205
+ // Assert
206
+ expect(mockQuery).toHaveBeenCalledWith(
207
+ 'INSERT INTO "user_role_assignments" ("user_id", "user_role_id", "created_at") VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING *',
208
+ ['user-123', 'role-456', '2024-01-01T00:00:00Z'],
209
+ );
210
+ });
211
+ });
212
+
213
+ describe('Error handling', () => {
214
+ it('should throw foreign key constraint errors', async () => {
215
+ // Arrange
216
+ const payload: PerformItemChangeCommand = {
217
+ table_name: 'images_tags',
218
+ action: 'ADD_RELATED_ENTITY',
219
+ stringified_condition: '',
220
+ stringified_payload: JSON.stringify({
221
+ image_id: 'image-123',
222
+ name: 'tag',
223
+ }),
224
+ };
225
+
226
+ const foreignKeyError = new Error(
227
+ 'insert or update on table "images_tags" violates foreign key constraint',
228
+ );
229
+ mockQuery.mockRejectedValueOnce(foreignKeyError);
230
+
231
+ // Act & Assert
232
+ await expect(
233
+ handlePerformItemChangeCommand(payload, mockClient),
234
+ ).rejects.toThrow(
235
+ 'insert or update on table "images_tags" violates foreign key constraint',
236
+ );
237
+ });
238
+
239
+ it('should throw database connection errors', async () => {
240
+ // Arrange
241
+ const payload: PerformItemChangeCommand = {
242
+ table_name: 'images',
243
+ action: 'SET_FIELD_VALUES',
244
+ stringified_condition: JSON.stringify({ id: 'test-id' }),
245
+ stringified_payload: JSON.stringify({ title: 'Test' }),
246
+ };
247
+
248
+ const connectionError = new Error('ECONNREFUSED');
249
+ mockQuery.mockRejectedValueOnce(connectionError);
250
+
251
+ // Act & Assert
252
+ await expect(
253
+ handlePerformItemChangeCommand(payload, mockClient),
254
+ ).rejects.toThrow('ECONNREFUSED');
255
+ });
256
+ });
257
+
258
+ describe('Edge cases', () => {
259
+ it('should handle empty payload for CLEAR_RELATED_ENTITIES', async () => {
260
+ // Arrange
261
+ const payload: PerformItemChangeCommand = {
262
+ table_name: 'images_tags',
263
+ action: 'CLEAR_RELATED_ENTITIES',
264
+ stringified_condition: JSON.stringify({ image_id: 'empty-image' }),
265
+ stringified_payload: JSON.stringify({}),
266
+ };
267
+
268
+ // Act
269
+ await handlePerformItemChangeCommand(payload, mockClient);
270
+
271
+ // Assert
272
+ expect(mockQuery).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ it('should handle special characters in table names', async () => {
276
+ // Arrange
277
+ const payload: PerformItemChangeCommand = {
278
+ table_name: 'app_public.images_tags',
279
+ action: 'CLEAR_RELATED_ENTITIES',
280
+ stringified_condition: JSON.stringify({ image_id: 'test' }),
281
+ stringified_payload: JSON.stringify({}),
282
+ };
283
+
284
+ // Act
285
+ await handlePerformItemChangeCommand(payload, mockClient);
286
+
287
+ // Assert
288
+ expect(mockQuery).toHaveBeenCalledWith(
289
+ 'DELETE FROM "app_public.images_tags" WHERE "image_id" = $1 RETURNING *',
290
+ ['test'],
291
+ );
292
+ });
293
+
294
+ it('should handle NULL values in payload', async () => {
295
+ // Arrange
296
+ const payload: PerformItemChangeCommand = {
297
+ table_name: 'images',
298
+ action: 'SET_FIELD_VALUES',
299
+ stringified_condition: JSON.stringify({ id: 'test-id' }),
300
+ stringified_payload: JSON.stringify({
301
+ description: null,
302
+ tags: null,
303
+ }),
304
+ };
305
+
306
+ // Act
307
+ await handlePerformItemChangeCommand(payload, mockClient);
308
+
309
+ // Assert
310
+ expect(mockQuery).toHaveBeenCalledWith(
311
+ 'UPDATE "images" SET "description" = $1, "tags" = $2 WHERE "id" = $3 RETURNING *',
312
+ [null, null, 'test-id'],
313
+ );
314
+ });
315
+ });
316
+ });
317
+
318
+ describe('SQL injection protection', () => {
319
+ it('should properly escape column names with quotes', async () => {
320
+ // Arrange
321
+ const payload: PerformItemChangeCommand = {
322
+ table_name: 'images',
323
+ action: 'SET_FIELD_VALUES',
324
+ stringified_condition: JSON.stringify({ id: 'test-id' }),
325
+ stringified_payload: JSON.stringify({
326
+ 'column; DROP TABLE images;': 'malicious',
327
+ }),
328
+ };
329
+
330
+ // Act
331
+ await handlePerformItemChangeCommand(payload, mockClient);
332
+
333
+ // Assert - Column names should be quoted, preventing SQL injection
334
+ expect(mockQuery).toHaveBeenCalledWith(
335
+ 'UPDATE "images" SET "column; DROP TABLE images;" = $1 WHERE "id" = $2 RETURNING *',
336
+ ['malicious', 'test-id'],
337
+ );
338
+ });
339
+
340
+ it('should use parameterized queries for values', async () => {
341
+ // Arrange
342
+ const maliciousValue = "'; DROP TABLE images; --";
343
+ const payload: PerformItemChangeCommand = {
344
+ table_name: 'images',
345
+ action: 'SET_FIELD_VALUES',
346
+ stringified_condition: JSON.stringify({ id: 'test-id' }),
347
+ stringified_payload: JSON.stringify({ title: maliciousValue }),
348
+ };
349
+
350
+ // Act
351
+ await handlePerformItemChangeCommand(payload, mockClient);
352
+
353
+ // Assert - Value should be parameterized, not interpolated
354
+ expect(mockQuery).toHaveBeenCalledWith(
355
+ 'UPDATE "images" SET "title" = $1 WHERE "id" = $2 RETURNING *',
356
+ [maliciousValue, 'test-id'],
357
+ );
358
+ });
359
+ });
360
+ });
@@ -7,16 +7,18 @@ import {
7
7
  import { ClientBase, QueryResult } from 'pg';
8
8
  import { DatabaseClient } from 'pg-transactional-outbox';
9
9
 
10
- async function insert(
10
+ async function upsert(
11
11
  client: ClientBase,
12
12
  tableName: string,
13
13
  jsonObject: Record<string, unknown>,
14
14
  ): Promise<QueryResult> {
15
- const columns = Object.keys(jsonObject).join(', ');
15
+ const columns = Object.keys(jsonObject)
16
+ .map((col) => `"${col}"`)
17
+ .join(', ');
16
18
  const values = Object.values(jsonObject);
17
19
  const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
18
20
 
19
- const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
21
+ const query = `INSERT INTO "${tableName}" (${columns}) VALUES (${placeholders}) ON CONFLICT DO NOTHING RETURNING *`;
20
22
 
21
23
  return client.query(query, values);
22
24
  }
@@ -28,86 +30,104 @@ async function update(
28
30
  condition: Record<string, unknown>,
29
31
  ): Promise<QueryResult> {
30
32
  const setClause = Object.keys(jsonObject)
31
- .map((key, index) => `${key} = $${index + 1}`)
33
+ .map((key, index) => `"${key}" = $${index + 1}`)
32
34
  .join(', ');
33
35
  const values = Object.values(jsonObject);
34
36
 
35
- const conditionClause = Object.keys(condition)
36
- .map((key, index) => `${key} = $${index + 1 + values.length}`)
37
- .join(' AND ');
38
- const conditionValues = Object.values(condition);
37
+ const { clause: conditionClause, values: conditionValues } = buildWhereClause(
38
+ condition,
39
+ values.length,
40
+ );
39
41
 
40
- const query = `UPDATE ${tableName} SET ${setClause} WHERE ${conditionClause} RETURNING *`;
42
+ const query = `UPDATE "${tableName}" SET ${setClause} WHERE ${conditionClause} RETURNING *`;
41
43
 
42
44
  return client.query(query, [...values, ...conditionValues]);
43
45
  }
44
46
 
45
- async function deletes(
47
+ /**
48
+ * Helper to build WHERE clause from a condition object
49
+ */
50
+ function buildWhereClause(
51
+ condition: Record<string, unknown>,
52
+ parameterOffset = 0,
53
+ ): { clause: string; values: unknown[] } {
54
+ const values = Object.values(condition);
55
+ const clause = Object.keys(condition)
56
+ .map((key, index) => `"${key}" = $${index + 1 + parameterOffset}`)
57
+ .join(' AND ');
58
+
59
+ return { clause, values };
60
+ }
61
+
62
+ /**
63
+ * Deletes records from a table matching the given condition
64
+ */
65
+ async function deleteWhere(
46
66
  client: ClientBase,
47
67
  tableName: string,
48
- jsonObject: Record<string, unknown>,
68
+ condition: Record<string, unknown>,
49
69
  ): Promise<QueryResult> {
50
- const conditionClause = Object.keys(jsonObject)
51
- .map((key, index) => `${key} = $${index + 1}`)
52
- .join(' AND ');
53
- const conditionValues = Object.values(jsonObject);
70
+ const { clause, values } = buildWhereClause(condition);
71
+ const query = `DELETE FROM "${tableName}" WHERE ${clause} RETURNING *`;
54
72
 
55
- const query = `DELETE FROM ${tableName} WHERE ${conditionClause} RETURNING *`;
56
-
57
- return client.query(query, conditionValues);
73
+ return client.query(query, values);
58
74
  }
59
75
 
60
76
  export async function handlePerformItemChangeCommand(
61
77
  payload: PerformItemChangeCommand,
62
78
  client: ClientBase,
63
79
  ): Promise<void> {
64
- try {
65
- if (payload.action === 'SET_FIELD_VALUES') {
80
+ switch (payload.action) {
81
+ case 'SET_FIELD_VALUES':
66
82
  await update(
67
83
  client,
68
84
  payload.table_name,
69
85
  JSON.parse(payload.stringified_payload),
70
86
  { id: JSON.parse(payload.stringified_condition).id },
71
87
  );
72
- } else if (payload.action === 'REMOVE_RELATED_ENTITY') {
73
- await deletes(
88
+ break;
89
+
90
+ case 'CLEAR_RELATED_ENTITIES':
91
+ await deleteWhere(
92
+ client,
93
+ payload.table_name,
94
+ JSON.parse(payload.stringified_condition),
95
+ );
96
+ break;
97
+
98
+ case 'REMOVE_RELATED_ENTITY':
99
+ await deleteWhere(
74
100
  client,
75
101
  payload.table_name,
76
102
  JSON.parse(payload.stringified_payload),
77
103
  );
78
- } else if (payload.action === 'ADD_RELATED_ENTITY') {
79
- await insert(
104
+ break;
105
+
106
+ case 'ADD_RELATED_ENTITY':
107
+ await upsert(
80
108
  client,
81
109
  payload.table_name,
82
110
  JSON.parse(payload.stringified_payload),
83
111
  );
84
- }
85
-
86
- // TODO: Publish success event or implement a JOB tracking/monitoring mechanism
87
- } catch (error) {
88
- // TODO: If error is a known/expected one like `duplicate key value violates unique constraint` we ignore it, else we throw it and bubble up.
89
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
- const errorAsAny = error as any;
91
- if (
92
- errorAsAny.message &&
93
- typeof errorAsAny.message === 'string' &&
94
- errorAsAny.message.startsWith(
95
- 'duplicate key value violates unique constraint',
96
- )
97
- ) {
98
- // Skipping duplicate key error
99
- } else {
100
- throw error;
101
- }
112
+ break;
102
113
  }
114
+
115
+ // TODO: Publish success event or implement a JOB tracking/monitoring mechanism
103
116
  }
104
117
 
118
+ /**
119
+ * !NOTE!
120
+ *
121
+ * This is a temporary stub implementation that needs to be properly implemented later.
122
+ * The idea is to have a way to track failed item change commands and notify the users
123
+ * or systems about the failures.
124
+ */
105
125
  export async function handlePerformItemChangeCommandError(
106
- config: BasicConfig,
107
- error: Error,
108
- message: TypedTransactionalMessage<PerformItemChangeCommand>,
109
- storeOutboxMessage: StoreOutboxMessage,
110
- client: DatabaseClient,
126
+ _config: BasicConfig,
127
+ _error: Error,
128
+ _message: TypedTransactionalMessage<PerformItemChangeCommand>,
129
+ _storeOutboxMessage: StoreOutboxMessage,
130
+ _client: DatabaseClient,
111
131
  ): Promise<void> {
112
132
  // TODO: Publish failed event or implement a JOB tracking/monitoring mechanism
113
133
  //