From 5bf9afecfb7712ad0b3b1b912b9c5739fa0c93d0 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 8 Apr 2026 16:31:30 -0300 Subject: [PATCH 1/3] feat: add emails attachments and attachment commands for outbound emails Add list/get subcommands for outbound email attachments, mirroring the existing receiving (inbound) attachment commands. Move shared renderAttachmentsTable to emails/utils.ts for reuse across both. --- src/commands/emails/attachment.ts | 51 ++++++ src/commands/emails/attachments.ts | 76 +++++++++ src/commands/emails/index.ts | 6 + src/commands/emails/receiving/attachments.ts | 3 +- src/commands/emails/receiving/utils.ts | 21 +-- src/commands/emails/utils.ts | 34 ++++ tests/commands/emails/attachment.test.ts | 141 ++++++++++++++++ tests/commands/emails/attachments.test.ts | 169 +++++++++++++++++++ 8 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 src/commands/emails/attachment.ts create mode 100644 src/commands/emails/attachments.ts create mode 100644 tests/commands/emails/attachment.test.ts create mode 100644 tests/commands/emails/attachments.test.ts diff --git a/src/commands/emails/attachment.ts b/src/commands/emails/attachment.ts new file mode 100644 index 00000000..c5672a98 --- /dev/null +++ b/src/commands/emails/attachment.ts @@ -0,0 +1,51 @@ +import { Command } from '@commander-js/extra-typings'; +import { runGet } from '../../lib/actions'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; +import { pickId } from '../../lib/prompts'; +import { attachmentPickerConfig, emailPickerConfig } from './utils'; + +export const getAttachmentCommand = new Command('attachment') + .description('Retrieve a single attachment from a sent (outbound) email') + .argument('[emailId]', 'Email UUID') + .argument('[attachmentId]', 'Attachment UUID') + .addHelpText( + 'after', + buildHelpText({ + context: + 'The download_url is a signed URL that expires in ~1 hour. Download the file directly:\n resend emails attachment --json | jq -r .download_url | xargs curl -O', + output: + ' {"object":"attachment","id":"","filename":"invoice.pdf","size":51200,"content_type":"application/pdf","content_disposition":"attachment","content_id":null,"download_url":"","expires_at":""}', + errorCodes: ['auth_error', 'fetch_error'], + examples: [ + 'resend emails attachment ', + 'resend emails attachment --json', + ], + }), + ) + .action(async (emailIdArg, attachmentIdArg, _opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const emailId = await pickId(emailIdArg, emailPickerConfig, globalOpts); + const attachmentId = await pickId( + attachmentIdArg, + attachmentPickerConfig(emailId), + globalOpts, + ); + await runGet( + { + loading: 'Fetching attachment...', + sdkCall: (resend) => + resend.emails.attachments.get({ emailId, id: attachmentId }), + onInteractive: (data) => { + console.log(`${data.filename ?? '(unnamed)'}`); + console.log(`ID: ${data.id}`); + console.log(`Content-Type: ${data.content_type}`); + console.log(`Size: ${data.size} bytes`); + console.log(`Disposition: ${data.content_disposition}`); + console.log(`Download URL: ${data.download_url}`); + console.log(`Expires: ${data.expires_at}`); + }, + }, + globalOpts, + ); + }); diff --git a/src/commands/emails/attachments.ts b/src/commands/emails/attachments.ts new file mode 100644 index 00000000..5da1174a --- /dev/null +++ b/src/commands/emails/attachments.ts @@ -0,0 +1,76 @@ +import { Command } from '@commander-js/extra-typings'; +import { runList } from '../../lib/actions'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; +import { + buildPaginationOpts, + parseLimitOpt, + printPaginationHint, +} from '../../lib/pagination'; +import { pickId } from '../../lib/prompts'; +import { emailPickerConfig, renderAttachmentsTable } from './utils'; + +export const listAttachmentsCommand = new Command('attachments') + .description('List attachments on a sent (outbound) email') + .argument('[emailId]', 'Email UUID') + .option( + '--limit ', + 'Maximum number of attachments to return (1-100)', + '10', + ) + .option( + '--after ', + 'Return attachments after this cursor (next page)', + ) + .option( + '--before ', + 'Return attachments before this cursor (previous page)', + ) + .addHelpText( + 'after', + buildHelpText({ + context: + 'Each attachment has a download_url (signed, expires ~1 hour).\nUse the attachment sub-command to retrieve a single attachment with its download URL:\n resend emails attachment \n\ncontent_disposition: "inline" means the attachment is embedded in the HTML body (e.g. an image).\ncontent_disposition: "attachment" means it is a standalone file download.', + output: + ' {"object":"list","has_more":false,"data":[{"id":"","filename":"invoice.pdf","size":51200,"content_type":"application/pdf","content_disposition":"attachment","content_id":null,"download_url":"","expires_at":""}]}', + errorCodes: ['auth_error', 'invalid_limit', 'list_error'], + examples: [ + 'resend emails attachments ', + 'resend emails attachments --json', + 'resend emails attachments --limit 25 --json', + ], + }), + ) + .action(async (emailIdArg, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const emailId = await pickId(emailIdArg, emailPickerConfig, globalOpts); + + const limit = parseLimitOpt(opts.limit, globalOpts); + const paginationOpts = buildPaginationOpts( + limit, + opts.after, + opts.before, + globalOpts, + ); + + await runList( + { + loading: 'Fetching attachments...', + sdkCall: (resend) => + resend.emails.attachments.list({ + emailId, + ...paginationOpts, + }), + onInteractive: (list) => { + console.log(renderAttachmentsTable(list.data)); + printPaginationHint(list, `emails attachments ${emailId}`, { + limit, + before: opts.before, + apiKey: globalOpts.apiKey, + profile: globalOpts.profile, + }); + }, + }, + globalOpts, + ); + }); diff --git a/src/commands/emails/index.ts b/src/commands/emails/index.ts index c1c21f13..8992cdff 100644 --- a/src/commands/emails/index.ts +++ b/src/commands/emails/index.ts @@ -1,5 +1,7 @@ import { Command } from '@commander-js/extra-typings'; import { buildHelpText } from '../../lib/help-text'; +import { getAttachmentCommand } from './attachment'; +import { listAttachmentsCommand } from './attachments'; import { batchCommand } from './batch'; import { cancelCommand } from './cancel'; import { getEmailCommand } from './get'; @@ -18,6 +20,8 @@ export const emailsCommand = new Command('emails') 'resend emails get ', 'resend emails batch --file ./emails.json', 'resend emails cancel ', + 'resend emails attachments ', + 'resend emails attachment ', 'resend emails receiving list', 'resend emails receiving forward --to delivered@resend.com --from onboarding@resend.com', ], @@ -29,4 +33,6 @@ export const emailsCommand = new Command('emails') .addCommand(batchCommand) .addCommand(cancelCommand) .addCommand(updateCommand) + .addCommand(listAttachmentsCommand) + .addCommand(getAttachmentCommand) .addCommand(receivingCommand); diff --git a/src/commands/emails/receiving/attachments.ts b/src/commands/emails/receiving/attachments.ts index 293a1a40..c8cd739c 100644 --- a/src/commands/emails/receiving/attachments.ts +++ b/src/commands/emails/receiving/attachments.ts @@ -8,7 +8,8 @@ import { printPaginationHint, } from '../../../lib/pagination'; import { pickId } from '../../../lib/prompts'; -import { receivedEmailPickerConfig, renderAttachmentsTable } from './utils'; +import { renderAttachmentsTable } from '../utils'; +import { receivedEmailPickerConfig } from './utils'; export const listAttachmentsCommand = new Command('attachments') .description('List attachments on a received (inbound) email') diff --git a/src/commands/emails/receiving/utils.ts b/src/commands/emails/receiving/utils.ts index 5bf17937..a0f35338 100644 --- a/src/commands/emails/receiving/utils.ts +++ b/src/commands/emails/receiving/utils.ts @@ -1,7 +1,4 @@ -import type { - ListAttachmentsResponseSuccess, - ListReceivingEmail, -} from 'resend'; +import type { ListReceivingEmail } from 'resend'; import type { PickerConfig } from '../../../lib/prompts'; import { renderTable } from '../../../lib/table'; @@ -32,19 +29,3 @@ export function renderReceivingEmailsTable( '(no received emails)', ); } - -export function renderAttachmentsTable( - attachments: ListAttachmentsResponseSuccess['data'], -): string { - const rows = attachments.map((a) => [ - a.filename ?? '(unnamed)', - a.content_type, - String(a.size), - a.id, - ]); - return renderTable( - ['Filename', 'Content-Type', 'Size (bytes)', 'ID'], - rows, - '(no attachments)', - ); -} diff --git a/src/commands/emails/utils.ts b/src/commands/emails/utils.ts index 18c307b4..556e6233 100644 --- a/src/commands/emails/utils.ts +++ b/src/commands/emails/utils.ts @@ -1,4 +1,6 @@ +import type { ListAttachmentsResponseSuccess } from 'resend'; import type { PickerConfig } from '../../lib/prompts'; +import { renderTable } from '../../lib/table'; export const emailPickerConfig: PickerConfig<{ id: string; @@ -10,3 +12,35 @@ export const emailPickerConfig: PickerConfig<{ resend.emails.list({ limit, ...(after && { after }) }), display: (e) => ({ label: e.subject || '(no subject)', hint: e.id }), }; + +export function attachmentPickerConfig( + emailId: string, +): PickerConfig<{ id: string; filename?: string }> { + return { + resource: 'attachment', + resourcePlural: 'attachments', + fetchItems: (resend, { limit, after }) => + resend.emails.attachments.list({ + emailId, + limit, + ...(after && { after }), + }), + display: (a) => ({ label: a.filename ?? '(unnamed)', hint: a.id }), + }; +} + +export function renderAttachmentsTable( + attachments: ListAttachmentsResponseSuccess['data'], +): string { + const rows = attachments.map((a) => [ + a.filename ?? '(unnamed)', + a.content_type, + String(a.size), + a.id, + ]); + return renderTable( + ['Filename', 'Content-Type', 'Size (bytes)', 'ID'], + rows, + '(no attachments)', + ); +} diff --git a/tests/commands/emails/attachment.test.ts b/tests/commands/emails/attachment.test.ts new file mode 100644 index 00000000..a1a910b9 --- /dev/null +++ b/tests/commands/emails/attachment.test.ts @@ -0,0 +1,141 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from 'vitest'; +import { + captureTestEnv, + expectExit1, + mockExitThrow, + mockSdkError, + setNonInteractive, + setupOutputSpies, +} from '../../helpers'; + +const mockGet = vi.fn(async () => ({ + data: { + object: 'attachment' as const, + id: 'attach_abc123', + filename: 'invoice.pdf', + size: 51200, + content_type: 'application/pdf', + content_disposition: 'attachment' as const, + content_id: undefined, + download_url: 'https://storage.example.com/signed/invoice.pdf', + expires_at: '2026-02-18T13:00:00.000Z', + }, + error: null, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + emails = { attachments: { get: mockGet } }; + }, +})); + +describe('emails attachment command', () => { + const restoreEnv = captureTestEnv(); + let spies: ReturnType | undefined; + let errorSpy: MockInstance | undefined; + let stderrSpy: MockInstance | undefined; + let exitSpy: MockInstance | undefined; + + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + mockGet.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + errorSpy?.mockRestore(); + stderrSpy?.mockRestore(); + exitSpy?.mockRestore(); + spies = undefined; + errorSpy = undefined; + stderrSpy = undefined; + exitSpy = undefined; + }); + + test('calls SDK get with emailId and attachmentId', async () => { + spies = setupOutputSpies(); + + const { getAttachmentCommand } = await import( + '../../../src/commands/emails/attachment' + ); + await getAttachmentCommand.parseAsync(['email123', 'attach_abc123'], { + from: 'user', + }); + + expect(mockGet).toHaveBeenCalledTimes(1); + const args = mockGet.mock.calls[0][0] as Record; + expect(args.emailId).toBe('email123'); + expect(args.id).toBe('attach_abc123'); + }); + + test('outputs JSON with attachment fields when non-interactive', async () => { + spies = setupOutputSpies(); + + const { getAttachmentCommand } = await import( + '../../../src/commands/emails/attachment' + ); + await getAttachmentCommand.parseAsync(['email123', 'attach_abc123'], { + from: 'user', + }); + + const output = spies.logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.id).toBe('attach_abc123'); + expect(parsed.filename).toBe('invoice.pdf'); + expect(parsed.content_type).toBe('application/pdf'); + expect(parsed.download_url).toBe( + 'https://storage.example.com/signed/invoice.pdf', + ); + }); + + test('errors with auth_error when no API key', async () => { + setNonInteractive(); + delete process.env.RESEND_API_KEY; + process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { getAttachmentCommand } = await import( + '../../../src/commands/emails/attachment' + ); + await expectExit1(() => + getAttachmentCommand.parseAsync(['email123', 'attach_abc123'], { + from: 'user', + }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('auth_error'); + }); + + test('errors with fetch_error when SDK returns an error', async () => { + setNonInteractive(); + mockGet.mockResolvedValueOnce(mockSdkError('Not found', 'not_found')); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { getAttachmentCommand } = await import( + '../../../src/commands/emails/attachment' + ); + await expectExit1(() => + getAttachmentCommand.parseAsync(['email123', 'attach_nonexistent'], { + from: 'user', + }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('fetch_error'); + }); +}); diff --git a/tests/commands/emails/attachments.test.ts b/tests/commands/emails/attachments.test.ts new file mode 100644 index 00000000..ca74dd36 --- /dev/null +++ b/tests/commands/emails/attachments.test.ts @@ -0,0 +1,169 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from 'vitest'; +import { + captureTestEnv, + expectExit1, + mockExitThrow, + mockSdkError, + setNonInteractive, + setupOutputSpies, +} from '../../helpers'; + +const mockList = vi.fn(async () => ({ + data: { + object: 'list' as const, + has_more: false, + data: [ + { + id: 'attach_abc123', + filename: 'invoice.pdf', + size: 51200, + content_type: 'application/pdf', + content_disposition: 'attachment' as const, + content_id: null, + download_url: 'https://storage.example.com/signed/invoice.pdf', + expires_at: '2026-02-18T13:00:00.000Z', + }, + ], + }, + error: null, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + emails = { attachments: { list: mockList } }; + }, +})); + +describe('emails attachments command', () => { + const restoreEnv = captureTestEnv(); + let spies: ReturnType | undefined; + let errorSpy: MockInstance | undefined; + let stderrSpy: MockInstance | undefined; + let exitSpy: MockInstance | undefined; + + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + mockList.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + errorSpy?.mockRestore(); + stderrSpy?.mockRestore(); + exitSpy?.mockRestore(); + spies = undefined; + errorSpy = undefined; + stderrSpy = undefined; + exitSpy = undefined; + }); + + test('calls SDK list with emailId and default pagination', async () => { + spies = setupOutputSpies(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await listAttachmentsCommand.parseAsync(['email123'], { from: 'user' }); + + expect(mockList).toHaveBeenCalledTimes(1); + const args = mockList.mock.calls[0][0] as Record; + expect(args.emailId).toBe('email123'); + expect(args.limit).toBe(10); + }); + + test('passes --limit to pagination options', async () => { + spies = setupOutputSpies(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await listAttachmentsCommand.parseAsync(['email123', '--limit', '5'], { + from: 'user', + }); + + const args = mockList.mock.calls[0][0] as Record; + expect(args.limit).toBe(5); + }); + + test('outputs JSON list with attachment data when non-interactive', async () => { + spies = setupOutputSpies(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await listAttachmentsCommand.parseAsync(['email123'], { from: 'user' }); + + const output = spies.logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data[0].id).toBe('attach_abc123'); + expect(parsed.data[0].filename).toBe('invoice.pdf'); + expect(parsed.data[0].content_type).toBe('application/pdf'); + expect(parsed.has_more).toBe(false); + }); + + test('errors with invalid_limit for out-of-range limit', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await expectExit1(() => + listAttachmentsCommand.parseAsync(['email123', '--limit', '200'], { + from: 'user', + }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_limit'); + }); + + test('errors with auth_error when no API key', async () => { + setNonInteractive(); + delete process.env.RESEND_API_KEY; + process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await expectExit1(() => + listAttachmentsCommand.parseAsync(['email123'], { from: 'user' }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('auth_error'); + }); + + test('errors with list_error when SDK returns an error', async () => { + setNonInteractive(); + mockList.mockResolvedValueOnce(mockSdkError('Not found', 'not_found')); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { listAttachmentsCommand } = await import( + '../../../src/commands/emails/attachments' + ); + await expectExit1(() => + listAttachmentsCommand.parseAsync(['nonexistent'], { from: 'user' }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('list_error'); + }); +}); From 7ecc75aac6921de5727ec90ef05f33cbd78b2f47 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 8 Apr 2026 17:17:02 -0300 Subject: [PATCH 2/3] feat: add interactive pickers to receiving attachment command Make receiving attachment command symmetrical with outbound: both emailId and attachmentId are now optional with interactive pickers. Add receivedAttachmentPickerConfig to receiving/utils.ts. --- src/commands/emails/receiving/attachment.ts | 64 ++++++++++++--------- src/commands/emails/receiving/utils.ts | 16 ++++++ 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/commands/emails/receiving/attachment.ts b/src/commands/emails/receiving/attachment.ts index 18a23045..ded3c62c 100644 --- a/src/commands/emails/receiving/attachment.ts +++ b/src/commands/emails/receiving/attachment.ts @@ -1,15 +1,17 @@ import { Command } from '@commander-js/extra-typings'; +import { runGet } from '../../../lib/actions'; import type { GlobalOpts } from '../../../lib/client'; -import { requireClient } from '../../../lib/client'; import { buildHelpText } from '../../../lib/help-text'; -import { outputResult } from '../../../lib/output'; -import { withSpinner } from '../../../lib/spinner'; -import { isInteractive } from '../../../lib/tty'; +import { pickId } from '../../../lib/prompts'; +import { + receivedAttachmentPickerConfig, + receivedEmailPickerConfig, +} from './utils'; export const getAttachmentCommand = new Command('attachment') .description('Retrieve a single attachment from a received (inbound) email') - .argument('', 'Received email UUID') - .argument('', 'Attachment UUID') + .argument('[emailId]', 'Received email UUID') + .argument('[attachmentId]', 'Attachment UUID') .addHelpText( 'after', buildHelpText({ @@ -24,28 +26,36 @@ export const getAttachmentCommand = new Command('attachment') ], }), ) - .action(async (emailId, attachmentId, _opts, cmd) => { + .action(async (emailIdArg, attachmentIdArg, _opts, cmd) => { const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - const resend = await requireClient(globalOpts); - - const data = await withSpinner( - 'Fetching attachment...', - () => - resend.emails.receiving.attachments.get({ emailId, id: attachmentId }), - 'fetch_error', + const emailId = await pickId( + emailIdArg, + receivedEmailPickerConfig, + globalOpts, + ); + const attachmentId = await pickId( + attachmentIdArg, + receivedAttachmentPickerConfig(emailId), + globalOpts, + ); + await runGet( + { + loading: 'Fetching attachment...', + sdkCall: (resend) => + resend.emails.receiving.attachments.get({ + emailId, + id: attachmentId, + }), + onInteractive: (data) => { + console.log(`${data.filename ?? '(unnamed)'}`); + console.log(`ID: ${data.id}`); + console.log(`Content-Type: ${data.content_type}`); + console.log(`Size: ${data.size} bytes`); + console.log(`Disposition: ${data.content_disposition}`); + console.log(`Download URL: ${data.download_url}`); + console.log(`Expires: ${data.expires_at}`); + }, + }, globalOpts, ); - - if (!globalOpts.json && isInteractive()) { - const d = data; - console.log(`${d.filename ?? '(unnamed)'}`); - console.log(`ID: ${d.id}`); - console.log(`Content-Type: ${d.content_type}`); - console.log(`Size: ${d.size} bytes`); - console.log(`Disposition: ${d.content_disposition}`); - console.log(`Download URL: ${d.download_url}`); - console.log(`Expires: ${d.expires_at}`); - } else { - outputResult(data, { json: globalOpts.json }); - } }); diff --git a/src/commands/emails/receiving/utils.ts b/src/commands/emails/receiving/utils.ts index a0f35338..0ec99853 100644 --- a/src/commands/emails/receiving/utils.ts +++ b/src/commands/emails/receiving/utils.ts @@ -13,6 +13,22 @@ export const receivedEmailPickerConfig: PickerConfig<{ display: (e) => ({ label: e.subject || '(no subject)', hint: e.id }), }; +export function receivedAttachmentPickerConfig( + emailId: string, +): PickerConfig<{ id: string; filename?: string }> { + return { + resource: 'attachment', + resourcePlural: 'attachments', + fetchItems: (resend, { limit, after }) => + resend.emails.receiving.attachments.list({ + emailId, + limit, + ...(after && { after }), + }), + display: (a) => ({ label: a.filename ?? '(unnamed)', hint: a.id }), + }; +} + export function renderReceivingEmailsTable( emails: ListReceivingEmail[], ): string { From a9f7ed7b3ed9f668dc12c6b6c7d6b57203dbec0f Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Thu, 9 Apr 2026 16:28:29 -0300 Subject: [PATCH 3/3] fix: spy on console.error instead of console.log in attachment error tests outputError() writes to console.error, so the error test spies were capturing nothing. --- tests/commands/emails/attachment.test.ts | 4 ++-- tests/commands/emails/attachments.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/commands/emails/attachment.test.ts b/tests/commands/emails/attachment.test.ts index a1a910b9..204adea9 100644 --- a/tests/commands/emails/attachment.test.ts +++ b/tests/commands/emails/attachment.test.ts @@ -101,7 +101,7 @@ describe('emails attachment command', () => { setNonInteractive(); delete process.env.RESEND_API_KEY; process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); const { getAttachmentCommand } = await import( @@ -120,7 +120,7 @@ describe('emails attachment command', () => { test('errors with fetch_error when SDK returns an error', async () => { setNonInteractive(); mockGet.mockResolvedValueOnce(mockSdkError('Not found', 'not_found')); - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); diff --git a/tests/commands/emails/attachments.test.ts b/tests/commands/emails/attachments.test.ts index ca74dd36..6c1c88c1 100644 --- a/tests/commands/emails/attachments.test.ts +++ b/tests/commands/emails/attachments.test.ts @@ -113,7 +113,7 @@ describe('emails attachments command', () => { test('errors with invalid_limit for out-of-range limit', async () => { setNonInteractive(); - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); const { listAttachmentsCommand } = await import( @@ -133,7 +133,7 @@ describe('emails attachments command', () => { setNonInteractive(); delete process.env.RESEND_API_KEY; process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = mockExitThrow(); const { listAttachmentsCommand } = await import( @@ -150,7 +150,7 @@ describe('emails attachments command', () => { test('errors with list_error when SDK returns an error', async () => { setNonInteractive(); mockList.mockResolvedValueOnce(mockSdkError('Not found', 'not_found')); - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true);