From 6665277c7b38510ba926aac0010f7054fcccc007 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:36:09 +0000 Subject: [PATCH] test: add full coverage for fetch command paths - --stdout with TS and JSON output - --stdout with multiple contracts (error) - --output writing to specified file path - --output with multiple contracts (error) - default file writing (derives filename from contract name) - default file writing with json format - multi-contract loop writes separate files - invalid format validation - network validation before fetch Tests go from 31 to 38. Closes #6, closes #16 Co-Authored-By: Claude Opus 4.6 --- .changeset/test-fetch-command-coverage.md | 5 + tests/unit/cli.test.ts | 231 +++++++++++++++++----- 2 files changed, 185 insertions(+), 51 deletions(-) create mode 100644 .changeset/test-fetch-command-coverage.md diff --git a/.changeset/test-fetch-command-coverage.md b/.changeset/test-fetch-command-coverage.md new file mode 100644 index 0000000..125edc6 --- /dev/null +++ b/.changeset/test-fetch-command-coverage.md @@ -0,0 +1,5 @@ +--- +"@satoshai/abi-cli": patch +--- + +Add comprehensive test coverage for fetch command (--stdout, --output, file writing, validation) diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index 074d4fd..d31ce2b 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -1,88 +1,217 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchCommand } from '../../src/commands/fetch.js'; import { sampleAbi } from './fixtures.js'; +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), +})); // eslint-disable-next-line @typescript-eslint/no-explicit-any const runFetch = (args: Record) => fetchCommand.run!({ args, rawArgs: [], cmd: fetchCommand } as never); +function mockFetchSuccess(times = 1) { + for (let i = 0; i < times; i++) { + vi.mocked(globalThis.fetch).mockResolvedValueOnce({ + ok: true, + json: async () => sampleAbi, + } as Response); + } +} + describe('fetchCommand', () => { const originalFetch = globalThis.fetch; + let errorSpy: ReturnType; beforeEach(() => { globalThis.fetch = vi.fn(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(writeFile).mockResolvedValue(); }); afterEach(() => { globalThis.fetch = originalFetch; + errorSpy.mockRestore(); + vi.restoreAllMocks(); }); - it('writes TypeScript to stdout', async () => { - vi.mocked(globalThis.fetch).mockResolvedValueOnce({ - ok: true, - json: async () => sampleAbi, - } as Response); + describe('--stdout', () => { + it('writes TypeScript to stdout', async () => { + mockFetchSuccess(); + + const chunks: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); - const chunks: string[] = []; - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - chunks.push(String(chunk)); - return true; + await runFetch({ + contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', + network: 'mainnet', + format: 'ts', + stdout: true, + }); + + const output = chunks.join(''); + expect(output).toContain('export const abi ='); + expect(output).toContain('as const;'); + expect(output).toContain('"transfer"'); + + writeSpy.mockRestore(); }); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - await runFetch({ - contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', - network: 'mainnet', - format: 'ts', - stdout: true, + it('writes JSON to stdout', async () => { + mockFetchSuccess(); + + const chunks: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); + + await runFetch({ + contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', + network: 'mainnet', + format: 'json', + stdout: true, + }); + + const output = chunks.join(''); + const parsed = JSON.parse(output); + expect(parsed.functions).toBeDefined(); + expect(parsed.functions.length).toBe(4); + + writeSpy.mockRestore(); }); - const output = chunks.join(''); - expect(output).toContain('export const abi ='); - expect(output).toContain('as const;'); - expect(output).toContain('"transfer"'); + it('errors with multiple contracts', async () => { + await expect( + runFetch({ + contract: 'SP1.a,SP2.b', + network: 'mainnet', + format: 'ts', + stdout: true, + }), + ).rejects.toThrow('--stdout cannot be used with multiple contracts'); + }); + }); - writeSpy.mockRestore(); - errorSpy.mockRestore(); + describe('--output', () => { + it('writes to the specified file path', async () => { + mockFetchSuccess(); + + await runFetch({ + contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', + network: 'mainnet', + format: 'ts', + stdout: false, + output: 'custom-output.ts', + }); + + expect(writeFile).toHaveBeenCalledWith( + resolve('custom-output.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + }); + + it('errors with multiple contracts', async () => { + await expect( + runFetch({ + contract: 'SP1.a,SP2.b', + network: 'mainnet', + format: 'ts', + stdout: false, + output: 'out.ts', + }), + ).rejects.toThrow('--output cannot be used with multiple contracts'); + }); }); - it('writes JSON to stdout', async () => { - vi.mocked(globalThis.fetch).mockResolvedValueOnce({ - ok: true, - json: async () => sampleAbi, - } as Response); + describe('default file writing', () => { + it('writes to default filename derived from contract name', async () => { + mockFetchSuccess(); - const chunks: string[] = []; - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - chunks.push(String(chunk)); - return true; + await runFetch({ + contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', + network: 'mainnet', + format: 'ts', + stdout: false, + }); + + expect(writeFile).toHaveBeenCalledWith( + resolve('nft-trait.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); }); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - await runFetch({ - contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', - network: 'mainnet', - format: 'json', - stdout: true, + it('uses .json extension for json format', async () => { + mockFetchSuccess(); + + await runFetch({ + contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait', + network: 'mainnet', + format: 'json', + stdout: false, + }); + + expect(writeFile).toHaveBeenCalledWith( + resolve('nft-trait.json'), + expect.any(String), + 'utf-8', + ); }); - const output = chunks.join(''); - const parsed = JSON.parse(output); - expect(parsed.functions).toBeDefined(); - expect(parsed.functions.length).toBe(4); + it('writes separate files for multiple contracts', async () => { + mockFetchSuccess(2); - writeSpy.mockRestore(); - errorSpy.mockRestore(); + await runFetch({ + contract: 'SP1.token-a,SP2.token-b', + network: 'mainnet', + format: 'ts', + stdout: false, + }); + + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith( + resolve('token-a.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + expect(writeFile).toHaveBeenCalledWith( + resolve('token-b.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + }); }); - it('throws on invalid format', async () => { - await expect( - runFetch({ - contract: 'SP2P.nft-trait', - network: 'mainnet', - format: 'yaml', - stdout: true, - }), - ).rejects.toThrow('Invalid format "yaml"'); + describe('validation', () => { + it('throws on invalid format', async () => { + await expect( + runFetch({ + contract: 'SP2P.nft-trait', + network: 'mainnet', + format: 'yaml', + stdout: true, + }), + ).rejects.toThrow('Invalid format "yaml"'); + }); + + it('validates network before fetching', async () => { + await expect( + runFetch({ + contract: 'SP1.token', + network: 'badnet', + format: 'ts', + stdout: true, + }), + ).rejects.toThrow('Invalid network "badnet"'); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); }); });