diff --git a/src/_fixtures/auth.ts b/src/_fixtures/auth.ts index c407f73..8cca024 100644 --- a/src/_fixtures/auth.ts +++ b/src/_fixtures/auth.ts @@ -1,5 +1,5 @@ import type { MigrateAuthResult } from '@doist/cli-core/auth' -import type { Config } from '../lib/config.js' +import type { Config, StoredUser } from '../lib/config.js' import type { OutlineAccount } from '../lib/outline-account.js' /** Canonical persisted `OutlineAccount` used across auth tests. */ @@ -11,6 +11,15 @@ export const STORED_ACCOUNT: OutlineAccount = { teamName: 'Analytics', } +/** Snake-case `StoredUser` twin of {@link STORED_ACCOUNT}, derived so the two can't drift. */ +export const STORED_USER_ADA: StoredUser = { + id: STORED_ACCOUNT.id, + name: STORED_ACCOUNT.label, + base_url: STORED_ACCOUNT.baseUrl, + oauth_client_id: STORED_ACCOUNT.oauthClientId, + team_name: STORED_ACCOUNT.teamName, +} + /** Secondary persisted `OutlineAccount` on a different instance — for multi-account tests. */ export const STORED_ACCOUNT_BOB: OutlineAccount = { id: 'bob-uuid', diff --git a/src/_fixtures/proxy-env.ts b/src/_fixtures/proxy-env.ts new file mode 100644 index 0000000..c7adb3a --- /dev/null +++ b/src/_fixtures/proxy-env.ts @@ -0,0 +1,35 @@ +const PROXY_ENV_KEYS = [ + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +] as const + +type ProxyEnvSnapshot = Map + +/** Snapshot the current proxy env. Take this per suite so restore is order-independent. */ +export function captureProxyEnv(): ProxyEnvSnapshot { + return new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) +} + +/** Unset every proxy env var so transport-selection tests start from a clean slate. */ +export function clearProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) { + delete process.env[key] + } +} + +/** Restore the proxy env to a snapshot taken by {@link captureProxyEnv}. */ +export function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void { + for (const key of PROXY_ENV_KEYS) { + const value = snapshot.get(key) + if (value === undefined) { + delete process.env[key] + continue + } + + process.env[key] = value + } +} diff --git a/src/_fixtures/testing.ts b/src/_fixtures/testing.ts new file mode 100644 index 0000000..141e991 --- /dev/null +++ b/src/_fixtures/testing.ts @@ -0,0 +1,40 @@ +import { type MockInstance, vi } from 'vitest' + +/** + * A `captureConsole`/`captureStream` spy's recorded calls as one string per + * call (space-joined args, matching how chalk's styled fragments arrive). + */ +export function lines(spy: MockInstance): string[] { + return spy.mock.calls.map((args) => args.join(' ')) +} + +/** Same as {@link lines} but newline-joined into a single string. */ +export function linesText(spy: MockInstance): string { + return lines(spy).join('\n') +} + +/** + * Spy on `process.exit` so it throws `process.exit()` instead of killing + * the test runner. Pair with `.rejects.toThrow('process.exit(1)')`. + */ +export function mockProcessExit(): MockInstance { + return vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`) + }) +} + +/** + * Default `../lib/auth.js` mock for command-surface tests: a logged-in + * config-file user on the test instance. Keys mirror the real `auth.ts` + * exports; pass `overrides` for the few tests that need dynamic behaviour. + * Use as `vi.mock('../lib/auth.js', () => mockOutlineAuthModule())`. + */ +export function mockOutlineAuthModule(overrides: Record = {}) { + return { + getApiToken: async () => 'test-token', + getBaseUrl: async () => 'https://test.outline.com', + getOAuthClientId: async () => undefined, + getActiveTokenSource: async () => 'config-file' as const, + ...overrides, + } +} diff --git a/src/commands/account.test.ts b/src/commands/account.test.ts index 98933b1..5c04f08 100644 --- a/src/commands/account.test.ts +++ b/src/commands/account.test.ts @@ -2,6 +2,7 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import type { Command } from 'commander' import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import { linesText } from '../_fixtures/testing.js' import { CliError } from '../lib/errors.js' // `account` consumes two auth-provider exports: the token store (list/use/remove @@ -25,15 +26,16 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => { } }) -function lines(spy: MockInstance): string { - return spy.mock.calls.map((args) => args.join(' ')).join('\n') -} - async function buildProgram(): Promise { const { registerAccountCommand } = await import('./account.js') return createTestProgram(registerAccountCommand) } +const STORED_LIST = [ + { account: STORED_ACCOUNT, isDefault: true }, + { account: STORED_ACCOUNT_BOB, isDefault: false }, +] + let logSpy: MockInstance let errSpy: MockInstance @@ -55,13 +57,10 @@ afterEach(() => { describe('account command', () => { describe('list', () => { it('renders all stored accounts with the default marker', async () => { - storeMocks.list.mockResolvedValue([ - { account: STORED_ACCOUNT, isDefault: true }, - { account: STORED_ACCOUNT_BOB, isDefault: false }, - ]) + storeMocks.list.mockResolvedValue(STORED_LIST) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list']) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Ada') expect(out).toContain('Bob') expect(out).toContain(`id:${STORED_ACCOUNT.id}`) @@ -72,24 +71,21 @@ describe('account command', () => { storeMocks.list.mockResolvedValue([]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list']) - expect(lines(logSpy)).toMatch(/No stored accounts/) + expect(linesText(logSpy)).toMatch(/No stored accounts/) }) it('runs by default when no subcommand is given (ol account)', async () => { storeMocks.list.mockResolvedValue([{ account: STORED_ACCOUNT, isDefault: true }]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account']) - expect(lines(logSpy)).toContain('Ada') + expect(linesText(logSpy)).toContain('Ada') }) it('emits a {accounts, default} envelope under --json', async () => { - storeMocks.list.mockResolvedValue([ - { account: STORED_ACCOUNT, isDefault: true }, - { account: STORED_ACCOUNT_BOB, isDefault: false }, - ]) + storeMocks.list.mockResolvedValue(STORED_LIST) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list', '--json']) - const payload = JSON.parse(lines(logSpy)) + const payload = JSON.parse(linesText(logSpy)) expect(payload.default).toBe(STORED_ACCOUNT.id) expect(payload.accounts).toHaveLength(2) expect(payload.accounts[0]).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true }) @@ -104,7 +100,7 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'use', STORED_ACCOUNT_BOB.id]) expect(storeMocks.setDefault).toHaveBeenCalledWith(STORED_ACCOUNT_BOB.id) - expect(lines(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`) + expect(linesText(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`) }) it('propagates ACCOUNT_NOT_FOUND from setDefault for an unknown ref', async () => { @@ -125,14 +121,14 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', 'bob']) expect(storeMocks.clear).toHaveBeenCalledWith('bob') - expect(lines(logSpy)).toContain('Removed Bob') + expect(linesText(logSpy)).toContain('Removed Bob') }) it('notes a cleared default when removing the default account', async () => { storeMocks.clear.mockResolvedValue({ account: STORED_ACCOUNT, wasDefault: true }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id]) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Removed Ada') expect(out).toMatch(/Cleared default account/) }) @@ -153,7 +149,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id]) - expect(lines(errSpy)).toContain('OS keyring unavailable') + expect(linesText(errSpy)).toContain('OS keyring unavailable') }) }) @@ -166,7 +162,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current']) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Ada') expect(out).toContain(STORED_ACCOUNT.baseUrl) }) @@ -179,7 +175,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current', '--json']) - const payload = JSON.parse(lines(logSpy)) + const payload = JSON.parse(linesText(logSpy)) expect(payload.source).toBe('stored') expect(payload.account).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true }) expect(payload.account).not.toHaveProperty('oauthClientId') @@ -198,7 +194,7 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current']) expect(resolveMock).toHaveBeenCalledWith('Bob') - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Bob') expect(out).not.toContain('OUTLINE_API_TOKEN') }) @@ -206,25 +202,25 @@ describe('account command', () => { it('reports the env-token source (human + --json)', async () => { resolveMock.mockResolvedValue({ source: 'env' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current']) - expect(lines(logSpy)).toContain('OUTLINE_API_TOKEN') + expect(linesText(logSpy)).toContain('OUTLINE_API_TOKEN') logSpy.mockClear() resolveMock.mockResolvedValue({ source: 'env' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current', '--json']) - expect(JSON.parse(lines(logSpy))).toEqual({ source: 'env' }) + expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'env' }) }) it('reports the legacy source (human + --json)', async () => { resolveMock.mockResolvedValue({ source: 'legacy' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current']) - expect(lines(logSpy)).toMatch(/legacy single-user credentials/) + expect(linesText(logSpy)).toMatch(/legacy single-user credentials/) logSpy.mockClear() resolveMock.mockResolvedValue({ source: 'legacy' }) await ( await buildProgram() ).parseAsync(['node', 'ol', 'account', 'current', '--ndjson']) - expect(JSON.parse(lines(logSpy))).toEqual({ source: 'legacy' }) + expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'legacy' }) }) it('throws NOT_AUTHENTICATED when nothing is active', async () => { diff --git a/src/commands/auth-command.test.ts b/src/commands/auth-command.test.ts index 1eb11e8..2685079 100644 --- a/src/commands/auth-command.test.ts +++ b/src/commands/auth-command.test.ts @@ -1,18 +1,18 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { Command } from 'commander' -import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js' - -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getActiveTokenSource: async () => - process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const), - // status resolves the live token for the selected account via this; echo - // the snapshot token back (no refresh in these command-surface tests). - refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback, -})) +import { lines, mockOutlineAuthModule } from '../_fixtures/testing.js' + +vi.mock('../lib/auth.js', () => + mockOutlineAuthModule({ + getActiveTokenSource: async () => + process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const), + // status resolves the live token for the selected account via this; echo + // the snapshot token back (no refresh in these command-surface tests). + refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback, + }), +) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) @@ -32,14 +32,6 @@ vi.mock('@doist/cli-core/auth', async () => ({ attachLoginCommand: vi.fn(), })) -/** - * Read a `captureConsole` spy's recorded calls as joined lines, matching how - * chalk's styled fragments arrive (one console call → one space-joined line). - */ -function lines(spy: MockInstance): string[] { - return spy.mock.calls.map((args) => args.join(' ')) -} - async function captureAttachOptions() { const { attachLoginCommand } = await import('@doist/cli-core/auth') const login = new Command('login') diff --git a/src/commands/auth-token.test.ts b/src/commands/auth-token.test.ts index 85be749..d8282cb 100644 --- a/src/commands/auth-token.test.ts +++ b/src/commands/auth-token.test.ts @@ -1,7 +1,8 @@ import { captureConsole, captureStream, createTestProgram } from '@doist/cli-core/testing' import type { Command } from 'commander' -import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import { linesText } from '../_fixtures/testing.js' import type { CliError } from '../lib/errors.js' // `auth token` save drives the raw store's `set` + `getLastStorageResult`; @@ -27,10 +28,6 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => { vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) -function lines(spy: MockInstance): string { - return spy.mock.calls.map((args) => args.join(' ')).join('\n') -} - async function buildProgram(): Promise { const { registerAuthCommand } = await import('./auth.js') return createTestProgram(registerAuthCommand) @@ -94,7 +91,7 @@ describe('auth token (save)', () => { }, 'tok-paste', ) - expect(lines(log)).toContain('Saved token for Ada Lovelace (Analytics)') + expect(linesText(log)).toContain('Saved token for Ada Lovelace (Analytics)') }) it('collapses any auth.info failure into a leak-free AUTH_VERIFICATION_FAILED', async () => { @@ -173,7 +170,7 @@ describe('auth token (save)', () => { ]) expect(storeMocks.set).toHaveBeenCalled() - expect(lines(log)).toEqual('') + expect(linesText(log)).toEqual('') }) }) diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 539f6ec..87b643f 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -1,23 +1,13 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { lines, mockOutlineAuthModule, mockProcessExit } from '../_fixtures/testing.js' -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getTokenSource: async () => 'config' as const, - clearConfig: vi.fn(), -})) +vi.mock('../lib/auth.js', () => mockOutlineAuthModule()) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn(), })) -/** Read a `captureConsole` spy's recorded calls as joined lines. */ -function lines(spy: MockInstance): string[] { - return spy.mock.calls.map((args) => args.join(' ')) -} - describe('search command', () => { let log: MockInstance @@ -199,9 +189,7 @@ describe('document commands', () => { }) it('document create with both --collection and --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -268,9 +256,7 @@ describe('document commands', () => { }) it('document move with both --collection and --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -295,9 +281,7 @@ describe('document commands', () => { }) it('document move without --collection or --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -330,9 +314,7 @@ describe('document commands', () => { return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`)) }) - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') diff --git a/src/commands/empty-output.test.ts b/src/commands/empty-output.test.ts index 97c01d9..9e3beba 100644 --- a/src/commands/empty-output.test.ts +++ b/src/commands/empty-output.test.ts @@ -1,13 +1,8 @@ import { createTestProgram, describeEmptyMachineOutput } from '@doist/cli-core/testing' import { vi } from 'vitest' +import { mockOutlineAuthModule } from '../_fixtures/testing.js' -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getTokenSource: async () => 'config' as const, - clearConfig: vi.fn(), -})) +vi.mock('../lib/auth.js', () => mockOutlineAuthModule()) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn().mockResolvedValue({ data: [], pagination: undefined }), diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 84dd600..58ad268 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { errResponse, okResponse } from '../_fixtures/auth.js' const authMocks = vi.hoisted(() => ({ getApiToken: vi.fn(async () => 'test-token'), @@ -30,9 +31,10 @@ describe('apiRequest', () => { }) it('uses fetchWithRetry for API requests', async () => { - const mockResponse = { ok: true, json: async () => ({ data: { id: '123' } }) } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + okResponse({ data: { id: '123' } }), + ) const { apiRequest } = await import('./api.js') await apiRequest('documents.info', { id: 'abc' }) @@ -51,12 +53,10 @@ describe('apiRequest', () => { }) it('returns data and pagination', async () => { - const mockResponse = { - ok: true, - json: async () => ({ data: [{ id: '1' }], pagination: { offset: 0, limit: 25 } }), - } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + okResponse({ data: [{ id: '1' }], pagination: { offset: 0, limit: 25 } }), + ) const { apiRequest } = await import('./api.js') const result = await apiRequest('documents.list') @@ -66,14 +66,13 @@ describe('apiRequest', () => { }) it('throws on non-ok response with API message', async () => { - const mockResponse = { - ok: false, - status: 500, - statusText: 'Internal Server Error', - json: async () => ({ error: 'server_error', message: 'Server exploded' }), - } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + errResponse(500, 'Internal Server Error', { + error: 'server_error', + message: 'Server exploded', + }), + ) const { apiRequest } = await import('./api.js') await expect(apiRequest('documents.list')).rejects.toThrow('Server exploded') @@ -100,13 +99,8 @@ describe('apiRequest', () => { it('force-refreshes and retries once when a managed token gets a 401', async () => { const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') const f = fetchWithRetry as ReturnType - f.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - json: async () => ({}), - }) - f.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { id: 'ok' } }) }) + f.mockResolvedValueOnce(errResponse(401, 'Unauthorized')) + f.mockResolvedValueOnce(okResponse({ data: { id: 'ok' } })) authMocks.reactiveRefresh.mockResolvedValueOnce(true) authMocks.getApiToken .mockResolvedValueOnce('stale-token') @@ -125,7 +119,7 @@ describe('apiRequest', () => { it('proactively refreshes a managed token and uses the rotated token (no extra store read)', async () => { const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') const f = fetchWithRetry as ReturnType - f.mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }) + f.mockResolvedValue(okResponse({ data: {} })) authMocks.proactiveRefresh.mockResolvedValueOnce('rotated-proactive') const { apiRequest } = await import('./api.js') @@ -141,10 +135,7 @@ describe('apiRequest', () => { it('does not refresh when OUTLINE_API_TOKEN is set (unmanaged token)', async () => { vi.stubEnv('OUTLINE_API_TOKEN', 'env-tok') const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }), - }) + ;(fetchWithRetry as ReturnType).mockResolvedValue(okResponse({ data: {} })) const { apiRequest } = await import('./api.js') await apiRequest('documents.list') diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index 13634bf..71af51d 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -1,11 +1,11 @@ import { captureConsole } from '@doist/cli-core/testing' import { type MockInstance, beforeEach, describe, expect, it } from 'vitest' +import { lines } from '../_fixtures/testing.js' import { BaseCliError } from './errors.js' import { formatError, formatErrorJson, getOutputOptions, outputItem, outputList } from './output.js' describe('output', () => { let log: MockInstance - const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { log = captureConsole() @@ -17,24 +17,24 @@ describe('output', () => { it('outputItem human mode', () => { outputItem(item, formatter, keys) - expect(lines()[0]).toBe('Test (1)') + expect(lines(log)[0]).toBe('Test (1)') }) it('outputItem json mode shows essential keys only', () => { outputItem(item, formatter, keys, { json: true }) - const parsed = JSON.parse(lines()[0]) + const parsed = JSON.parse(lines(log)[0]) expect(parsed).toEqual({ id: '1', name: 'Test' }) }) it('outputItem json full mode shows all keys', () => { outputItem(item, formatter, keys, { json: true, full: true }) - const parsed = JSON.parse(lines()[0]) + const parsed = JSON.parse(lines(log)[0]) expect(parsed).toEqual({ id: '1', name: 'Test', extra: 'hidden' }) }) it('outputList ndjson mode', () => { outputList([item, { ...item, id: '2' }], formatter, keys, { ndjson: true }) - const records = lines() + const records = lines(log) .flatMap((line) => line.split('\n')) .filter(Boolean) .map((line) => JSON.parse(line)) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index b123ab5..2c2b110 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -10,6 +10,16 @@ vi.mock('./api.js', () => ({ apiRequest: (...args: unknown[]) => mockApiRequest(...args), })) +const ENG_PRODUCT_DOCS = [ + { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, + { id: '2', title: 'Product Docs', urlId: 'prod-2' }, +] + +const ENG_PRODUCT_COLLECTIONS = [ + { id: '1', name: 'Engineering' }, + { id: '2', name: 'Product' }, +] + describe('resolveDocumentRef', () => { beforeEach(() => { vi.resetModules() @@ -68,10 +78,7 @@ describe('resolveDocumentRef', () => { }) it('resolves document by exact name match (case-insensitive)', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "engineering guide" has spaces, doesn't look like an ID mockApiRequest.mockResolvedValueOnce({ data: mockDocs }) @@ -82,10 +89,7 @@ describe('resolveDocumentRef', () => { }) it('resolves document by partial name match when unique', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "product" is 7 chars, looks like an ID, so will try ID lookup first mockApiRequest .mockRejectedValueOnce(new Error('Not found')) // ID lookup fails @@ -163,10 +167,7 @@ describe('resolveDocumentRef', () => { }) it('falls back to name search when ID lookup fails', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "abc123" looks like an ID, but fails, then falls back to name search mockApiRequest .mockRejectedValueOnce(new Error('Not found')) @@ -244,10 +245,7 @@ describe('resolveCollectionRef', () => { }) it('resolves collection by exact name match', async () => { - const mockCols = [ - { id: '1', name: 'Engineering' }, - { id: '2', name: 'Product' }, - ] + const mockCols = ENG_PRODUCT_COLLECTIONS // "Engineering" is 11 chars, looks like an ID mockApiRequest .mockRejectedValueOnce(new Error('Not found')) @@ -260,10 +258,7 @@ describe('resolveCollectionRef', () => { }) it('resolves collection by partial name match when unique', async () => { - const mockCols = [ - { id: '1', name: 'Engineering' }, - { id: '2', name: 'Product' }, - ] + const mockCols = ENG_PRODUCT_COLLECTIONS // "prod" is 4 chars, doesn't look like an ID (needs 6+) mockApiRequest.mockResolvedValueOnce({ data: mockCols }) diff --git a/src/lib/user-records.test.ts b/src/lib/user-records.test.ts index d61ea37..6f89370 100644 --- a/src/lib/user-records.test.ts +++ b/src/lib/user-records.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { STORED_ACCOUNT } from '../_fixtures/auth.js' +import { STORED_ACCOUNT, STORED_USER_ADA as ADA } from '../_fixtures/auth.js' const configMock = vi.hoisted(() => ({ getConfig: vi.fn(), @@ -11,14 +11,6 @@ vi.mock('./config.js', async (importOriginal) => { return { ...actual, getConfig: configMock.getConfig, updateConfig: configMock.updateConfig } }) -const ADA = { - id: 'user-uuid', - name: 'Ada', - base_url: 'https://wiki.example.com', - oauth_client_id: 'cid-xyz', - team_name: 'Analytics', -} as const - const GRACE = { id: 'grace-uuid', name: 'Grace', diff --git a/src/transport/fetch-with-retry.test.ts b/src/transport/fetch-with-retry.test.ts index 83f24f2..9c21bc5 100644 --- a/src/transport/fetch-with-retry.test.ts +++ b/src/transport/fetch-with-retry.test.ts @@ -1,32 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const PROXY_ENV_KEYS = [ - 'HTTP_PROXY', - 'http_proxy', - 'HTTPS_PROXY', - 'https_proxy', - 'NO_PROXY', - 'no_proxy', -] as const - -const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) - -function clearProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - delete process.env[key] - } -} - -function restoreProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - const value = originalProxyEnv.get(key) - if (value === undefined) { - delete process.env[key] - continue - } - - process.env[key] = value - } +import { okResponse } from '../_fixtures/auth.js' +import { captureProxyEnv, clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' + +const originalProxyEnv = captureProxyEnv() + +/** A `fetch` impl that never resolves and rejects with the abort reason on signal. */ +function abortableFetch(_url: RequestInfo | URL, options?: RequestInit): Promise { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const reason = options.signal?.reason + reject( + reason instanceof Error + ? reason + : new Error(String(reason ?? 'Request aborted')), + ) + }, + { once: true }, + ) + }) } describe('fetchWithRetry', () => { @@ -38,7 +31,7 @@ describe('fetchWithRetry', () => { afterEach(async () => { const { resetDefaultDispatcherForTests } = await import('./http-dispatcher.js') await resetDefaultDispatcherForTests() - restoreProxyEnv() + restoreProxyEnv(originalProxyEnv) vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -47,12 +40,7 @@ describe('fetchWithRetry', () => { it('uses the default dispatcher for requests', async () => { const fetchMock = vi.fn() - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + fetchMock.mockResolvedValue(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { getDefaultDispatcher } = await import('./http-dispatcher.js') @@ -75,12 +63,7 @@ describe('fetchWithRetry', () => { fetchMock .mockRejectedValueOnce(new TypeError('Failed to fetch')) .mockRejectedValueOnce(new TypeError('Failed to fetch')) - .mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + .mockResolvedValue(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { fetchWithRetry } = await import('./fetch-with-retry.js') @@ -101,29 +84,8 @@ describe('fetchWithRetry', () => { const fetchMock = vi.fn() fetchMock - .mockImplementationOnce( - (_url: RequestInfo | URL, options?: RequestInit) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const reason = options.signal?.reason - reject( - reason instanceof Error - ? reason - : new Error(String(reason ?? 'Request aborted')), - ) - }, - { once: true }, - ) - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + .mockImplementationOnce(abortableFetch) + .mockResolvedValueOnce(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { fetchWithRetry } = await import('./fetch-with-retry.js') @@ -150,23 +112,7 @@ describe('fetchWithRetry', () => { vi.useFakeTimers() const fetchMock = vi.fn() - fetchMock.mockImplementation( - (_url: RequestInfo | URL, options?: RequestInit) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const reason = options.signal?.reason - reject( - reason instanceof Error - ? reason - : new Error(String(reason ?? 'Request aborted')), - ) - }, - { once: true }, - ) - }), - ) + fetchMock.mockImplementation(abortableFetch) vi.stubGlobal('fetch', fetchMock) const { getDefaultDispatcher } = await import('./http-dispatcher.js') diff --git a/src/transport/http-dispatcher.test.ts b/src/transport/http-dispatcher.test.ts index 8274884..c8fd277 100644 --- a/src/transport/http-dispatcher.test.ts +++ b/src/transport/http-dispatcher.test.ts @@ -3,35 +3,9 @@ import type { AddressInfo } from 'node:net' import { gzipSync } from 'node:zlib' import { Agent, EnvHttpProxyAgent } from 'undici' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureProxyEnv, clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' -const PROXY_ENV_KEYS = [ - 'HTTP_PROXY', - 'http_proxy', - 'HTTPS_PROXY', - 'https_proxy', - 'NO_PROXY', - 'no_proxy', -] as const - -const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) - -function clearProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - delete process.env[key] - } -} - -function restoreProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - const value = originalProxyEnv.get(key) - if (value === undefined) { - delete process.env[key] - continue - } - - process.env[key] = value - } -} +const originalProxyEnv = captureProxyEnv() describe('http-dispatcher', () => { beforeEach(() => { @@ -42,7 +16,7 @@ describe('http-dispatcher', () => { afterEach(async () => { const { resetDefaultDispatcherForTests } = await import('./http-dispatcher.js') await resetDefaultDispatcherForTests() - restoreProxyEnv() + restoreProxyEnv(originalProxyEnv) vi.restoreAllMocks() vi.resetModules() })