From ca9e6b335c0328ed4c60db52e85edb92019198cf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 20 May 2026 22:45:02 +0100 Subject: [PATCH 1/6] test(api-client): add regression guard for PUT /configs/:id body shape (FT-1941) --- src/api-client/api-client.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index 5d6d0fb..5656552 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -1188,7 +1188,30 @@ describe.skipIf(isLiveMode)('APIClient core', () => { expect(await client.getPlatformConfig(1)).toBeDefined(); server.use(http.put(`${BASE_URL}/configs/1`, () => HttpResponse.json({ config: { id: 1 } }))); - await client.updatePlatformConfig(1, { key: 'val' }); + await client.updatePlatformConfig(1, { name: 'cfg', value: 'val' }); + }); + + it('should PUT the full config object under data, not the bare value', async () => { + let sentBody: Record = {}; + server.use( + http.put(`${BASE_URL}/configs/22`, async ({ request }) => { + sentBody = (await request.json()) as Record; + return HttpResponse.json({ + ok: true, + config: { id: 22, name: 'experiment_form_max_secondary_metrics', value: '30' }, + }); + }) + ); + + await client.updatePlatformConfig(22, { + name: 'experiment_form_max_secondary_metrics', + value: '30', + }); + + // Wire body must be {data: {name, value}} — not {data: '30'} or {data: {data: ...}} + expect(sentBody).toEqual({ + data: { name: 'experiment_form_max_secondary_metrics', value: '30' }, + }); }); }); From ffbc3cc45485a4841802e4a11d90511d7f4a2f59 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 20 May 2026 22:49:02 +0100 Subject: [PATCH 2/6] fix(platform-config): fetch current config and merge --value before PUT (FT-1941) --- .../platformconfig/platformconfig.test.ts | 52 ++++++++++++++++--- src/core/platformconfig/platformconfig.ts | 12 ++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/core/platformconfig/platformconfig.test.ts b/src/core/platformconfig/platformconfig.test.ts index 307e1c6..58d6bb3 100644 --- a/src/core/platformconfig/platformconfig.test.ts +++ b/src/core/platformconfig/platformconfig.test.ts @@ -22,11 +22,51 @@ describe('platformconfig', () => { expect(result.data).toEqual({ id: 1, key: 'val' }); }); - it('should update platform config', async () => { - const value = { setting: true }; - mockClient.updatePlatformConfig.mockResolvedValue({ id: 1, setting: true }); - const result = await updatePlatformConfig(mockClient as any, { id: 1, value }); - expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, value); - expect(result.data).toEqual({ id: 1, setting: true }); + it('should fetch the current config, merge the new value, and PUT without id', async () => { + mockClient.getPlatformConfig.mockResolvedValue({ + id: 22, + name: 'experiment_form_max_secondary_metrics', + value: '20', + }); + mockClient.updatePlatformConfig.mockResolvedValue({ + id: 22, + name: 'experiment_form_max_secondary_metrics', + value: '30', + }); + + const result = await updatePlatformConfig(mockClient as any, { id: 22, value: '30' }); + + expect(mockClient.getPlatformConfig).toHaveBeenCalledWith(22); + // The merged object passed to the api-client must include name (from GET) and the new value, + // and must NOT include id (id is in the URL path). + expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(22, { + name: 'experiment_form_max_secondary_metrics', + value: '30', + }); + expect(result.data).toEqual({ + id: 22, + name: 'experiment_form_max_secondary_metrics', + value: '30', + }); + }); + + it('should accept any JSON value for value (string, number, object)', async () => { + mockClient.getPlatformConfig.mockResolvedValue({ id: 1, name: 'numeric_cfg', value: 0 }); + mockClient.updatePlatformConfig.mockResolvedValue({ id: 1, name: 'numeric_cfg', value: 42 }); + + await updatePlatformConfig(mockClient as any, { id: 1, value: 42 }); + + expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { + name: 'numeric_cfg', + value: 42, + }); + }); + + it('should throw a clear error if the current config cannot be fetched', async () => { + mockClient.getPlatformConfig.mockResolvedValue(null); + + await expect( + updatePlatformConfig(mockClient as any, { id: 999, value: 'x' }) + ).rejects.toThrow(/platform config 999/); }); }); diff --git a/src/core/platformconfig/platformconfig.ts b/src/core/platformconfig/platformconfig.ts index 224ed7c..d526175 100644 --- a/src/core/platformconfig/platformconfig.ts +++ b/src/core/platformconfig/platformconfig.ts @@ -20,13 +20,21 @@ export async function getPlatformConfig( export interface UpdatePlatformConfigParams { id: number; - value: Record; + value: unknown; } export async function updatePlatformConfig( client: APIClient, params: UpdatePlatformConfigParams ): Promise> { - const data = await client.updatePlatformConfig(params.id, params.value); + const current = await client.getPlatformConfig(params.id); + if (!current || typeof current !== 'object') { + throw new Error( + `Cannot update platform config ${params.id}: existing config not found` + ); + } + const { id: _ignored, ...rest } = current as Record; + const merged: Record = { ...rest, value: params.value }; + const data = await client.updatePlatformConfig(params.id, merged); return { data }; } From 43ffb917e12a85c4b6d3cfbfe34366cb1185f834 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 20 May 2026 22:53:57 +0100 Subject: [PATCH 3/6] refactor(platform-config): use _id discard convention in destructure (FT-1941) --- src/core/platformconfig/platformconfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/platformconfig/platformconfig.ts b/src/core/platformconfig/platformconfig.ts index d526175..1cd0ced 100644 --- a/src/core/platformconfig/platformconfig.ts +++ b/src/core/platformconfig/platformconfig.ts @@ -33,7 +33,7 @@ export async function updatePlatformConfig( `Cannot update platform config ${params.id}: existing config not found` ); } - const { id: _ignored, ...rest } = current as Record; + const { id: _id, ...rest } = current as Record; const merged: Record = { ...rest, value: params.value }; const data = await client.updatePlatformConfig(params.id, merged); return { data }; From 007f50698d28f7fe89b46b3d108288c692d90bae Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 20 May 2026 22:57:19 +0100 Subject: [PATCH 4/6] fix(platform-config): accept any JSON value in --value (FT-1941) --- src/commands/platformconfig/index.ts | 2 +- .../platformconfig/platformconfig.test.ts | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/commands/platformconfig/index.ts b/src/commands/platformconfig/index.ts index a2e01ec..ba670c1 100644 --- a/src/commands/platformconfig/index.ts +++ b/src/commands/platformconfig/index.ts @@ -46,7 +46,7 @@ const updateCommand = new Command('update') withErrorHandling(async (id: number, options) => { const globalOptions = getGlobalOptions(updateCommand); const client = await getAPIClientFromOptions(globalOptions); - const value = validateJSON(options.value, '--value') as Record; + const value = validateJSON(options.value, '--value'); const result = await updatePlatformConfig(client, { id, value }); console.log(chalk.green(`✓ Platform config ${id} updated`)); printFormatted(result.data, globalOptions); diff --git a/src/commands/platformconfig/platformconfig.test.ts b/src/commands/platformconfig/platformconfig.test.ts index 5a72271..f4aeb0b 100644 --- a/src/commands/platformconfig/platformconfig.test.ts +++ b/src/commands/platformconfig/platformconfig.test.ts @@ -23,9 +23,9 @@ describe('platform-config command', () => { let processExitSpy: ReturnType; const mockClient = { - listPlatformConfigs: vi.fn().mockResolvedValue([{ id: 1, value: 'test' }]), - getPlatformConfig: vi.fn().mockResolvedValue({ id: 1, value: 'test' }), - updatePlatformConfig: vi.fn().mockResolvedValue({ id: 1, value: 'new' }), + listPlatformConfigs: vi.fn().mockResolvedValue([{ id: 1, name: 'cfg', value: 'test' }]), + getPlatformConfig: vi.fn().mockResolvedValue({ id: 1, name: 'cfg', value: 'test' }), + updatePlatformConfig: vi.fn().mockResolvedValue({ id: 1, name: 'cfg', value: 'new' }), }; beforeEach(() => { @@ -61,7 +61,24 @@ describe('platform-config command', () => { expect(printFormatted).toHaveBeenCalled(); }); - it('should update platform config', async () => { + it('should fetch the current config and PUT the merged payload (string value)', async () => { + await platformConfigCommand.parseAsync([ + 'node', + 'test', + 'update', + '1', + '--value', + '"30"', + ]); + + expect(mockClient.getPlatformConfig).toHaveBeenCalledWith(1); + expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { + name: 'cfg', + value: '30', + }); + }); + + it('should accept an object as --value', async () => { await platformConfigCommand.parseAsync([ 'node', 'test', @@ -71,6 +88,9 @@ describe('platform-config command', () => { '{"key":"val"}', ]); - expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { key: 'val' }); + expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { + name: 'cfg', + value: { key: 'val' }, + }); }); }); From 937a503f35d66cf24c2c0a98b526d688389d26a5 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 20 May 2026 23:03:29 +0100 Subject: [PATCH 5/6] test(platform-config): document id drop and cover numeric CLI value (FT-1941) Polish from final review of FT-1941: a one-line comment on the destructure explaining why id is dropped (it lives in the URL path), and an end-to-end CLI test that exercises a numeric --value to lock in the cast removal. --- .../platformconfig/platformconfig.test.ts | 16 ++++++++++++++++ src/core/platformconfig/platformconfig.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/src/commands/platformconfig/platformconfig.test.ts b/src/commands/platformconfig/platformconfig.test.ts index f4aeb0b..2f06421 100644 --- a/src/commands/platformconfig/platformconfig.test.ts +++ b/src/commands/platformconfig/platformconfig.test.ts @@ -78,6 +78,22 @@ describe('platform-config command', () => { }); }); + it('should accept a numeric value via --value', async () => { + await platformConfigCommand.parseAsync([ + 'node', + 'test', + 'update', + '1', + '--value', + '42', + ]); + + expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { + name: 'cfg', + value: 42, + }); + }); + it('should accept an object as --value', async () => { await platformConfigCommand.parseAsync([ 'node', diff --git a/src/core/platformconfig/platformconfig.ts b/src/core/platformconfig/platformconfig.ts index 1cd0ced..1bffba1 100644 --- a/src/core/platformconfig/platformconfig.ts +++ b/src/core/platformconfig/platformconfig.ts @@ -33,6 +33,7 @@ export async function updatePlatformConfig( `Cannot update platform config ${params.id}: existing config not found` ); } + // id is in the URL path; don't echo it in the body const { id: _id, ...rest } = current as Record; const merged: Record = { ...rest, value: params.value }; const data = await client.updatePlatformConfig(params.id, merged); From 038e41040c0809b0b9ac40644b67c6f96719c092 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 09:11:04 +0100 Subject: [PATCH 6/6] chore(platform-config): apply prettier formatting (FT-1941) --- .../platformconfig/platformconfig.test.ts | 18 ++---------------- src/core/platformconfig/platformconfig.test.ts | 6 +++--- src/core/platformconfig/platformconfig.ts | 4 +--- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/src/commands/platformconfig/platformconfig.test.ts b/src/commands/platformconfig/platformconfig.test.ts index 2f06421..2a0b5b1 100644 --- a/src/commands/platformconfig/platformconfig.test.ts +++ b/src/commands/platformconfig/platformconfig.test.ts @@ -62,14 +62,7 @@ describe('platform-config command', () => { }); it('should fetch the current config and PUT the merged payload (string value)', async () => { - await platformConfigCommand.parseAsync([ - 'node', - 'test', - 'update', - '1', - '--value', - '"30"', - ]); + await platformConfigCommand.parseAsync(['node', 'test', 'update', '1', '--value', '"30"']); expect(mockClient.getPlatformConfig).toHaveBeenCalledWith(1); expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { @@ -79,14 +72,7 @@ describe('platform-config command', () => { }); it('should accept a numeric value via --value', async () => { - await platformConfigCommand.parseAsync([ - 'node', - 'test', - 'update', - '1', - '--value', - '42', - ]); + await platformConfigCommand.parseAsync(['node', 'test', 'update', '1', '--value', '42']); expect(mockClient.updatePlatformConfig).toHaveBeenCalledWith(1, { name: 'cfg', diff --git a/src/core/platformconfig/platformconfig.test.ts b/src/core/platformconfig/platformconfig.test.ts index 58d6bb3..3441584 100644 --- a/src/core/platformconfig/platformconfig.test.ts +++ b/src/core/platformconfig/platformconfig.test.ts @@ -65,8 +65,8 @@ describe('platformconfig', () => { it('should throw a clear error if the current config cannot be fetched', async () => { mockClient.getPlatformConfig.mockResolvedValue(null); - await expect( - updatePlatformConfig(mockClient as any, { id: 999, value: 'x' }) - ).rejects.toThrow(/platform config 999/); + await expect(updatePlatformConfig(mockClient as any, { id: 999, value: 'x' })).rejects.toThrow( + /platform config 999/ + ); }); }); diff --git a/src/core/platformconfig/platformconfig.ts b/src/core/platformconfig/platformconfig.ts index 1bffba1..f21c4a7 100644 --- a/src/core/platformconfig/platformconfig.ts +++ b/src/core/platformconfig/platformconfig.ts @@ -29,9 +29,7 @@ export async function updatePlatformConfig( ): Promise> { const current = await client.getPlatformConfig(params.id); if (!current || typeof current !== 'object') { - throw new Error( - `Cannot update platform config ${params.id}: existing config not found` - ); + throw new Error(`Cannot update platform config ${params.id}: existing config not found`); } // id is in the URL path; don't echo it in the body const { id: _id, ...rest } = current as Record;