|
1 | | -import findup from 'findup-sync'; |
2 | 1 | import fs from 'fs-extra'; |
3 | 2 | import { |
4 | 3 | getLocalConfigDefaultFilePath, |
5 | 4 | getConfigPathEnvironmentVariables, |
| 5 | + doesConfigFileExistAtPath, |
6 | 6 | readConfigFile, |
7 | 7 | removeUndefinedFieldsFromConfigAccount, |
| 8 | + formatConfigForWrite, |
8 | 9 | writeConfigFile, |
| 10 | + parseConfig, |
9 | 11 | normalizeParsedConfig, |
| 12 | + convertToDeprecatedConfig, |
10 | 13 | buildConfigFromEnvironment, |
| 14 | + getAccountIdentifierAndType, |
11 | 15 | getConfigAccountByIdentifier, |
| 16 | + getConfigAccountByInferredIdentifier, |
12 | 17 | getConfigAccountIndexById, |
13 | 18 | validateConfigAccount, |
14 | | - getAccountIdentifierAndType, |
| 19 | + handleConfigFileSystemError, |
15 | 20 | } from '../utils'; |
16 | 21 | import { HubSpotConfigError } from '../../models/HubSpotConfigError'; |
17 | 22 | import { getCwd } from '../../lib/path'; |
@@ -39,7 +44,6 @@ jest.mock('findup-sync'); |
39 | 44 | jest.mock('../../lib/path'); |
40 | 45 | jest.mock('fs-extra'); |
41 | 46 |
|
42 | | -const mockFindup = findup as jest.MockedFunction<typeof findup>; |
43 | 47 | const mockCwd = getCwd as jest.MockedFunction<typeof getCwd>; |
44 | 48 | const mockFs = fs as jest.Mocked<typeof fs>; |
45 | 49 |
|
@@ -224,6 +228,127 @@ describe('config/utils', () => { |
224 | 228 | }); |
225 | 229 | }); |
226 | 230 |
|
| 231 | + describe('convertToDeprecatedConfig()', () => { |
| 232 | + it('converts account fields to portal fields', () => { |
| 233 | + const deprecated = convertToDeprecatedConfig(CONFIG); |
| 234 | + |
| 235 | + expect(deprecated.portals).toBeDefined(); |
| 236 | + expect(deprecated.accounts).toBeUndefined(); |
| 237 | + expect(deprecated.portals).toHaveLength(1); |
| 238 | + expect(deprecated.portals![0]).toHaveProperty('portalId', 123); |
| 239 | + expect(deprecated.portals![0]).not.toHaveProperty('accountId'); |
| 240 | + }); |
| 241 | + |
| 242 | + it('converts defaultAccount to defaultPortal', () => { |
| 243 | + const deprecated = convertToDeprecatedConfig(CONFIG); |
| 244 | + |
| 245 | + expect(deprecated.defaultPortal).toBe('test-account'); |
| 246 | + expect(deprecated.defaultAccount).toBeUndefined(); |
| 247 | + }); |
| 248 | + |
| 249 | + it('preserves personal access key account fields', () => { |
| 250 | + const deprecated = convertToDeprecatedConfig(CONFIG); |
| 251 | + |
| 252 | + expect(deprecated.portals![0]).toMatchObject({ |
| 253 | + name: 'test-account', |
| 254 | + portalId: 123, |
| 255 | + authType: 'personalaccesskey', |
| 256 | + personalAccessKey: 'test-key', |
| 257 | + env: 'qa', |
| 258 | + }); |
| 259 | + }); |
| 260 | + |
| 261 | + it('preserves OAuth account fields', () => { |
| 262 | + const oauthConfig = { |
| 263 | + ...CONFIG, |
| 264 | + accounts: [OAUTH_ACCOUNT], |
| 265 | + defaultAccount: 123, |
| 266 | + }; |
| 267 | + const deprecated = convertToDeprecatedConfig(oauthConfig); |
| 268 | + |
| 269 | + expect(deprecated.portals![0]).toMatchObject({ |
| 270 | + name: '123', |
| 271 | + portalId: 123, |
| 272 | + authType: 'oauth2', |
| 273 | + auth: { |
| 274 | + clientId: 'test-client-id', |
| 275 | + clientSecret: 'test-client-secret', |
| 276 | + tokenInfo: { |
| 277 | + refreshToken: 'test-refresh-token', |
| 278 | + }, |
| 279 | + scopes: ['content', 'hubdb', 'files'], |
| 280 | + }, |
| 281 | + }); |
| 282 | + }); |
| 283 | + |
| 284 | + it('preserves API key account fields', () => { |
| 285 | + const apiKeyConfig = { |
| 286 | + ...CONFIG, |
| 287 | + accounts: [API_KEY_ACCOUNT], |
| 288 | + defaultAccount: 123, |
| 289 | + }; |
| 290 | + const deprecated = convertToDeprecatedConfig(apiKeyConfig); |
| 291 | + |
| 292 | + expect(deprecated.portals![0]).toMatchObject({ |
| 293 | + name: '123', |
| 294 | + portalId: 123, |
| 295 | + authType: 'apikey', |
| 296 | + apiKey: 'test-api-key', |
| 297 | + }); |
| 298 | + }); |
| 299 | + |
| 300 | + it('handles multiple accounts', () => { |
| 301 | + const multiAccountConfig = { |
| 302 | + ...CONFIG, |
| 303 | + accounts: [PAK_ACCOUNT, OAUTH_ACCOUNT, API_KEY_ACCOUNT], |
| 304 | + defaultAccount: 123, |
| 305 | + }; |
| 306 | + const deprecated = convertToDeprecatedConfig(multiAccountConfig); |
| 307 | + |
| 308 | + expect(deprecated.portals).toHaveLength(3); |
| 309 | + expect(deprecated.portals![0].portalId).toBe(123); |
| 310 | + expect(deprecated.portals![1].portalId).toBe(123); |
| 311 | + expect(deprecated.portals![2].portalId).toBe(123); |
| 312 | + }); |
| 313 | + |
| 314 | + it('handles config without defaultAccount', () => { |
| 315 | + const configWithoutDefault = { |
| 316 | + ...CONFIG, |
| 317 | + defaultAccount: undefined, |
| 318 | + }; |
| 319 | + const deprecated = convertToDeprecatedConfig(configWithoutDefault); |
| 320 | + |
| 321 | + expect(deprecated.defaultPortal).toBeUndefined(); |
| 322 | + expect(deprecated.defaultAccount).toBeUndefined(); |
| 323 | + }); |
| 324 | + |
| 325 | + it('preserves all other config fields', () => { |
| 326 | + const deprecated = convertToDeprecatedConfig(CONFIG); |
| 327 | + |
| 328 | + expect(deprecated.defaultCmsPublishMode).toBe('publish'); |
| 329 | + expect(deprecated.httpTimeout).toBe(1000); |
| 330 | + expect(deprecated.httpUseLocalhost).toBe(true); |
| 331 | + expect(deprecated.allowUsageTracking).toBe(true); |
| 332 | + }); |
| 333 | + |
| 334 | + it('does not mutate the original config', () => { |
| 335 | + const testConfig = structuredClone(CONFIG); |
| 336 | + |
| 337 | + convertToDeprecatedConfig(testConfig); |
| 338 | + |
| 339 | + // Verify original structure is preserved |
| 340 | + expect(testConfig.accounts).toBeDefined(); |
| 341 | + expect(testConfig.accounts).toHaveLength(1); |
| 342 | + expect(testConfig.accounts[0]).toHaveProperty('accountId', 123); |
| 343 | + expect(testConfig.defaultAccount).toBe(123); |
| 344 | + |
| 345 | + // Verify deprecated fields were not added to original |
| 346 | + expect(testConfig).not.toHaveProperty('portals'); |
| 347 | + expect(testConfig).not.toHaveProperty('defaultPortal'); |
| 348 | + expect(testConfig.accounts[0]).not.toHaveProperty('portalId'); |
| 349 | + }); |
| 350 | + }); |
| 351 | + |
227 | 352 | describe('buildConfigFromEnvironment()', () => { |
228 | 353 | it('builds personal access key config', () => { |
229 | 354 | process.env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY] = |
@@ -385,4 +510,193 @@ describe('config/utils', () => { |
385 | 510 | expect(identifierType).toBe('accountId'); |
386 | 511 | }); |
387 | 512 | }); |
| 513 | + |
| 514 | + describe('doesConfigFileExistAtPath()', () => { |
| 515 | + it('returns true when file exists', () => { |
| 516 | + mockFs.existsSync.mockReturnValue(true); |
| 517 | + expect(doesConfigFileExistAtPath('/path/to/config.yml')).toBe(true); |
| 518 | + }); |
| 519 | + |
| 520 | + it('returns false when file does not exist', () => { |
| 521 | + mockFs.existsSync.mockReturnValue(false); |
| 522 | + expect(doesConfigFileExistAtPath('/path/to/config.yml')).toBe(false); |
| 523 | + }); |
| 524 | + |
| 525 | + it('throws HubSpotConfigError on error', () => { |
| 526 | + mockFs.existsSync.mockImplementation(() => { |
| 527 | + throw new Error('Permission denied'); |
| 528 | + }); |
| 529 | + expect(() => doesConfigFileExistAtPath('/path/to/config.yml')).toThrow( |
| 530 | + HubSpotConfigError |
| 531 | + ); |
| 532 | + }); |
| 533 | + }); |
| 534 | + |
| 535 | + describe('formatConfigForWrite()', () => { |
| 536 | + it('formats config with consistent field order', () => { |
| 537 | + const formattedConfig = formatConfigForWrite(CONFIG); |
| 538 | + const keys = Object.keys(formattedConfig); |
| 539 | + expect(keys[0]).toBe('defaultAccount'); |
| 540 | + expect(keys[keys.length - 1]).toBe('accounts'); |
| 541 | + }); |
| 542 | + |
| 543 | + it('removes undefined fields from accounts', () => { |
| 544 | + const configWithUndefined = { |
| 545 | + ...CONFIG, |
| 546 | + accounts: [ |
| 547 | + { |
| 548 | + ...PAK_ACCOUNT, |
| 549 | + someUndefinedField: undefined, |
| 550 | + } as any, |
| 551 | + ], |
| 552 | + }; |
| 553 | + const formattedConfig = formatConfigForWrite(configWithUndefined); |
| 554 | + expect(formattedConfig.accounts[0]).not.toHaveProperty( |
| 555 | + 'someUndefinedField' |
| 556 | + ); |
| 557 | + }); |
| 558 | + |
| 559 | + it('preserves all account types', () => { |
| 560 | + const multiAccountConfig = { |
| 561 | + ...CONFIG, |
| 562 | + accounts: [PAK_ACCOUNT, OAUTH_ACCOUNT, API_KEY_ACCOUNT], |
| 563 | + }; |
| 564 | + const formatted = formatConfigForWrite(multiAccountConfig); |
| 565 | + expect(formatted.accounts).toHaveLength(3); |
| 566 | + expect(formatted.accounts[0].authType).toBe('personalaccesskey'); |
| 567 | + expect(formatted.accounts[1].authType).toBe('oauth2'); |
| 568 | + expect(formatted.accounts[2].authType).toBe('apikey'); |
| 569 | + }); |
| 570 | + }); |
| 571 | + |
| 572 | + describe('parseConfig()', () => { |
| 573 | + it('parses valid YAML config', () => { |
| 574 | + const yamlConfig = ` |
| 575 | +accounts: |
| 576 | + - name: test-account |
| 577 | + accountId: 123 |
| 578 | + authType: personalaccesskey |
| 579 | + personalAccessKey: test-key |
| 580 | + env: qa |
| 581 | + auth: |
| 582 | + tokenInfo: {} |
| 583 | + accountType: STANDARD |
| 584 | +defaultAccount: 123 |
| 585 | +defaultCmsPublishMode: publish |
| 586 | +httpTimeout: 1000 |
| 587 | +httpUseLocalhost: true |
| 588 | +allowUsageTracking: true |
| 589 | +`; |
| 590 | + const parsed = parseConfig(yamlConfig, '/path/to/config.yml'); |
| 591 | + expect(parsed).toEqual(CONFIG); |
| 592 | + }); |
| 593 | + |
| 594 | + it('throws HubSpotConfigError on invalid YAML', () => { |
| 595 | + const invalidYaml = 'invalid: yaml: content: ['; |
| 596 | + expect(() => parseConfig(invalidYaml, '/path/to/config.yml')).toThrow( |
| 597 | + HubSpotConfigError |
| 598 | + ); |
| 599 | + }); |
| 600 | + |
| 601 | + it('normalizes deprecated config format', () => { |
| 602 | + const deprecatedYaml = ` |
| 603 | +portals: |
| 604 | + - name: test-account |
| 605 | + portalId: 123 |
| 606 | + authType: personalaccesskey |
| 607 | + personalAccessKey: test-key |
| 608 | + env: qa |
| 609 | + auth: |
| 610 | + tokenInfo: {} |
| 611 | +defaultPortal: test-account |
| 612 | +defaultMode: publish |
| 613 | +httpTimeout: 1000 |
| 614 | +httpUseLocalhost: true |
| 615 | +allowUsageTracking: true |
| 616 | +`; |
| 617 | + const parsed = parseConfig(deprecatedYaml, '/path/to/config.yml'); |
| 618 | + expect(parsed).toEqual(CONFIG); |
| 619 | + }); |
| 620 | + }); |
| 621 | + |
| 622 | + describe('getConfigAccountByInferredIdentifier()', () => { |
| 623 | + it('finds account by numeric accountId', () => { |
| 624 | + const account = getConfigAccountByInferredIdentifier( |
| 625 | + CONFIG.accounts, |
| 626 | + 123 |
| 627 | + ); |
| 628 | + expect(account).toEqual(PAK_ACCOUNT); |
| 629 | + }); |
| 630 | + |
| 631 | + it('finds account by string accountId', () => { |
| 632 | + const account = getConfigAccountByInferredIdentifier( |
| 633 | + CONFIG.accounts, |
| 634 | + '123' |
| 635 | + ); |
| 636 | + expect(account).toEqual(PAK_ACCOUNT); |
| 637 | + }); |
| 638 | + |
| 639 | + it('finds account by name', () => { |
| 640 | + const account = getConfigAccountByInferredIdentifier( |
| 641 | + CONFIG.accounts, |
| 642 | + 'test-account' |
| 643 | + ); |
| 644 | + expect(account).toEqual(PAK_ACCOUNT); |
| 645 | + }); |
| 646 | + |
| 647 | + it('handles accounts with numeric names as fallback', () => { |
| 648 | + const accountWithNumericName = { |
| 649 | + ...PAK_ACCOUNT, |
| 650 | + name: '456', |
| 651 | + accountId: 789, |
| 652 | + }; |
| 653 | + const accounts = [accountWithNumericName]; |
| 654 | + const account = getConfigAccountByInferredIdentifier(accounts, '456'); |
| 655 | + expect(account).toEqual(accountWithNumericName); |
| 656 | + }); |
| 657 | + |
| 658 | + it('returns undefined when account not found', () => { |
| 659 | + const account = getConfigAccountByInferredIdentifier( |
| 660 | + CONFIG.accounts, |
| 661 | + 999 |
| 662 | + ); |
| 663 | + expect(account).toBeUndefined(); |
| 664 | + }); |
| 665 | + }); |
| 666 | + |
| 667 | + describe('handleConfigFileSystemError()', () => { |
| 668 | + it('handles ENOENT error', () => { |
| 669 | + const error = Object.assign(new Error('File not found'), { |
| 670 | + code: 'ENOENT', |
| 671 | + }); |
| 672 | + const { message, type } = handleConfigFileSystemError( |
| 673 | + error, |
| 674 | + '/path/to/config.yml' |
| 675 | + ); |
| 676 | + expect(message).toContain('No config file found'); |
| 677 | + expect(type).toBe('CONFIG_NOT_FOUND'); |
| 678 | + }); |
| 679 | + |
| 680 | + it('handles EACCES error', () => { |
| 681 | + const error = Object.assign(new Error('Permission denied'), { |
| 682 | + code: 'EACCES', |
| 683 | + }); |
| 684 | + const { message, type } = handleConfigFileSystemError( |
| 685 | + error, |
| 686 | + '/path/to/config.yml' |
| 687 | + ); |
| 688 | + expect(message).toContain('Insufficient permissions'); |
| 689 | + expect(type).toBe('INSUFFICIENT_PERMISSIONS'); |
| 690 | + }); |
| 691 | + |
| 692 | + it('handles unknown errors', () => { |
| 693 | + const error = new Error('Unknown error'); |
| 694 | + const { message, type } = handleConfigFileSystemError( |
| 695 | + error, |
| 696 | + '/path/to/config.yml' |
| 697 | + ); |
| 698 | + expect(message).toBeUndefined(); |
| 699 | + expect(type).toBe('UNKNOWN'); |
| 700 | + }); |
| 701 | + }); |
388 | 702 | }); |
0 commit comments