diff --git a/src/commands/contact-properties/update.ts b/src/commands/contact-properties/update.ts index 50a7a5ba..f0e11d25 100644 --- a/src/commands/contact-properties/update.ts +++ b/src/commands/contact-properties/update.ts @@ -1,9 +1,11 @@ import { Command } from '@commander-js/extra-typings'; import { runWrite } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; +import { requireClient } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; import { pickId } from '../../lib/prompts'; +import { withSpinner } from '../../lib/spinner'; import { contactPropertyPickerConfig } from './utils'; export const updateContactPropertyCommand = new Command('update') @@ -33,6 +35,8 @@ The fallback value is used in broadcast template interpolation when a contact ha 'auth_error', 'no_changes', 'conflicting_flags', + 'fetch_error', + 'invalid_fallback_value', 'update_error', ], examples: [ @@ -69,7 +73,33 @@ The fallback value is used in broadcast template interpolation when a contact ha ); } - const fallbackValue = opts.clearFallbackValue ? null : opts.fallbackValue; + let fallbackValue: string | number | null | undefined = + opts.clearFallbackValue ? null : opts.fallbackValue; + + if (typeof fallbackValue === 'string') { + const resend = await requireClient(globalOpts); + const property = await withSpinner( + 'Fetching contact property...', + () => resend.contactProperties.get(id), + 'fetch_error', + globalOpts, + ); + + if (property.type === 'number') { + const parsed = parseFloat(fallbackValue); + if (Number.isNaN(parsed)) { + outputError( + { + message: + '--fallback-value must be a valid number for number-type properties.', + code: 'invalid_fallback_value', + }, + { json: globalOpts.json }, + ); + } + fallbackValue = parsed; + } + } await runWrite( { diff --git a/tests/commands/contact-properties/update.test.ts b/tests/commands/contact-properties/update.test.ts index 4ebdff0d..43cbefb1 100644 --- a/tests/commands/contact-properties/update.test.ts +++ b/tests/commands/contact-properties/update.test.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect, + it, type MockInstance, - test, vi, } from 'vitest'; import { @@ -24,10 +24,22 @@ const mockUpdate = vi.fn(async () => ({ error: null, })); +const mockGet = vi.fn(async () => ({ + data: { + object: 'contact_property' as const, + id: 'b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', + key: 'company_name', + type: 'string' as const, + fallbackValue: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + error: null, +})); + vi.mock('resend', () => ({ Resend: class MockResend { constructor(public key: string) {} - contactProperties = { update: mockUpdate }; + contactProperties = { update: mockUpdate, get: mockGet }; }, })); @@ -41,6 +53,7 @@ describe('contact-properties update command', () => { beforeEach(() => { process.env.RESEND_API_KEY = 're_test_key'; mockUpdate.mockClear(); + mockGet.mockClear(); }); afterEach(() => { @@ -54,7 +67,7 @@ describe('contact-properties update command', () => { exitSpy = undefined; }); - test('updates property fallback value', async () => { + it('updates property fallback value', async () => { spies = setupOutputSpies(); const { updateContactPropertyCommand } = await import( @@ -71,7 +84,7 @@ describe('contact-properties update command', () => { expect(args.fallbackValue).toBe('Acme Corp'); }); - test('clears fallback value with --clear-fallback-value', async () => { + it('clears fallback value with --clear-fallback-value', async () => { spies = setupOutputSpies(); const { updateContactPropertyCommand } = await import( @@ -87,7 +100,7 @@ describe('contact-properties update command', () => { expect(args.fallbackValue).toBeNull(); }); - test('errors with conflicting_flags when both --fallback-value and --clear-fallback-value are given', async () => { + it('errors with conflicting_flags when both --fallback-value and --clear-fallback-value are given', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); @@ -111,7 +124,7 @@ describe('contact-properties update command', () => { expect(output).toContain('conflicting_flags'); }); - test('outputs JSON result when non-interactive', async () => { + it('outputs JSON result when non-interactive', async () => { spies = setupOutputSpies(); const { updateContactPropertyCommand } = await import( @@ -128,7 +141,7 @@ describe('contact-properties update command', () => { expect(parsed.id).toBe('b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d'); }); - test('errors with no_changes when no flags are provided', async () => { + it('errors with no_changes when no flags are provided', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); @@ -149,7 +162,7 @@ describe('contact-properties update command', () => { expect(output).toContain('no_changes'); }); - test('does not call SDK when no_changes error is raised', async () => { + it('does not call SDK when no_changes error is raised', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); @@ -169,7 +182,7 @@ describe('contact-properties update command', () => { expect(mockUpdate).not.toHaveBeenCalled(); }); - test('errors with auth_error when no API key', async () => { + it('errors with auth_error when no API key', async () => { setNonInteractive(); delete process.env.RESEND_API_KEY; process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; @@ -190,7 +203,7 @@ describe('contact-properties update command', () => { expect(output).toContain('auth_error'); }); - test('errors with update_error when SDK returns an error', async () => { + it('errors with update_error when SDK returns an error', async () => { setNonInteractive(); mockUpdate.mockResolvedValueOnce( mockSdkError('Property not found', 'not_found'), @@ -214,4 +227,95 @@ describe('contact-properties update command', () => { const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); expect(output).toContain('update_error'); }); + + it('coerces fallback-value to number for number-type properties', async () => { + spies = setupOutputSpies(); + mockGet.mockResolvedValueOnce({ + data: { + object: 'contact_property' as const, + id: 'b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', + key: 'score', + type: 'number' as const, + fallbackValue: 0, + createdAt: '2026-01-01T00:00:00.000Z', + }, + error: null, + }); + + const { updateContactPropertyCommand } = await import( + '../../../src/commands/contact-properties/update' + ); + await updateContactPropertyCommand.parseAsync( + ['b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', '--fallback-value', '42'], + { from: 'user' }, + ); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + const args = mockUpdate.mock.calls[0][0] as Record; + expect(args.fallbackValue).toBe(42); + }); + + it('keeps fallback-value as string for string-type properties', async () => { + spies = setupOutputSpies(); + mockGet.mockResolvedValueOnce({ + data: { + object: 'contact_property' as const, + id: 'b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', + key: 'company_name', + type: 'string' as const, + fallbackValue: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + error: null, + }); + + const { updateContactPropertyCommand } = await import( + '../../../src/commands/contact-properties/update' + ); + await updateContactPropertyCommand.parseAsync( + ['b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', '--fallback-value', 'Acme Corp'], + { from: 'user' }, + ); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + const args = mockUpdate.mock.calls[0][0] as Record; + expect(args.fallbackValue).toBe('Acme Corp'); + }); + + it('errors with invalid_fallback_value when number-type gets a non-numeric fallback', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + mockGet.mockResolvedValueOnce({ + data: { + object: 'contact_property' as const, + id: 'b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', + key: 'score', + type: 'number' as const, + fallbackValue: 0, + createdAt: '2026-01-01T00:00:00.000Z', + }, + error: null, + }); + + const { updateContactPropertyCommand } = await import( + '../../../src/commands/contact-properties/update' + ); + await expectExit1(() => + updateContactPropertyCommand.parseAsync( + [ + 'b4a3c2d1-6e5f-8a7b-0c9d-2e1f4a3b6c5d', + '--fallback-value', + 'not-a-number', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_fallback_value'); + }); });