@aifabrix/builder 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/integration/hubspot/README.md +136 -0
  2. package/integration/hubspot/env.template +9 -0
  3. package/integration/hubspot/hubspot-deploy-company.json +200 -0
  4. package/integration/hubspot/hubspot-deploy-contact.json +228 -0
  5. package/integration/hubspot/hubspot-deploy-deal.json +248 -0
  6. package/integration/hubspot/hubspot-deploy.json +91 -0
  7. package/integration/hubspot/variables.yaml +17 -0
  8. package/lib/app-config.js +4 -3
  9. package/lib/app-deploy.js +8 -20
  10. package/lib/app-dockerfile.js +7 -9
  11. package/lib/app-prompts.js +6 -5
  12. package/lib/app-push.js +9 -9
  13. package/lib/app-register.js +23 -5
  14. package/lib/app-rotate-secret.js +10 -0
  15. package/lib/app-run.js +5 -11
  16. package/lib/app.js +42 -14
  17. package/lib/build.js +20 -16
  18. package/lib/cli.js +61 -2
  19. package/lib/datasource-deploy.js +14 -20
  20. package/lib/external-system-deploy.js +123 -40
  21. package/lib/external-system-download.js +431 -0
  22. package/lib/external-system-generator.js +13 -10
  23. package/lib/external-system-test.js +446 -0
  24. package/lib/generator-builders.js +323 -0
  25. package/lib/generator.js +200 -292
  26. package/lib/schema/application-schema.json +853 -852
  27. package/lib/schema/external-datasource.schema.json +823 -49
  28. package/lib/schema/external-system.schema.json +96 -78
  29. package/lib/templates.js +1 -1
  30. package/lib/utils/cli-utils.js +4 -4
  31. package/lib/utils/external-system-display.js +159 -0
  32. package/lib/utils/external-system-validators.js +245 -0
  33. package/lib/utils/paths.js +151 -1
  34. package/lib/utils/schema-resolver.js +7 -2
  35. package/lib/validator.js +5 -2
  36. package/package.json +1 -1
@@ -0,0 +1,446 @@
1
+ /**
2
+ * External System Testing Module
3
+ *
4
+ * Provides unit testing (local validation) and integration testing (via dataplane)
5
+ * for external systems and datasources.
6
+ *
7
+ * @fileoverview External system testing functionality for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs').promises;
13
+ const fsSync = require('fs');
14
+ const path = require('path');
15
+ const yaml = require('js-yaml');
16
+ const chalk = require('chalk');
17
+ const { authenticatedApiCall } = require('./utils/api');
18
+ const { getDeploymentAuth } = require('./utils/token-manager');
19
+ const { getDataplaneUrl } = require('./datasource-deploy');
20
+ const { getConfig } = require('./config');
21
+ const { detectAppType } = require('./utils/paths');
22
+ const externalSystemSchema = require('./schema/external-system.schema.json');
23
+ const externalDataSourceSchema = require('./schema/external-datasource.schema.json');
24
+ const logger = require('./utils/logger');
25
+ const {
26
+ validateFieldMappings,
27
+ validateMetadataSchema,
28
+ validateAgainstSchema
29
+ } = require('./utils/external-system-validators');
30
+ const {
31
+ displayTestResults,
32
+ displayIntegrationTestResults
33
+ } = require('./utils/external-system-display');
34
+
35
+ /**
36
+ * Loads and validates external system files
37
+ * @async
38
+ * @param {string} appName - Application name
39
+ * @returns {Promise<Object>} Loaded files and validation results
40
+ */
41
+ async function loadExternalSystemFiles(appName) {
42
+ const { appPath } = await detectAppType(appName);
43
+ const variablesPath = path.join(appPath, 'variables.yaml');
44
+
45
+ // Load variables.yaml
46
+ if (!fsSync.existsSync(variablesPath)) {
47
+ throw new Error(`variables.yaml not found: ${variablesPath}`);
48
+ }
49
+
50
+ const variablesContent = await fs.readFile(variablesPath, 'utf8');
51
+ let variables;
52
+ try {
53
+ variables = yaml.load(variablesContent);
54
+ } catch (error) {
55
+ throw new Error(`Invalid YAML syntax in variables.yaml: ${error.message}`);
56
+ }
57
+
58
+ if (!variables.externalIntegration) {
59
+ throw new Error('externalIntegration block not found in variables.yaml');
60
+ }
61
+
62
+ // Load system file(s)
63
+ const schemaBasePath = variables.externalIntegration.schemaBasePath || './';
64
+ const systemFiles = variables.externalIntegration.systems || [];
65
+ const systemJsonFiles = [];
66
+
67
+ for (const systemFile of systemFiles) {
68
+ const systemPath = path.isAbsolute(schemaBasePath)
69
+ ? path.join(schemaBasePath, systemFile)
70
+ : path.join(appPath, schemaBasePath, systemFile);
71
+
72
+ if (!fsSync.existsSync(systemPath)) {
73
+ throw new Error(`System file not found: ${systemPath}`);
74
+ }
75
+
76
+ const systemContent = await fs.readFile(systemPath, 'utf8');
77
+ let systemJson;
78
+ try {
79
+ systemJson = JSON.parse(systemContent);
80
+ } catch (error) {
81
+ throw new Error(`Invalid JSON syntax in ${systemFile}: ${error.message}`);
82
+ }
83
+
84
+ systemJsonFiles.push({ path: systemPath, data: systemJson });
85
+ }
86
+
87
+ // Load datasource files
88
+ const datasourceFiles = variables.externalIntegration.dataSources || [];
89
+ const datasourceJsonFiles = [];
90
+
91
+ for (const datasourceFile of datasourceFiles) {
92
+ const datasourcePath = path.isAbsolute(schemaBasePath)
93
+ ? path.join(schemaBasePath, datasourceFile)
94
+ : path.join(appPath, schemaBasePath, datasourceFile);
95
+
96
+ if (!fsSync.existsSync(datasourcePath)) {
97
+ throw new Error(`Datasource file not found: ${datasourcePath}`);
98
+ }
99
+
100
+ const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
101
+ let datasourceJson;
102
+ try {
103
+ datasourceJson = JSON.parse(datasourceContent);
104
+ } catch (error) {
105
+ throw new Error(`Invalid JSON syntax in ${datasourceFile}: ${error.message}`);
106
+ }
107
+
108
+ datasourceJsonFiles.push({ path: datasourcePath, data: datasourceJson });
109
+ }
110
+
111
+ return {
112
+ variables,
113
+ systemFiles: systemJsonFiles,
114
+ datasourceFiles: datasourceJsonFiles
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Runs unit tests for external system (local validation, no API calls)
120
+ * @async
121
+ * @function testExternalSystem
122
+ * @param {string} appName - Application name
123
+ * @param {Object} options - Test options
124
+ * @param {string} [options.datasource] - Test specific datasource only
125
+ * @param {boolean} [options.verbose] - Show detailed validation output
126
+ * @returns {Promise<Object>} Test results
127
+ * @throws {Error} If testing fails
128
+ */
129
+ async function testExternalSystem(appName, options = {}) {
130
+ if (!appName || typeof appName !== 'string') {
131
+ throw new Error('App name is required and must be a string');
132
+ }
133
+
134
+ try {
135
+ logger.log(chalk.blue(`\n🧪 Running unit tests for: ${appName}`));
136
+
137
+ // Load files
138
+ const { variables: _variables, systemFiles, datasourceFiles } = await loadExternalSystemFiles(appName);
139
+
140
+ const results = {
141
+ valid: true,
142
+ errors: [],
143
+ warnings: [],
144
+ systemResults: [],
145
+ datasourceResults: []
146
+ };
147
+
148
+ // Validate system files
149
+ logger.log(chalk.blue('šŸ“‹ Validating system files...'));
150
+ for (const systemFile of systemFiles) {
151
+ const validation = validateAgainstSchema(systemFile.data, externalSystemSchema);
152
+ if (!validation.valid) {
153
+ results.valid = false;
154
+ results.errors.push(`System file ${path.basename(systemFile.path)}: ${validation.errors.join(', ')}`);
155
+ } else {
156
+ results.systemResults.push({
157
+ file: path.basename(systemFile.path),
158
+ valid: true
159
+ });
160
+ }
161
+ }
162
+
163
+ // Validate datasource files
164
+ logger.log(chalk.blue('šŸ“‹ Validating datasource files...'));
165
+ const datasourcesToTest = options.datasource
166
+ ? datasourceFiles.filter(ds => ds.data.key === options.datasource || path.basename(ds.path).includes(options.datasource))
167
+ : datasourceFiles;
168
+
169
+ for (const datasourceFile of datasourcesToTest) {
170
+ const datasource = datasourceFile.data;
171
+ const datasourceResult = {
172
+ key: datasource.key,
173
+ file: path.basename(datasourceFile.path),
174
+ valid: true,
175
+ errors: [],
176
+ warnings: [],
177
+ fieldMappingResults: null,
178
+ metadataSchemaResults: null
179
+ };
180
+
181
+ // Validate against schema
182
+ const schemaValidation = validateAgainstSchema(datasource, externalDataSourceSchema);
183
+ if (!schemaValidation.valid) {
184
+ datasourceResult.valid = false;
185
+ datasourceResult.errors.push(...schemaValidation.errors);
186
+ results.valid = false;
187
+ }
188
+
189
+ // Validate relationships
190
+ if (systemFiles.length > 0) {
191
+ const systemKey = systemFiles[0].data.key;
192
+ if (datasource.systemKey !== systemKey) {
193
+ datasourceResult.valid = false;
194
+ datasourceResult.errors.push(`systemKey mismatch: expected '${systemKey}', got '${datasource.systemKey}'`);
195
+ results.valid = false;
196
+ }
197
+ }
198
+
199
+ // Test with testPayload if available
200
+ if (datasource.testPayload && datasource.testPayload.payloadTemplate) {
201
+ logger.log(chalk.blue(` Testing datasource: ${datasource.key}`));
202
+
203
+ // Validate field mappings
204
+ const fieldMappingResults = validateFieldMappings(datasource, datasource.testPayload);
205
+ datasourceResult.fieldMappingResults = fieldMappingResults;
206
+ if (!fieldMappingResults.valid) {
207
+ datasourceResult.valid = false;
208
+ datasourceResult.errors.push(...fieldMappingResults.errors);
209
+ results.valid = false;
210
+ }
211
+ if (fieldMappingResults.warnings.length > 0) {
212
+ datasourceResult.warnings.push(...fieldMappingResults.warnings);
213
+ }
214
+
215
+ // Validate metadata schema
216
+ const metadataSchemaResults = validateMetadataSchema(datasource, datasource.testPayload);
217
+ datasourceResult.metadataSchemaResults = metadataSchemaResults;
218
+ if (!metadataSchemaResults.valid) {
219
+ datasourceResult.valid = false;
220
+ datasourceResult.errors.push(...metadataSchemaResults.errors);
221
+ results.valid = false;
222
+ }
223
+ if (metadataSchemaResults.warnings.length > 0) {
224
+ datasourceResult.warnings.push(...metadataSchemaResults.warnings);
225
+ }
226
+
227
+ // Compare with expectedResult if provided
228
+ if (datasource.testPayload.expectedResult && fieldMappingResults.mappedFields) {
229
+ // This would require actual transformation execution, which is complex
230
+ // For now, we just note that expectedResult is present
231
+ if (options.verbose) {
232
+ datasourceResult.warnings.push('expectedResult validation not yet implemented (requires transformation engine)');
233
+ }
234
+ }
235
+ } else {
236
+ datasourceResult.warnings.push('No testPayload.payloadTemplate found - skipping field mapping and metadata schema tests');
237
+ }
238
+
239
+ results.datasourceResults.push(datasourceResult);
240
+ }
241
+
242
+ return results;
243
+ } catch (error) {
244
+ throw new Error(`Failed to run unit tests: ${error.message}`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Retries API call with exponential backoff
250
+ * @async
251
+ * @param {Function} fn - Function to retry
252
+ * @param {number} maxRetries - Maximum number of retries
253
+ * @param {number} backoffMs - Initial backoff in milliseconds
254
+ * @returns {Promise<*>} Function result
255
+ */
256
+ async function retryApiCall(fn, maxRetries = 3, backoffMs = 1000) {
257
+ let lastError;
258
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
259
+ try {
260
+ return await fn();
261
+ } catch (error) {
262
+ lastError = error;
263
+ if (attempt < maxRetries) {
264
+ const delay = backoffMs * Math.pow(2, attempt);
265
+ await new Promise(resolve => setTimeout(resolve, delay));
266
+ }
267
+ }
268
+ }
269
+ throw lastError;
270
+ }
271
+
272
+ /**
273
+ * Calls pipeline test endpoint
274
+ * @async
275
+ * @param {string} systemKey - System key
276
+ * @param {string} datasourceKey - Datasource key
277
+ * @param {Object} payloadTemplate - Test payload template
278
+ * @param {string} dataplaneUrl - Dataplane URL
279
+ * @param {Object} authConfig - Authentication configuration
280
+ * @param {number} timeout - Request timeout in milliseconds
281
+ * @returns {Promise<Object>} Test response
282
+ */
283
+ async function callPipelineTestEndpoint(systemKey, datasourceKey, payloadTemplate, dataplaneUrl, authConfig, timeout = 30000) {
284
+ const endpoint = `${dataplaneUrl}/api/v1/pipeline/${systemKey}/${datasourceKey}/test`;
285
+
286
+ const response = await retryApiCall(async() => {
287
+ return await authenticatedApiCall(
288
+ endpoint,
289
+ {
290
+ method: 'POST',
291
+ body: JSON.stringify({ payloadTemplate }),
292
+ timeout
293
+ },
294
+ authConfig.token
295
+ );
296
+ });
297
+
298
+ if (!response.success || !response.data) {
299
+ throw new Error(`Test endpoint failed: ${response.error || response.formattedError || 'Unknown error'}`);
300
+ }
301
+
302
+ return response.data.data || response.data;
303
+ }
304
+
305
+ /**
306
+ * Runs integration tests via dataplane pipeline API
307
+ * @async
308
+ * @function testExternalSystemIntegration
309
+ * @param {string} appName - Application name
310
+ * @param {Object} options - Test options
311
+ * @param {string} [options.datasource] - Test specific datasource only
312
+ * @param {string} [options.payload] - Path to custom test payload file
313
+ * @param {string} [options.environment] - Environment (dev, tst, pro)
314
+ * @param {string} [options.controller] - Controller URL
315
+ * @param {boolean} [options.verbose] - Show detailed test output
316
+ * @param {number} [options.timeout] - Request timeout in milliseconds
317
+ * @returns {Promise<Object>} Integration test results
318
+ * @throws {Error} If testing fails
319
+ */
320
+ async function testExternalSystemIntegration(appName, options = {}) {
321
+ if (!appName || typeof appName !== 'string') {
322
+ throw new Error('App name is required and must be a string');
323
+ }
324
+
325
+ try {
326
+ logger.log(chalk.blue(`\nšŸ”— Running integration tests for: ${appName}`));
327
+
328
+ // Load files
329
+ const { variables: _variables, systemFiles, datasourceFiles } = await loadExternalSystemFiles(appName);
330
+
331
+ if (systemFiles.length === 0) {
332
+ throw new Error('No system files found');
333
+ }
334
+
335
+ const systemKey = systemFiles[0].data.key;
336
+
337
+ // Get authentication
338
+ const config = await getConfig();
339
+ const environment = options.environment || 'dev';
340
+ const controllerUrl = options.controller || config.deployment?.controllerUrl || 'http://localhost:3000';
341
+ const authConfig = await getDeploymentAuth(controllerUrl, environment, appName);
342
+
343
+ if (!authConfig.token && !authConfig.clientId) {
344
+ throw new Error('Authentication required. Run "aifabrix login" or "aifabrix app register" first.');
345
+ }
346
+
347
+ // Get dataplane URL
348
+ logger.log(chalk.blue('🌐 Getting dataplane URL from controller...'));
349
+ const dataplaneUrl = await getDataplaneUrl(controllerUrl, appName, environment, authConfig);
350
+ logger.log(chalk.green(`āœ“ Dataplane URL: ${dataplaneUrl}`));
351
+
352
+ // Determine datasources to test
353
+ const datasourcesToTest = options.datasource
354
+ ? datasourceFiles.filter(ds => ds.data.key === options.datasource || path.basename(ds.path).includes(options.datasource))
355
+ : datasourceFiles;
356
+
357
+ if (datasourcesToTest.length === 0) {
358
+ throw new Error('No datasources found to test');
359
+ }
360
+
361
+ const results = {
362
+ success: true,
363
+ systemKey,
364
+ datasourceResults: []
365
+ };
366
+
367
+ // Load custom payload if provided
368
+ let customPayload = null;
369
+ if (options.payload) {
370
+ const payloadPath = path.isAbsolute(options.payload) ? options.payload : path.join(process.cwd(), options.payload);
371
+ const payloadContent = await fs.readFile(payloadPath, 'utf8');
372
+ customPayload = JSON.parse(payloadContent);
373
+ }
374
+
375
+ // Test each datasource
376
+ for (const datasourceFile of datasourcesToTest) {
377
+ const datasource = datasourceFile.data;
378
+ const datasourceKey = datasource.key;
379
+
380
+ logger.log(chalk.blue(`\nšŸ“” Testing datasource: ${datasourceKey}`));
381
+
382
+ // Determine payload to use
383
+ let payloadTemplate;
384
+ if (customPayload) {
385
+ payloadTemplate = customPayload;
386
+ } else if (datasource.testPayload && datasource.testPayload.payloadTemplate) {
387
+ payloadTemplate = datasource.testPayload.payloadTemplate;
388
+ } else {
389
+ logger.log(chalk.yellow(` ⚠ No test payload found for ${datasourceKey}, skipping...`));
390
+ results.datasourceResults.push({
391
+ key: datasourceKey,
392
+ skipped: true,
393
+ reason: 'No test payload available'
394
+ });
395
+ continue;
396
+ }
397
+
398
+ try {
399
+ const testResponse = await callPipelineTestEndpoint(
400
+ systemKey,
401
+ datasourceKey,
402
+ payloadTemplate,
403
+ dataplaneUrl,
404
+ authConfig,
405
+ parseInt(options.timeout, 10) || 30000
406
+ );
407
+
408
+ const datasourceResult = {
409
+ key: datasourceKey,
410
+ skipped: false,
411
+ success: testResponse.success !== false,
412
+ validationResults: testResponse.validationResults || {},
413
+ fieldMappingResults: testResponse.fieldMappingResults || {},
414
+ endpointTestResults: testResponse.endpointTestResults || {}
415
+ };
416
+
417
+ if (!datasourceResult.success) {
418
+ results.success = false;
419
+ }
420
+
421
+ results.datasourceResults.push(datasourceResult);
422
+ } catch (error) {
423
+ results.success = false;
424
+ results.datasourceResults.push({
425
+ key: datasourceKey,
426
+ skipped: false,
427
+ success: false,
428
+ error: error.message
429
+ });
430
+ }
431
+ }
432
+
433
+ return results;
434
+ } catch (error) {
435
+ throw new Error(`Failed to run integration tests: ${error.message}`);
436
+ }
437
+ }
438
+
439
+ module.exports = {
440
+ testExternalSystem,
441
+ testExternalSystemIntegration,
442
+ displayTestResults,
443
+ displayIntegrationTestResults,
444
+ callPipelineTestEndpoint,
445
+ retryApiCall
446
+ };