From f66c64f5e921c4e2074553cd9ca076a6f3a8606a Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:07:59 -0400 Subject: [PATCH 1/8] test: add failing tests for free API key auto-detection Add tests for isFreeApiKey() helper and URL auto-detection from API key suffix (:fx = free, otherwise = pro). These tests currently fail because the feature does not exist yet (TDD red phase). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../deepl-client.integration.test.ts | 40 +++++++++++++ tests/unit/http-client.test.ts | 57 ++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index e602658..3db50f0 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -69,6 +69,46 @@ describe('DeepLClient Integration', () => { await client.getUsage(); expect(scope.isDone()).toBe(true); }); + + it('should auto-detect free API URL from :fx key suffix', async () => { + const freeKey = 'my-free-api-key:fx'; + const client = new DeepLClient(freeKey); + clients.push(client); + + const scope = nock(FREE_API_URL) + .get('/v2/usage') + .reply(200, { character_count: 0, character_limit: 500000 }); + + await client.getUsage(); + expect(scope.isDone()).toBe(true); + }); + + it('should auto-detect pro API URL for keys without :fx suffix', async () => { + const proKey = 'my-pro-api-key'; + const client = new DeepLClient(proKey); + clients.push(client); + + const scope = nock(PRO_API_URL) + .get('/v2/usage') + .reply(200, { character_count: 0, character_limit: 500000 }); + + await client.getUsage(); + expect(scope.isDone()).toBe(true); + }); + + it('should use explicit baseUrl even for :fx keys', async () => { + const freeKey = 'my-free-api-key:fx'; + const customUrl = 'https://custom-deepl.example.com'; + const client = new DeepLClient(freeKey, { baseUrl: customUrl }); + clients.push(client); + + const scope = nock(customUrl) + .get('/v2/usage') + .reply(200, { character_count: 0, character_limit: 500000 }); + + await client.getUsage(); + expect(scope.isDone()).toBe(true); + }); }); describe('translate()', () => { diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 8eef3d5..69cb82d 100644 --- a/tests/unit/http-client.test.ts +++ b/tests/unit/http-client.test.ts @@ -1,5 +1,5 @@ import nock from 'nock'; -import { HttpClient } from '../../src/api/http-client'; +import { HttpClient, isFreeApiKey } from '../../src/api/http-client'; import { NetworkError } from '../../src/utils/errors'; class TestHttpClient extends HttpClient { @@ -12,6 +12,28 @@ class TestHttpClient extends HttpClient { } } +describe('isFreeApiKey', () => { + it('should return true for keys ending with :fx', () => { + expect(isFreeApiKey('a1b2c3d4-e5f6-7890-abcd-ef1234567890:fx')).toBe(true); + }); + + it('should return true for short keys ending with :fx', () => { + expect(isFreeApiKey('test-key:fx')).toBe(true); + }); + + it('should return false for pro keys without :fx suffix', () => { + expect(isFreeApiKey('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(false); + }); + + it('should return false for keys with :fx in the middle', () => { + expect(isFreeApiKey('key:fx:extra')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isFreeApiKey('')).toBe(false); + }); +}); + describe('HttpClient', () => { const apiKey = 'test-api-key'; const baseUrl = 'https://api-free.deepl.com'; @@ -278,4 +300,37 @@ describe('HttpClient', () => { } }); }); + + describe('API URL auto-detection from key suffix', () => { + it('should use free API URL for keys ending with :fx', async () => { + const freeClient = new TestHttpClient('test-key:fx'); + const scope = nock('https://api-free.deepl.com') + .get('/v2/test') + .reply(200, { result: 'ok' }); + + await freeClient.get('/v2/test'); + expect(scope.isDone()).toBe(true); + }); + + it('should use pro API URL for keys without :fx suffix', async () => { + const proClient = new TestHttpClient('test-pro-key'); + const scope = nock('https://api.deepl.com') + .get('/v2/test') + .reply(200, { result: 'ok' }); + + await proClient.get('/v2/test'); + expect(scope.isDone()).toBe(true); + }); + + it('should allow explicit baseUrl to override auto-detection', async () => { + const customUrl = 'https://custom.example.com'; + const customClient = new TestHttpClient('test-key:fx', { baseUrl: customUrl }); + const scope = nock(customUrl) + .get('/v2/test') + .reply(200, { result: 'ok' }); + + await customClient.get('/v2/test'); + expect(scope.isDone()).toBe(true); + }); + }); }); From c14668b59ec5b5f2b61f85233441cca436d96eae Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:58:25 -0400 Subject: [PATCH 2/8] fix: auto-detect free API endpoint from :fx key suffix Free API keys (ending with :fx) require https://api-free.deepl.com but the CLI defaulted to the pro endpoint, causing 403 errors. Add isFreeApiKey() helper and use it in HttpClient to auto-detect the correct endpoint from the key suffix, matching the behavior of the official DeepL SDKs (e.g. deepl-python). Config defaults for api.baseUrl and api.usePro are now undefined, allowing auto-detection. Explicit baseUrl still overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/http-client.ts | 7 ++++++- src/storage/config.ts | 4 ++-- src/types/config.ts | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/api/http-client.ts b/src/api/http-client.ts index 9cdbff6..0449fb8 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -42,6 +42,10 @@ export function sanitizeUrl(url: string): string { const FREE_API_URL = 'https://api-free.deepl.com'; const PRO_API_URL = 'https://api.deepl.com'; + +export function isFreeApiKey(apiKey: string): boolean { + return apiKey.endsWith(':fx'); +} const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_RETRIES = 3; const MAX_SOCKETS = 10; @@ -99,7 +103,8 @@ export class HttpClient { throw new AuthError('API key is required'); } - const baseURL = options.baseUrl ?? (options.usePro ? PRO_API_URL : FREE_API_URL); + const autoUrl = isFreeApiKey(apiKey) ? FREE_API_URL : PRO_API_URL; + const baseURL = options.baseUrl ?? autoUrl; this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; diff --git a/src/storage/config.ts b/src/storage/config.ts index 2409a7b..f55ffb5 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -169,8 +169,8 @@ export class ConfigService { apiKey: undefined, }, api: { - baseUrl: 'https://api.deepl.com', - usePro: true, + baseUrl: undefined, + usePro: undefined, }, defaults: { sourceLang: undefined, diff --git a/src/types/config.ts b/src/types/config.ts index c8c9e0d..349517d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -9,8 +9,8 @@ export interface DeepLConfig { apiKey?: string; }; api: { - baseUrl: string; - usePro: boolean; + baseUrl?: string; + usePro?: boolean; }; defaults: { sourceLang?: Language; From 7c1b818a7eac646f5ec2e8ed7af573e61d7ff603 Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:58:35 -0400 Subject: [PATCH 3/8] test: update tests for API key auto-detection - Add isFreeApiKey() unit tests - Add URL auto-detection tests (unit + integration) - Update existing tests to use :fx keys when mocking free API URL - Update tests that used usePro:true with free keys to use pro keys Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli-style-rules.integration.test.ts | 4 +- .../deepl-client.integration.test.ts | 40 ++++--------------- tests/unit/deepl-client.test.ts | 10 ++--- tests/unit/http-client.test.ts | 2 +- 4 files changed, 16 insertions(+), 40 deletions(-) diff --git a/tests/integration/cli-style-rules.integration.test.ts b/tests/integration/cli-style-rules.integration.test.ts index 966c354..d00bcdd 100644 --- a/tests/integration/cli-style-rules.integration.test.ts +++ b/tests/integration/cli-style-rules.integration.test.ts @@ -499,8 +499,8 @@ describe('Style Rules API Integration', () => { expect(receivedBody).toBe(''); }); - it('should use Pro API URL when configured', async () => { - const proClient = new DeepLClient(API_KEY, { usePro: true }); + it('should use Pro API URL for pro keys', async () => { + const proClient = new DeepLClient('test-pro-api-key'); const proCommand = new StyleRulesCommand(new StyleRulesService(proClient)); const scope = nock('https://api.deepl.com') diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index 3db50f0..2af04cc 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -33,8 +33,8 @@ describe('DeepLClient Integration', () => { expect(() => new DeepLClient('')).toThrow('API key is required'); }); - it('should use free API URL by default', async () => { - const client = new DeepLClient(API_KEY); + it('should auto-detect free API URL from :fx key suffix', async () => { + const client = new DeepLClient(API_KEY); // API_KEY ends with :fx clients.push(client); const scope = nock(FREE_API_URL) @@ -45,8 +45,9 @@ describe('DeepLClient Integration', () => { expect(scope.isDone()).toBe(true); }); - it('should use pro API URL when usePro is true', async () => { - const client = new DeepLClient(API_KEY, { usePro: true }); + it('should auto-detect pro API URL for keys without :fx suffix', async () => { + const proKey = 'test-pro-api-key'; + const client = new DeepLClient(proKey); clients.push(client); const scope = nock(PRO_API_URL) @@ -70,32 +71,6 @@ describe('DeepLClient Integration', () => { expect(scope.isDone()).toBe(true); }); - it('should auto-detect free API URL from :fx key suffix', async () => { - const freeKey = 'my-free-api-key:fx'; - const client = new DeepLClient(freeKey); - clients.push(client); - - const scope = nock(FREE_API_URL) - .get('/v2/usage') - .reply(200, { character_count: 0, character_limit: 500000 }); - - await client.getUsage(); - expect(scope.isDone()).toBe(true); - }); - - it('should auto-detect pro API URL for keys without :fx suffix', async () => { - const proKey = 'my-pro-api-key'; - const client = new DeepLClient(proKey); - clients.push(client); - - const scope = nock(PRO_API_URL) - .get('/v2/usage') - .reply(200, { character_count: 0, character_limit: 500000 }); - - await client.getUsage(); - expect(scope.isDone()).toBe(true); - }); - it('should use explicit baseUrl even for :fx keys', async () => { const freeKey = 'my-free-api-key:fx'; const customUrl = 'https://custom-deepl.example.com'; @@ -679,8 +654,9 @@ describe('DeepLClient Integration', () => { expect(scope.isDone()).toBe(true); }); - it('should use pro API URL when usePro is true', async () => { - const client = new DeepLClient(API_KEY, { usePro: true }); + it('should auto-detect pro API URL for pro key', async () => { + const proKey = 'test-pro-api-key'; + const client = new DeepLClient(proKey); clients.push(client); const scope = nock(PRO_API_URL) diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 79af176..a2b4908 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -9,7 +9,7 @@ import { HttpClient, USER_AGENT } from '../../src/api/http-client'; describe('DeepLClient', () => { let client: DeepLClient; - const apiKey = 'test-api-key'; + const apiKey = 'test-api-key:fx'; const baseUrl = 'https://api-free.deepl.com'; beforeEach(() => { @@ -32,8 +32,8 @@ describe('DeepLClient', () => { expect(client).toBeInstanceOf(DeepLClient); }); - it('should use free API endpoint by default', async () => { - const freeClient = new DeepLClient('test-key'); + it('should auto-detect free API endpoint for :fx keys', async () => { + const freeClient = new DeepLClient('test-key:fx'); nock('https://api-free.deepl.com') .post('/v2/translate') .reply(200, { translations: [{ text: 'Hola' }] }); @@ -41,8 +41,8 @@ describe('DeepLClient', () => { expect(result.text).toBe('Hola'); }); - it('should use pro API endpoint when specified', async () => { - const proClient = new DeepLClient('test-key', { usePro: true }); + it('should auto-detect pro API endpoint for non-:fx keys', async () => { + const proClient = new DeepLClient('test-pro-key'); nock('https://api.deepl.com') .post('/v2/translate') .reply(200, { translations: [{ text: 'Hola' }] }); diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 69cb82d..62967dd 100644 --- a/tests/unit/http-client.test.ts +++ b/tests/unit/http-client.test.ts @@ -35,7 +35,7 @@ describe('isFreeApiKey', () => { }); describe('HttpClient', () => { - const apiKey = 'test-api-key'; + const apiKey = 'test-api-key:fx'; const baseUrl = 'https://api-free.deepl.com'; let client: TestHttpClient; let sleepSpy: jest.SpyInstance; From e46f3a4552fa27b3ce8676861c870a7044ada68d Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:58:47 -0400 Subject: [PATCH 4/8] docs: add free API key auto-detection fix to changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab47732..95168a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Free API keys (ending with `:fx`) now auto-detect the correct endpoint (`api-free.deepl.com`), matching the behavior of official DeepL SDKs + ### Security - Updated `minimatch` from `^9.0.5` to `^10.2.1` to fix ReDoS vulnerability (GHSA-3ppc-4f35-3m26) From c77cfa4ca4ee83c9dd2a5fb5b6c4471af3116eb1 Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:39:00 -0400 Subject: [PATCH 5/8] fix: don't let persisted config override key-based auto-detection init, auth set-key, and createDeepLClient were passing the config's api.baseUrl to HttpClient, which overrode auto-detection for users with old config files that had the pro URL hardcoded. - init/auth: validate new keys with auto-detected endpoint only - createDeepLClient: stop passing usePro (redundant with auto-detect) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/auth.ts | 7 ++----- src/cli/commands/init.ts | 4 +--- src/cli/index.ts | 6 ++---- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 0b5bfec..c5123e4 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -27,11 +27,8 @@ export class AuthCommand { // Note: No format validation - let the API determine if the key is valid // This supports production keys (:fx suffix), free keys, and test keys try { - // Use configured API endpoint for validation - const baseUrl = this.config.getValue('api.baseUrl'); - const usePro = this.config.getValue('api.usePro'); - - const client = new DeepLClient(apiKey, { baseUrl, usePro }); + // Let the client auto-detect the endpoint from the key suffix + const client = new DeepLClient(apiKey); await client.getUsage(); // Test API key validity } catch (error) { if (error instanceof Error) { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6d307da..462b313 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -39,9 +39,7 @@ export class InitCommand { Logger.output('\nValidating API key...'); const { DeepLClient } = await import('../../api/deepl-client.js'); - const baseUrl = this.config.getValue('api.baseUrl'); - const usePro = this.config.getValue('api.usePro'); - const client = new DeepLClient(apiKey.trim(), { baseUrl, usePro }); + const client = new DeepLClient(apiKey.trim()); await client.getUsage(); this.config.set('auth.apiKey', apiKey.trim()); diff --git a/src/cli/index.ts b/src/cli/index.ts index c55fe4b..fdf5ae0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -100,7 +100,6 @@ async function createDeepLClient(overrideBaseUrl?: string): Promise } const baseUrl = overrideBaseUrl ?? configService.getValue('api.baseUrl'); - const usePro = configService.getValue('api.usePro'); if (baseUrl) { const { validateApiUrl } = await import('../utils/validate-url.js'); @@ -108,7 +107,7 @@ async function createDeepLClient(overrideBaseUrl?: string): Promise } const { DeepLClient: Client } = await import('../api/deepl-client.js'); - return new Client(key, { baseUrl, usePro }); + return new Client(key, { baseUrl }); } // Create program @@ -198,9 +197,8 @@ function getApiKeyAndOptions(): { apiKey: string; options: import('../api/http-c if (baseUrl) { validateApiUrl(baseUrl); } - const usePro = configService.getValue('api.usePro'); - return { apiKey: key, options: { baseUrl, usePro } }; + return { apiKey: key, options: { baseUrl } }; } // Shared dependencies passed to register functions From 0ce9596200c06f034d97d4b0830af0d5ccd76438 Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:45:43 -0400 Subject: [PATCH 6/8] refactor: remove usePro option entirely The API key suffix (:fx) is the source of truth for endpoint selection, and baseUrl serves as the explicit override. usePro is redundant and has been removed from types, config, and all tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/http-client.ts | 1 - src/cli/commands/config.ts | 1 - src/cli/commands/register-config.ts | 2 +- src/storage/config.ts | 2 -- src/types/config.ts | 1 - tests/e2e/cli-success-paths.e2e.test.ts | 2 +- tests/e2e/cli-watch.e2e.test.ts | 2 +- .../batch-translation.integration.test.ts | 2 +- .../integration/cli-config-file.integration.test.ts | 12 ++++++------ tests/integration/cli-config.integration.test.ts | 1 - ...cli-structured-file-translate.integration.test.ts | 2 +- tests/integration/cli-translate.integration.test.ts | 2 +- tests/integration/cli-voice.integration.test.ts | 2 +- tests/unit/cli-translate-workflow.test.ts | 4 ++-- tests/unit/cli.test.ts | 4 ++-- tests/unit/config-command.test.ts | 12 +++++------- tests/unit/document-client.test.ts | 8 ++++---- tests/unit/register-commands-group1.test.ts | 4 ++-- tests/unit/register-config.test.ts | 6 +++--- tests/unit/translation-service.test.ts | 6 +++--- 20 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/api/http-client.ts b/src/api/http-client.ts index 0449fb8..2bfade4 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -20,7 +20,6 @@ export interface ProxyConfig { } export interface DeepLClientOptions { - usePro?: boolean; timeout?: number; maxRetries?: number; baseUrl?: string; diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index df1ae69..4582a2c 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -6,7 +6,6 @@ import { ConfigService } from '../../storage/config.js'; const BOOLEAN_KEYS = [ - 'api.usePro', 'cache.enabled', 'output.verbose', 'output.color', diff --git a/src/cli/commands/register-config.ts b/src/cli/commands/register-config.ts index 447da4e..bc1bfd4 100644 --- a/src/cli/commands/register-config.ts +++ b/src/cli/commands/register-config.ts @@ -17,7 +17,7 @@ export function registerConfig( .description('Manage configuration') .addHelpText('after', ` Examples: - $ deepl config set api.usePro true + $ deepl config set api.baseUrl https://api.deepl.com $ deepl config get auth.apiKey $ deepl config list $ deepl config reset diff --git a/src/storage/config.ts b/src/storage/config.ts index f55ffb5..3a835c2 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -28,7 +28,6 @@ const VALID_OUTPUT_FORMATS: readonly OutputFormat[] = [ ] as const; const BOOLEAN_CONFIG_PATHS = [ - 'api.usePro', 'cache.enabled', 'output.verbose', 'output.color', @@ -170,7 +169,6 @@ export class ConfigService { }, api: { baseUrl: undefined, - usePro: undefined, }, defaults: { sourceLang: undefined, diff --git a/src/types/config.ts b/src/types/config.ts index 349517d..d25a96a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -10,7 +10,6 @@ export interface DeepLConfig { }; api: { baseUrl?: string; - usePro?: boolean; }; defaults: { sourceLang?: Language; diff --git a/tests/e2e/cli-success-paths.e2e.test.ts b/tests/e2e/cli-success-paths.e2e.test.ts index ef19209..3d05030 100644 --- a/tests/e2e/cli-success-paths.e2e.test.ts +++ b/tests/e2e/cli-success-paths.e2e.test.ts @@ -59,7 +59,7 @@ describe('CLI Success Paths E2E', () => { function writeConfig(configDir: string, apiUrl: string): void { const config = { auth: { apiKey: 'mock-api-key-for-testing:fx' }, - api: { baseUrl: apiUrl, usePro: false }, + api: { baseUrl: apiUrl }, defaults: { targetLangs: [], formality: 'default', diff --git a/tests/e2e/cli-watch.e2e.test.ts b/tests/e2e/cli-watch.e2e.test.ts index 85cb71d..52460b9 100644 --- a/tests/e2e/cli-watch.e2e.test.ts +++ b/tests/e2e/cli-watch.e2e.test.ts @@ -110,7 +110,7 @@ describe('Watch Command E2E', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-e2e-')); const configWithDefaults = { auth: { apiKey: 'test-key:fx' }, - api: { baseUrl: 'https://api-free.deepl.com', usePro: false }, + api: { baseUrl: 'https://api-free.deepl.com' }, defaults: { targetLangs: ['es', 'fr'], formality: 'default', preserveFormatting: true }, cache: { enabled: false, maxSize: 1048576, ttl: 2592000 }, output: { format: 'text', verbose: false, color: false }, diff --git a/tests/integration/batch-translation.integration.test.ts b/tests/integration/batch-translation.integration.test.ts index d524e4e..0d202f4 100644 --- a/tests/integration/batch-translation.integration.test.ts +++ b/tests/integration/batch-translation.integration.test.ts @@ -46,7 +46,7 @@ describe('Batch Translation Service Integration', () => { mockConfig = createMockConfigService({ get: jest.fn(() => ({ auth: {}, - api: { baseUrl: '', usePro: false }, + api: { baseUrl: '' }, defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, cache: { enabled: true }, output: { format: 'text', color: true }, diff --git a/tests/integration/cli-config-file.integration.test.ts b/tests/integration/cli-config-file.integration.test.ts index 4f6cf7b..fe61bfc 100644 --- a/tests/integration/cli-config-file.integration.test.ts +++ b/tests/integration/cli-config-file.integration.test.ts @@ -38,7 +38,7 @@ describe('CLI --config flag integration', () => { // Set up default config with a test API key const defaultConfig = { auth: { apiKey: 'default-test-key' }, - api: { baseUrl: 'https://api.deepl.com', usePro: true }, + api: { baseUrl: 'https://api.deepl.com' }, defaults: { sourceLang: undefined, targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 }, output: { format: 'text', verbose: false, color: true }, @@ -50,7 +50,7 @@ describe('CLI --config flag integration', () => { // Set up custom config with different API key const customConfig = { auth: { apiKey: 'custom-test-key' }, - api: { baseUrl: 'https://api-free.deepl.com', usePro: false }, + api: { baseUrl: 'https://api-free.deepl.com' }, defaults: { sourceLang: 'en', targetLangs: ['es', 'fr'], formality: 'more', preserveFormatting: false }, cache: { enabled: false, maxSize: 104857600, ttl: 86400 }, output: { format: 'json', verbose: true, color: false }, @@ -79,7 +79,7 @@ describe('CLI --config flag integration', () => { }); it('should read nested config values from custom config', () => { - const output = runCLI(`deepl --config "${customConfigPath}" config get api.usePro`); + const output = runCLI(`deepl --config "${customConfigPath}" config get cache.enabled`); expect(output.trim()).toBe('false'); }); @@ -116,7 +116,7 @@ describe('CLI --config flag integration', () => { const config = JSON.parse(output.trim()); // API keys are masked in list output expect(config.auth.apiKey).toBe('defa...-key'); - expect(config.api.usePro).toBe(true); + expect(config.api.baseUrl).toBe('https://api.deepl.com'); }); it('should list custom config when --config is specified', () => { @@ -124,7 +124,7 @@ describe('CLI --config flag integration', () => { const config = JSON.parse(output.trim()); // API keys are masked in list output expect(config.auth.apiKey).toBe('cust...-key'); - expect(config.api.usePro).toBe(false); + expect(config.api.baseUrl).toBe('https://api-free.deepl.com'); expect(config.defaults.formality).toBe('more'); }); }); @@ -223,7 +223,7 @@ describe('CLI --config flag integration', () => { const upperCasePath = path.join(testDir, 'config.JSON'); fs.writeFileSync(upperCasePath, JSON.stringify({ auth: { apiKey: 'upper-case-key' }, - api: { baseUrl: 'https://api.deepl.com', usePro: true }, + api: { baseUrl: 'https://api.deepl.com' }, defaults: { targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 }, output: { format: 'text', verbose: false, color: true }, diff --git a/tests/integration/cli-config.integration.test.ts b/tests/integration/cli-config.integration.test.ts index 1c4b6f3..7431084 100644 --- a/tests/integration/cli-config.integration.test.ts +++ b/tests/integration/cli-config.integration.test.ts @@ -24,7 +24,6 @@ describe('Config CLI Integration', () => { auth: { apiKey: undefined }, api: { baseUrl: 'https://api-free.deepl.com/v2', - usePro: false, }, defaults: { sourceLang: undefined, diff --git a/tests/integration/cli-structured-file-translate.integration.test.ts b/tests/integration/cli-structured-file-translate.integration.test.ts index b5e06ee..5cf3718 100644 --- a/tests/integration/cli-structured-file-translate.integration.test.ts +++ b/tests/integration/cli-structured-file-translate.integration.test.ts @@ -24,7 +24,7 @@ describe('Structured File Translation CLI Integration', () => { path.join(testConfig.path, 'config.json'), JSON.stringify({ auth: { apiKey: 'test-api-key-123' }, - api: { baseUrl: 'https://api-free.deepl.com/v2', usePro: false }, + api: { baseUrl: 'https://api-free.deepl.com/v2' }, defaults: { sourceLang: undefined, targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: false, maxSize: 1073741824, ttl: 2592000 }, }) diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index bbdb799..534d227 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -116,7 +116,7 @@ describe('Translate CLI Integration', () => { const configPath = path.join(testConfigDir, 'config.json'); const config = { auth: {}, - api: { baseUrl: 'https://api.deepl.com', usePro: true }, + api: { baseUrl: 'https://api.deepl.com' }, defaults: { targetLangs: ['es'], formality: 'default', diff --git a/tests/integration/cli-voice.integration.test.ts b/tests/integration/cli-voice.integration.test.ts index d3b8f80..ba2512e 100644 --- a/tests/integration/cli-voice.integration.test.ts +++ b/tests/integration/cli-voice.integration.test.ts @@ -123,7 +123,7 @@ describe('Voice CLI Integration', () => { const configPath = path.join(testConfig.path, 'config.json'); fs.writeFileSync(configPath, JSON.stringify({ auth: { apiKey: 'test-key-for-url-validation' }, - api: { baseUrl: 'http://evil-server.example.com/v2', usePro: false }, + api: { baseUrl: 'http://evil-server.example.com/v2' }, })); expect.assertions(1); diff --git a/tests/unit/cli-translate-workflow.test.ts b/tests/unit/cli-translate-workflow.test.ts index 2c4f767..67f0f89 100644 --- a/tests/unit/cli-translate-workflow.test.ts +++ b/tests/unit/cli-translate-workflow.test.ts @@ -32,7 +32,7 @@ describe('Translation Workflow Integration', () => { mockConfig = createMockConfigService({ get: jest.fn(() => ({ auth: {}, - api: { baseUrl: '', usePro: false }, + api: { baseUrl: '' }, defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, cache: { enabled: true }, output: { format: 'text', color: true }, @@ -87,7 +87,7 @@ describe('Translation Workflow Integration', () => { mockConfig = createMockConfigService({ get: jest.fn(() => ({ auth: {}, - api: { baseUrl: '', usePro: false }, + api: { baseUrl: '' }, defaults: { targetLangs: [], sourceLang: 'en', diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index f5982fc..7b3c4b2 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -78,7 +78,7 @@ describe('CLI Entry Point', () => { const configPath = path.join(testConfigDir, 'config.json'); fs.writeFileSync(configPath, JSON.stringify({ auth: {}, - api: { baseUrl: 'https://api.deepl.com', usePro: true }, + api: { baseUrl: 'https://api.deepl.com' }, defaults: { targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 }, output: { format: 'text', verbose: false, color: false }, @@ -103,7 +103,7 @@ describe('CLI Entry Point', () => { const configPath = path.join(testConfigDir, 'config.json'); fs.writeFileSync(configPath, JSON.stringify({ auth: {}, - api: { baseUrl: 'https://api.deepl.com', usePro: true }, + api: { baseUrl: 'https://api.deepl.com' }, defaults: { targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 }, output: { format: 'text', verbose: false, color: true }, diff --git a/tests/unit/config-command.test.ts b/tests/unit/config-command.test.ts index 199f215..00df724 100644 --- a/tests/unit/config-command.test.ts +++ b/tests/unit/config-command.test.ts @@ -43,7 +43,7 @@ describe('ConfigCommand', () => { it('should get entire config when no key specified', async () => { (mockConfigService.get as jest.Mock).mockReturnValueOnce({ auth: { apiKey: 'test-key' }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, defaults: { sourceLang: undefined, targetLangs: ['es', 'fr'], @@ -66,7 +66,7 @@ describe('ConfigCommand', () => { it('should mask API key when returning entire config', async () => { (mockConfigService.get as jest.Mock).mockReturnValueOnce({ auth: { apiKey: 'super-secret-key-123' }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, defaults: { sourceLang: undefined, targetLangs: [], formality: 'default', preserveFormatting: true }, cache: { enabled: true, maxSize: 1024, ttl: 2592000 }, output: { format: 'text', color: true, verbose: false }, @@ -108,7 +108,6 @@ describe('ConfigCommand', () => { it('should coerce "false" to boolean false for all known boolean keys', async () => { const booleanKeys = [ - 'api.usePro', 'cache.enabled', 'output.verbose', 'output.color', @@ -125,7 +124,6 @@ describe('ConfigCommand', () => { it('should coerce "true" to boolean true for all known boolean keys', async () => { const booleanKeys = [ - 'api.usePro', 'cache.enabled', 'output.verbose', 'output.color', @@ -197,7 +195,7 @@ describe('ConfigCommand', () => { it('should list all config values', async () => { (mockConfigService.get as jest.Mock).mockReturnValueOnce({ auth: { apiKey: 'test-key' }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, defaults: { sourceLang: 'en', targetLangs: ['es', 'fr'], @@ -220,7 +218,7 @@ describe('ConfigCommand', () => { it('should format config as readable key-value pairs', async () => { (mockConfigService.get as jest.Mock).mockReturnValueOnce({ auth: { apiKey: 'test-key' }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, defaults: { sourceLang: 'en', targetLangs: ['es', 'fr'], @@ -241,7 +239,7 @@ describe('ConfigCommand', () => { it('should hide sensitive values like API keys', async () => { (mockConfigService.get as jest.Mock).mockReturnValueOnce({ auth: { apiKey: 'super-secret-key-123' }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, defaults: { sourceLang: undefined, targetLangs: [], diff --git a/tests/unit/document-client.test.ts b/tests/unit/document-client.test.ts index d39d17b..45f9f33 100644 --- a/tests/unit/document-client.test.ts +++ b/tests/unit/document-client.test.ts @@ -41,16 +41,16 @@ describe('DocumentClient', () => { expect(() => new DocumentClient('')).toThrow('API key is required'); }); - it('should use Free API URL by default', () => { + it('should auto-detect Pro API URL for non-:fx keys', () => { expect(mockedAxios.create).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: 'https://api-free.deepl.com', + baseURL: 'https://api.deepl.com', }), ); }); - it('should use Pro API URL when usePro is true', () => { - new DocumentClient('test-key', { usePro: true }); + it('should use Pro API URL when baseUrl is set to pro', () => { + new DocumentClient('test-key', { baseUrl: 'https://api.deepl.com' }); expect(mockedAxios.create).toHaveBeenCalledWith( expect.objectContaining({ baseURL: 'https://api.deepl.com', diff --git a/tests/unit/register-commands-group1.test.ts b/tests/unit/register-commands-group1.test.ts index f9357fc..2304e3f 100644 --- a/tests/unit/register-commands-group1.test.ts +++ b/tests/unit/register-commands-group1.test.ts @@ -377,13 +377,13 @@ describe('service-factory', () => { }); it('createVoiceCommand should wire up VoiceClient, VoiceService, and VoiceCommand', async () => { - const getApiKeyAndOptions = jest.fn().mockReturnValue({ apiKey: 'test-key', options: { usePro: true } }); + const getApiKeyAndOptions = jest.fn().mockReturnValue({ apiKey: 'test-key', options: {} }); const { createVoiceCommand } = await import('../../src/cli/commands/service-factory'); const cmd = await createVoiceCommand(getApiKeyAndOptions); expect(getApiKeyAndOptions).toHaveBeenCalled(); const { VoiceClient } = require('../../src/api/voice-client'); - expect(VoiceClient).toHaveBeenCalledWith('test-key', { usePro: true }); + expect(VoiceClient).toHaveBeenCalledWith('test-key', {}); const { VoiceService } = require('../../src/services/voice'); expect(VoiceService).toHaveBeenCalledWith(mockVoiceClientObj); const { VoiceCommand } = require('../../src/cli/commands/voice'); diff --git a/tests/unit/register-config.test.ts b/tests/unit/register-config.test.ts index 5968629..62230c2 100644 --- a/tests/unit/register-config.test.ts +++ b/tests/unit/register-config.test.ts @@ -91,9 +91,9 @@ describe('registerConfig', () => { describe('config set', () => { it('should set a config value', async () => { mockConfigCommandInstance.set.mockResolvedValue(undefined); - await program.parseAsync(['node', 'test', 'config', 'set', 'api.usePro', 'true']); - expect(mockConfigCommandInstance.set).toHaveBeenCalledWith('api.usePro', 'true'); - expect(Logger.success).toHaveBeenCalledWith(expect.stringContaining('Set api.usePro = true')); + await program.parseAsync(['node', 'test', 'config', 'set', 'cache.enabled', 'true']); + expect(mockConfigCommandInstance.set).toHaveBeenCalledWith('cache.enabled', 'true'); + expect(Logger.success).toHaveBeenCalledWith(expect.stringContaining('Set cache.enabled = true')); }); it('should mask API key in success message', async () => { diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index e1d20ed..0058edc 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -42,7 +42,7 @@ describe('TranslationService', () => { maxSize: 1024 * 1024 * 1024, ttl: 30 * 24 * 60 * 60, }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, }), getValue: jest.fn().mockReturnValue(true), }); @@ -93,7 +93,7 @@ describe('TranslationService', () => { maxSize: 1024 * 1024 * 1024, ttl: 30 * 24 * 60 * 60, }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, auth: {}, output: { format: 'text', color: true, verbose: false }, watch: { debounceMs: 500, autoCommit: false, pattern: '**/*' }, @@ -125,7 +125,7 @@ describe('TranslationService', () => { preserveFormatting: true, }, cache: { enabled: true, maxSize: 1024 * 1024 * 1024, ttl: 30 * 24 * 60 * 60 }, - api: { baseUrl: 'https://api.deepl.com/v2', usePro: true }, + api: { baseUrl: 'https://api.deepl.com/v2' }, auth: {}, output: { format: 'text', color: true, verbose: false }, watch: { debounceMs: 500, autoCommit: false, pattern: '**/*' }, From a28294f97fc5cfdf61e79dfd95f58d04091fbbe4 Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:42:00 -0400 Subject: [PATCH 7/8] fix: stop reading api.baseUrl from config for client creation Persisted config files from older versions have api.baseUrl set to the pro endpoint, which overrides auto-detection for all commands. Only the --api-url CLI flag should override; otherwise let the key suffix determine the endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/index.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index fdf5ae0..336e60d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,8 +35,6 @@ import { registerCompletion } from './commands/register-completion.js'; import { registerVoice } from './commands/register-voice.js'; import { registerInit } from './commands/register-init.js'; import { registerDetect } from './commands/register-detect.js'; -import { validateApiUrl } from '../utils/validate-url.js'; - // Get __dirname equivalent in ESM const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -99,15 +97,13 @@ async function createDeepLClient(overrideBaseUrl?: string): Promise process.exit(ExitCode.AuthError); } - const baseUrl = overrideBaseUrl ?? configService.getValue('api.baseUrl'); - - if (baseUrl) { + if (overrideBaseUrl) { const { validateApiUrl } = await import('../utils/validate-url.js'); - validateApiUrl(baseUrl); + validateApiUrl(overrideBaseUrl); } const { DeepLClient: Client } = await import('../api/deepl-client.js'); - return new Client(key, { baseUrl }); + return new Client(key, { baseUrl: overrideBaseUrl }); } // Create program @@ -193,12 +189,7 @@ function getApiKeyAndOptions(): { apiKey: string; options: import('../api/http-c process.exit(ExitCode.AuthError); } - const baseUrl = configService.getValue('api.baseUrl'); - if (baseUrl) { - validateApiUrl(baseUrl); - } - - return { apiKey: key, options: { baseUrl } }; + return { apiKey: key, options: {} }; } // Shared dependencies passed to register functions From e47197c1c051e02a33c017ca3df74be025a7b680 Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:46:33 -0400 Subject: [PATCH 8/8] test: add auto-detection assertion and clean up stale baseUrl in tests - Verify auth setKey creates client without config baseUrl - Remove hardcoded api.baseUrl from translate test config object Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/cli-translate.integration.test.ts | 2 +- tests/unit/auth-command.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index 534d227..45c0df7 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -116,7 +116,7 @@ describe('Translate CLI Integration', () => { const configPath = path.join(testConfigDir, 'config.json'); const config = { auth: {}, - api: { baseUrl: 'https://api.deepl.com' }, + api: {}, defaults: { targetLangs: ['es'], formality: 'default', diff --git a/tests/unit/auth-command.test.ts b/tests/unit/auth-command.test.ts index 7abc9d6..179b569 100644 --- a/tests/unit/auth-command.test.ts +++ b/tests/unit/auth-command.test.ts @@ -63,6 +63,18 @@ describe('AuthCommand', () => { expect(mockGetUsage).toHaveBeenCalled(); }); + it('should create client without config baseUrl to allow auto-detection', async () => { + const mockGetUsage = jest.fn().mockResolvedValue({ character: { count: 0, limit: 500000 } }); + (DeepLClient as jest.MockedClass).mockImplementation(() => ({ + getUsage: mockGetUsage, + } as any)); + + await authCommand.setKey('test-key:fx'); + + // Client should be created with just the key, no baseUrl from config + expect(DeepLClient).toHaveBeenCalledWith('test-key:fx'); + }); + it('should throw error for empty API key', async () => { await expect(authCommand.setKey('')).rejects.toThrow('API key cannot be empty'); });