@friggframework/core 2.0.0-next.97 → 2.0.0-next.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/application/commands/integration-commands.js +34 -1
  2. package/generated/prisma-mongodb/edge.js +6 -4
  3. package/generated/prisma-mongodb/index-browser.js +3 -1
  4. package/generated/prisma-mongodb/index.d.ts +3 -1
  5. package/generated/prisma-mongodb/index.js +6 -4
  6. package/generated/prisma-mongodb/package.json +1 -1
  7. package/generated/prisma-mongodb/schema.prisma +3 -1
  8. package/generated/prisma-mongodb/wasm.js +5 -3
  9. package/generated/prisma-postgresql/edge.js +6 -4
  10. package/generated/prisma-postgresql/index-browser.js +3 -1
  11. package/generated/prisma-postgresql/index.d.ts +3 -1
  12. package/generated/prisma-postgresql/index.js +6 -4
  13. package/generated/prisma-postgresql/package.json +1 -1
  14. package/generated/prisma-postgresql/schema.prisma +3 -1
  15. package/generated/prisma-postgresql/wasm.js +5 -3
  16. package/handlers/backend-utils.js +2 -2
  17. package/integrations/integration-base.js +150 -35
  18. package/integrations/integration-router.js +2 -1
  19. package/integrations/repositories/config-patch-shared.js +43 -0
  20. package/integrations/repositories/integration-repository-documentdb.js +54 -1
  21. package/integrations/repositories/integration-repository-interface.js +15 -0
  22. package/integrations/repositories/integration-repository-mongo.js +48 -0
  23. package/integrations/repositories/integration-repository-postgres.js +28 -0
  24. package/integrations/tests/doubles/dummy-integration-class.js +8 -0
  25. package/integrations/tests/doubles/test-integration-repository.js +24 -1
  26. package/integrations/use-cases/create-integration.js +138 -6
  27. package/integrations/use-cases/delete-integration-for-user.js +19 -1
  28. package/integrations/use-cases/patch-integration-config.js +39 -0
  29. package/integrations/use-cases/update-integration-config.js +32 -0
  30. package/package.json +5 -5
  31. package/prisma-mongodb/schema.prisma +3 -1
  32. package/prisma-postgresql/migrations/20260703000000_add_integration_status_in_creation_in_deletion/migration.sql +11 -0
  33. package/prisma-postgresql/migrations/20260703000001_integration_status_default_in_creation/migration.sql +8 -0
  34. package/prisma-postgresql/schema.prisma +3 -1
  35. package/reporting/README.md +86 -0
  36. package/reporting/reporting-router.js +29 -0
  37. package/reporting/use-cases/list-integrations-report.js +6 -1
@@ -11,6 +11,12 @@ const {
11
11
  const {
12
12
  UpdateIntegrationMessages,
13
13
  } = require('./use-cases/update-integration-messages');
14
+ const {
15
+ PatchIntegrationConfig,
16
+ } = require('./use-cases/patch-integration-config');
17
+ const {
18
+ UpdateIntegrationConfig,
19
+ } = require('./use-cases/update-integration-config');
14
20
  const { validateExtensionBinding } = require('./extension');
15
21
 
16
22
  const constantsToBeMigrated = {
@@ -43,6 +49,12 @@ class IntegrationBase {
43
49
  updateIntegrationMessages = new UpdateIntegrationMessages({
44
50
  integrationRepository: this.integrationRepository,
45
51
  });
52
+ patchIntegrationConfig = new PatchIntegrationConfig({
53
+ integrationRepository: this.integrationRepository,
54
+ });
55
+ updateIntegrationConfig = new UpdateIntegrationConfig({
56
+ integrationRepository: this.integrationRepository,
57
+ });
46
58
 
47
59
  static getOptionDetails() {
48
60
  const options = new Options({
@@ -266,41 +278,57 @@ class IntegrationBase {
266
278
  return modules;
267
279
  }
268
280
 
281
+ /**
282
+ * Check the current config against the required fields declared by
283
+ * `getConfigOptions()`. Config options use the react-jsonschema-form shape,
284
+ * so the required top-level keys live in `jsonSchema.required`. Records a
285
+ * warning for each missing field. Does not change integration status —
286
+ * the caller decides the consequence (see `onCreate`/`onUpdate`), the same
287
+ * separation `testAuth`/`reconcileAuthStatus` use for the auth axis.
288
+ * @returns {Promise<boolean>} True when a required field is missing.
289
+ */
269
290
  async validateConfig() {
270
- const configOptions = await this.getConfigOptions();
271
- const currentConfig = this.getConfig();
291
+ const { jsonSchema } = await this.getConfigOptions();
292
+ const currentConfig = this.getConfig() || {};
293
+ const requiredKeys = Array.isArray(jsonSchema?.required)
294
+ ? jsonSchema.required
295
+ : [];
272
296
  let needsConfig = false;
273
- for (const option of configOptions) {
274
- if (option.required) {
275
- // For now, just make sure the key exists. We should add more dynamic/better validation later.
276
- if (
277
- !Object.prototype.hasOwnProperty.call(
278
- currentConfig,
279
- option.key
280
- )
281
- ) {
282
- needsConfig = true;
283
- await this.updateIntegrationMessages.execute(
284
- this.id,
285
- 'warnings',
286
- 'Config Validation Error',
287
- `Missing required field of ${option.label}`,
288
- Date.now()
289
- );
290
- }
297
+ for (const key of requiredKeys) {
298
+ if (!Object.prototype.hasOwnProperty.call(currentConfig, key)) {
299
+ needsConfig = true;
300
+ const label = jsonSchema?.properties?.[key]?.title || key;
301
+ await this.updateIntegrationMessages.execute(
302
+ this.id,
303
+ 'warnings',
304
+ 'Config Validation Error',
305
+ `Missing required field of ${label}`,
306
+ Date.now()
307
+ );
291
308
  }
292
309
  }
293
- if (needsConfig) {
294
- await this.updateIntegrationStatus.execute(this.id, 'NEEDS_CONFIG');
295
- }
310
+ return needsConfig;
296
311
  }
297
312
 
313
+ /**
314
+ * Verify every module's credentials. Records a diagnostic error message
315
+ * per failing module and returns whether all passed. Does not directly
316
+ * change integration status — the caller decides the consequence (see
317
+ * reconcileAuthStatus), so a passive check and an active reconnect can
318
+ * react differently. (A module can still fire a credential-invalidated
319
+ * delegate that flips status via receiveNotification, independent of this
320
+ * return value.)
321
+ * @returns {Promise<boolean>} True when every module authenticated.
322
+ */
298
323
  async testAuth() {
299
324
  let didAuthPass = true;
300
325
 
301
326
  for (const module of Object.keys(this.constructor.Definition.modules)) {
302
327
  try {
303
- await this[module].testAuth();
328
+ const authPassed = await this[module].testAuth();
329
+ if (!authPassed) {
330
+ throw new Error(`testAuth returned false for module ${module}`);
331
+ }
304
332
  } catch {
305
333
  didAuthPass = false;
306
334
  await this.updateIntegrationMessages.execute(
@@ -309,15 +337,35 @@ class IntegrationBase {
309
337
  'Authentication Error',
310
338
  `There was an error with your ${this[
311
339
  module
312
- ].constructor.getName()} Entity.
340
+ ].getName()} Entity.
313
341
  Please reconnect/re-authenticate, or reach out to Support for assistance.`,
314
342
  Date.now()
315
343
  );
316
344
  }
317
345
  }
318
346
 
319
- if (!didAuthPass) {
320
- await this.updateIntegrationStatus.execute(this.id, 'ERROR');
347
+ return didAuthPass;
348
+ }
349
+
350
+ /**
351
+ * Reconcile the auth-health axis (ERROR ↔ ENABLED) from a testAuth result.
352
+ * On success it never clears DISABLED — a user pause is not an auth-health
353
+ * state, so it is only lifted by a deliberate reconnect. On failure the
354
+ * integration is marked ERROR regardless of its prior status.
355
+ * @param {boolean} authPassed - The result of testAuth().
356
+ */
357
+ async reconcileAuthStatus(authPassed) {
358
+ if (!authPassed) {
359
+ console.log(
360
+ `[Frigg] Integration ${this.id} failed to authenticate`
361
+ );
362
+ await this.persistStatus('ERROR');
363
+ }
364
+ if (authPassed && this.status === 'ERROR') {
365
+ console.log(
366
+ `[Frigg] auth confirmed for integration ${this.id} — clearing ERROR → ENABLED`
367
+ );
368
+ await this.persistStatus('ENABLED');
321
369
  }
322
370
  }
323
371
 
@@ -344,12 +392,39 @@ class IntegrationBase {
344
392
  /**
345
393
  * CHILDREN CAN OVERRIDE THESE CONFIGURATION METHODS
346
394
  */
347
- async onCreate({ integrationId }) {
348
- await this.updateIntegrationStatus.execute(integrationId, 'ENABLED');
395
+ /**
396
+ * Default post-create lifecycle hook. The integration is born IN_CREATION;
397
+ * moved to NEEDS_CONFIG when validateConfig finds a required field
398
+ * missing, otherwise enabled. If this hook throws, the integration is
399
+ * left IN_CREATION — a visibly incomplete create rather than a healthy
400
+ * looking one. Children can override to run their own setup (and then own
401
+ * their status transition, calling `super.onCreate()` to keep this default).
402
+ */
403
+ async onCreate() {
404
+ const needsConfig = await this.validateConfig();
405
+ await this.persistStatus(needsConfig ? 'NEEDS_CONFIG' : 'ENABLED');
349
406
  }
350
407
 
408
+ /**
409
+ * Default post-update lifecycle hook: merges any submitted config in as a
410
+ * patch, then re-validates. A NEEDS_CONFIG integration moves to ENABLED
411
+ * once nothing required is missing — other statuses (DISABLED, ERROR,
412
+ * IN_CREATION, IN_DELETION) are left alone; a config edit shouldn't
413
+ * silently un-pause or auto-heal those. Children can override to run
414
+ * their own update logic.
415
+ * @param {Object} [params]
416
+ * @param {Object} [params.config] - Keys to merge into the existing config.
417
+ */
351
418
  async onUpdate(params) {
352
- return this.validateConfig();
419
+ if (params?.config) {
420
+ await this.patchConfig(params.config);
421
+ }
422
+ const needsConfig = await this.validateConfig();
423
+ if (needsConfig) {
424
+ await this.persistStatus('NEEDS_CONFIG');
425
+ } else if (this.status === 'NEEDS_CONFIG') {
426
+ await this.persistStatus('ENABLED');
427
+ }
353
428
  }
354
429
 
355
430
  async onDelete(params) {}
@@ -500,6 +575,48 @@ class IntegrationBase {
500
575
  this.messages.warnings.push(warning);
501
576
  }
502
577
 
578
+ /**
579
+ * Persist a status change and keep the in-memory field in sync. The
580
+ * single place that couples both writes, so no caller can update the
581
+ * database while leaving `this.status` stale.
582
+ * @param {string} status - The new integration status.
583
+ */
584
+ async persistStatus(status) {
585
+ await this.updateIntegrationStatus.execute(this.id, status);
586
+ this.status = status;
587
+ console.log(`[Frigg] Integration ${this.id} status changed to ${status}`);
588
+ }
589
+
590
+ /**
591
+ * Merge a partial update into config and keep the in-memory field in
592
+ * sync with what was actually persisted — not a local `{...this.config,
593
+ * ...patch}` guess, which would silently drop keys a concurrent writer
594
+ * already landed. Throws if the merge fails.
595
+ * @param {Object} patch - Keys to merge into the existing config.
596
+ */
597
+ async patchConfig(patch) {
598
+ const updated = await this.patchIntegrationConfig.execute(
599
+ this.id,
600
+ patch
601
+ );
602
+ this.config = updated.config;
603
+ return this.config;
604
+ }
605
+
606
+ /**
607
+ * Replace config entirely and keep the in-memory field in sync. Keys
608
+ * omitted from the new config are deleted. Throws if the write fails.
609
+ * @param {Object} config - The new configuration object.
610
+ */
611
+ async updateConfig(config) {
612
+ const updated = await this.updateIntegrationConfig.execute(
613
+ this.id,
614
+ config
615
+ );
616
+ this.config = updated.config;
617
+ return this.config;
618
+ }
619
+
503
620
  isActive() {
504
621
  return this.status === 'ENABLED' || this.status === 'ACTIVE';
505
622
  }
@@ -655,7 +772,7 @@ class IntegrationBase {
655
772
  * Receives notifications from modules (the Delegate pattern) when
656
773
  * something integration-level needs attention. Today this catches the
657
774
  * `CREDENTIAL_INVALIDATED` event Module fires from `markCredentialsInvalid`
658
- * and flips this integration's status to DISABLED so the queue worker
775
+ * and flips this integration's status to ERROR so the queue worker
659
776
  * stops processing further webhooks until the user re-authorizes.
660
777
  *
661
778
  * Modules are wired to this delegate in `_appendModules()`, which runs
@@ -677,8 +794,7 @@ class IntegrationBase {
677
794
  console.log(
678
795
  `[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
679
796
  );
680
- await this.updateIntegrationStatus.execute(this.id, 'ERROR');
681
- this.status = 'ERROR';
797
+ await this.persistStatus('ERROR');
682
798
  return;
683
799
  }
684
800
 
@@ -687,8 +803,7 @@ class IntegrationBase {
687
803
  console.log(
688
804
  `[Frigg] Module ${notifier?.name || '?'} reported valid credentials for integration ${this.id} — clearing ERROR → ENABLED`
689
805
  );
690
- await this.updateIntegrationStatus.execute(this.id, 'ENABLED');
691
- this.status = 'ENABLED';
806
+ await this.persistStatus('ENABLED');
692
807
  }
693
808
  }
694
809
  }
@@ -468,7 +468,8 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
468
468
  }
469
469
 
470
470
  const start = Date.now();
471
- await instance.testAuth();
471
+ const authPassed = await instance.testAuth();
472
+ await instance.reconcileAuthStatus(authPassed);
472
473
  const errors = instance.record.messages?.errors?.filter(
473
474
  ({ timestamp }) => timestamp >= start
474
475
  );
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared validation for IntegrationRepository.patchIntegrationConfig().
3
+ *
4
+ * A patch is an atomic shallow merge into Integration.config: every key in
5
+ * the patch is written, every other existing key is left untouched. This
6
+ * contract is enforced here, before any adapter emits a query, so a bug fix
7
+ * (or a tighter rule) fixes all three backends in one place.
8
+ *
9
+ * Keys may not contain '.' or start with '$' — both are reserved by the
10
+ * MongoDB/DocumentDB update-command syntax the mongo and documentdb adapters
11
+ * use to address `config.<key>` paths. A value of `null` is allowed and sets
12
+ * the key to null — the only way to clear a config field over the PATCH HTTP
13
+ * endpoint (it sets the value, it does not remove the key). `undefined` is
14
+ * rejected: it can't arrive over JSON and signals a programming error, not an
15
+ * intent. Removing a key entirely still requires a full replace via updateConfig.
16
+ */
17
+ function validateConfigPatch(patch) {
18
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
19
+ throw new Error('patchIntegrationConfig: patch must be a non-null object');
20
+ }
21
+
22
+ const keys = Object.keys(patch);
23
+ if (keys.length === 0) {
24
+ throw new Error(
25
+ 'patchIntegrationConfig: patch must contain at least one key'
26
+ );
27
+ }
28
+
29
+ for (const key of keys) {
30
+ if (key.includes('.') || key.startsWith('$')) {
31
+ throw new Error(
32
+ `patchIntegrationConfig: patch key '${key}' cannot contain '.' or start with '$'`
33
+ );
34
+ }
35
+ if (patch[key] === undefined) {
36
+ throw new Error(
37
+ `patchIntegrationConfig: patch['${key}'] cannot be undefined`
38
+ );
39
+ }
40
+ }
41
+ }
42
+
43
+ module.exports = { validateConfigPatch };
@@ -12,6 +12,7 @@ const {
12
12
  const {
13
13
  IntegrationRepositoryInterface,
14
14
  } = require('./integration-repository-interface');
15
+ const { validateConfigPatch } = require('./config-patch-shared');
15
16
 
16
17
  class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
17
18
  constructor() {
@@ -124,7 +125,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
124
125
  userId: toObjectId(userId) || null,
125
126
  config,
126
127
  version: '0.0.0',
127
- status: 'ENABLED',
128
+ status: 'IN_CREATION',
128
129
  entityIds: toObjectIdArray(entities),
129
130
  messages: { errors: [], warnings: [], info: [], logs: [] },
130
131
  errors: [],
@@ -190,6 +191,57 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
190
191
  return this._mapIntegration(updated);
191
192
  }
192
193
 
194
+ /**
195
+ * Atomically merge a patch into the existing config with a per-key
196
+ * $set (config.<k> for each patch key), then re-read to shape the
197
+ * return value — DocumentDB's raw update command doesn't return the
198
+ * post-update document directly.
199
+ *
200
+ * @param {string} integrationId - Integration ID
201
+ * @param {Object} patch - Keys to merge into the existing config
202
+ * @returns {Promise<Object>} Updated integration object
203
+ */
204
+ async patchIntegrationConfig(integrationId, patch) {
205
+ validateConfigPatch(patch);
206
+ const objectId = toObjectId(integrationId);
207
+ if (!objectId) {
208
+ throw new Error(`Integration with id ${integrationId} not found`);
209
+ }
210
+
211
+ const $set = { updatedAt: new Date() };
212
+ for (const [key, value] of Object.entries(patch)) {
213
+ $set[`config.${key}`] = value;
214
+ }
215
+
216
+ const result = await updateOne(
217
+ this.prisma,
218
+ 'Integration',
219
+ { _id: objectId },
220
+ { $set }
221
+ );
222
+ if (result.writeErrors?.length) {
223
+ throw new Error(
224
+ `Failed to patch integration config: ${result.writeErrors[0].errmsg}`
225
+ );
226
+ }
227
+ if (!result.n) {
228
+ throw new Error(`Integration with id ${integrationId} not found`);
229
+ }
230
+
231
+ const updated = await findOne(this.prisma, 'Integration', { _id: objectId });
232
+ if (!updated) {
233
+ console.error('[IntegrationRepositoryDocumentDB] Integration not found after update', {
234
+ integrationId: fromObjectId(objectId),
235
+ patch,
236
+ });
237
+ throw new Error(
238
+ 'Failed to update integration: Document not found after update. ' +
239
+ 'This indicates a database consistency issue.'
240
+ );
241
+ }
242
+ return this._mapIntegration(updated);
243
+ }
244
+
193
245
  _mapIntegration(doc) {
194
246
  const messages = this._extractMessages(doc);
195
247
  return {
@@ -200,6 +252,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
200
252
  version: doc?.version ?? null,
201
253
  status: doc?.status ?? null,
202
254
  messages,
255
+ createdAt: doc?.createdAt ?? null,
203
256
  };
204
257
  }
205
258
 
@@ -123,6 +123,21 @@ class IntegrationRepositoryInterface {
123
123
  throw new Error('Method updateIntegrationConfig must be implemented by subclass');
124
124
  }
125
125
 
126
+ /**
127
+ * Atomically merge a partial update into an integration's config. Keys
128
+ * not present in the patch are left untouched; concurrent patches with
129
+ * disjoint keys must both persist. Patch values may not be null or
130
+ * undefined — key deletion is only supported via updateIntegrationConfig.
131
+ *
132
+ * @param {string|number} integrationId - Integration ID
133
+ * @param {Object} patch - Keys to merge into the existing config
134
+ * @returns {Promise<Object>} Updated integration object
135
+ * @abstract
136
+ */
137
+ async patchIntegrationConfig(integrationId, patch) {
138
+ throw new Error('Method patchIntegrationConfig must be implemented by subclass');
139
+ }
140
+
126
141
  /**
127
142
  * Find all integrations whose entity set includes the given entity ID.
128
143
  *
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
+ const { validateConfigPatch } = require('./config-patch-shared');
5
6
 
6
7
  /**
7
8
  * MongoDB Integration Repository Adapter
@@ -48,6 +49,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
48
49
  version: integration.version,
49
50
  status: integration.status,
50
51
  messages: integration.messages,
52
+ createdAt: integration.createdAt,
51
53
  }));
52
54
  }
53
55
 
@@ -325,6 +327,52 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
325
327
  messages: integration.messages,
326
328
  };
327
329
  }
330
+
331
+ /**
332
+ * Atomically merge a patch into the existing config via findAndModify,
333
+ * so the write and the post-write read happen in one server-side round
334
+ * trip with no JS-side read-modify-write to race on. entityIds is a
335
+ * scalar array directly on the Integration document in Mongo, so the
336
+ * raw document already carries everything needed to shape the return
337
+ * value — no follow-up findUnique.
338
+ *
339
+ * @param {string} integrationId - Integration ID
340
+ * @param {Object} patch - Keys to merge into the existing config
341
+ * @returns {Promise<Object>} Updated integration object
342
+ */
343
+ async patchIntegrationConfig(integrationId, patch) {
344
+ validateConfigPatch(patch);
345
+
346
+ const $set = {};
347
+ for (const [key, value] of Object.entries(patch)) {
348
+ $set[`config.${key}`] = value;
349
+ }
350
+ $set.updatedAt = new Date();
351
+
352
+ const result = await this.prisma.$runCommandRaw({
353
+ findAndModify: 'Integration',
354
+ query: { _id: { $oid: integrationId } },
355
+ update: { $set },
356
+ new: true,
357
+ });
358
+
359
+ const doc = result && result.value;
360
+ if (!doc) {
361
+ throw new Error(`Integration with id ${integrationId} not found`);
362
+ }
363
+
364
+ return {
365
+ id: doc._id.$oid ?? doc._id,
366
+ entitiesIds: (doc.entityIds || []).map(
367
+ (entityId) => entityId.$oid ?? entityId
368
+ ),
369
+ userId: doc.userId?.$oid ?? doc.userId ?? null,
370
+ config: doc.config,
371
+ version: doc.version,
372
+ status: doc.status,
373
+ messages: doc.messages,
374
+ };
375
+ }
328
376
  }
329
377
 
330
378
  module.exports = { IntegrationRepositoryMongo };
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
+ const { validateConfigPatch } = require('./config-patch-shared');
5
6
 
6
7
  /**
7
8
  * PostgreSQL Integration Repository Adapter
@@ -84,6 +85,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
84
85
  version: converted.version,
85
86
  status: converted.status,
86
87
  messages: converted.messages,
88
+ createdAt: converted.createdAt,
87
89
  };
88
90
  });
89
91
  }
@@ -348,6 +350,32 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
348
350
  };
349
351
  }
350
352
 
353
+ /**
354
+ * Atomically merge a patch into the existing config with a single
355
+ * jsonb concatenation — the database does the merge, so there is no
356
+ * JS-side read-modify-write for concurrent callers to race on.
357
+ *
358
+ * @param {string} integrationId - Integration ID (string from application layer)
359
+ * @param {Object} patch - Keys to merge into the existing config
360
+ * @returns {Promise<Object>} Updated integration object with string IDs
361
+ */
362
+ async patchIntegrationConfig(integrationId, patch) {
363
+ validateConfigPatch(patch);
364
+
365
+ const intId = this._convertId(integrationId);
366
+ const affectedCount = await this.prisma.$executeRawUnsafe(
367
+ 'UPDATE "Integration" SET "config" = COALESCE("config", \'{}\'::jsonb) || $1::jsonb, "updatedAt" = NOW() WHERE "id" = $2',
368
+ JSON.stringify(patch),
369
+ intId
370
+ );
371
+
372
+ if (!affectedCount) {
373
+ throw new Error(`Integration with id ${integrationId} not found`);
374
+ }
375
+
376
+ return this.findIntegrationById(integrationId);
377
+ }
378
+
351
379
  /**
352
380
  * Find all integrations whose entity set includes the given entity ID.
353
381
  *
@@ -32,6 +32,7 @@ class DummyIntegration extends IntegrationBase {
32
32
  constructor(params) {
33
33
  super(params);
34
34
  this.sendSpy = jest.fn();
35
+ this.testAuthSpy = jest.fn();
35
36
  this.eventCallHistory = [];
36
37
  this.events = {};
37
38
 
@@ -59,6 +60,9 @@ class DummyIntegration extends IntegrationBase {
59
60
  if (event === 'ON_UPDATE') {
60
61
  await this.onUpdate(data);
61
62
  }
63
+ if (event === 'ON_DELETE') {
64
+ await this.onDelete(data);
65
+ }
62
66
  return { event, data };
63
67
  }
64
68
 
@@ -66,6 +70,10 @@ class DummyIntegration extends IntegrationBase {
66
70
  return;
67
71
  }
68
72
 
73
+ async testAuth() {
74
+ this.testAuthSpy();
75
+ }
76
+
69
77
  async onCreate({ integrationId }) {
70
78
  return;
71
79
  }
@@ -1,4 +1,5 @@
1
1
  const { v4: uuid } = require('uuid');
2
+ const { validateConfigPatch } = require('../../repositories/config-patch-shared');
2
3
 
3
4
  class TestIntegrationRepository {
4
5
  constructor() {
@@ -15,8 +16,9 @@ class TestIntegrationRepository {
15
16
  userId: userId,
16
17
  config,
17
18
  version: '0.0.0',
18
- status: 'NEW',
19
+ status: 'IN_CREATION',
19
20
  messages: {},
21
+ createdAt: new Date(),
20
22
  };
21
23
  this.store.set(id, record);
22
24
  this.operationHistory.push({ operation: 'create', id, userId, config });
@@ -72,6 +74,9 @@ class TestIntegrationRepository {
72
74
  }
73
75
 
74
76
  async updateIntegrationConfig(id, config) {
77
+ if (config === null || config === undefined) {
78
+ throw new Error('Config parameter is required');
79
+ }
75
80
  const rec = this.store.get(id);
76
81
  if (!rec) {
77
82
  this.operationHistory.push({ operation: 'updateConfig', id, success: false });
@@ -82,6 +87,24 @@ class TestIntegrationRepository {
82
87
  return rec;
83
88
  }
84
89
 
90
+ async patchIntegrationConfig(id, patch) {
91
+ validateConfigPatch(patch);
92
+ if (!this.store.has(id)) {
93
+ this.operationHistory.push({ operation: 'patchConfig', id, success: false });
94
+ throw new Error(`Integration with id ${id} not found`);
95
+ }
96
+ // Simulates the DB round trip real adapters make: the merge reads
97
+ // the record fresh at write time rather than off a snapshot taken
98
+ // before the await, so concurrent patches to the same id can't lose
99
+ // each other's keys — same guarantee as `config || $1::jsonb` in
100
+ // postgres or a mongo/documentdb findAndModify.
101
+ await Promise.resolve();
102
+ const rec = this.store.get(id);
103
+ rec.config = { ...rec.config, ...patch };
104
+ this.operationHistory.push({ operation: 'patchConfig', id, success: true });
105
+ return rec;
106
+ }
107
+
85
108
  async deleteIntegrationById(id) {
86
109
  const existed = this.store.has(id);
87
110
  const result = this.store.delete(id);