fetch.test.ts
1 import { afterEach, describe, expect, it, vi } from 'vitest'; 2 import { CliError } from '../../errors.js'; 3 import type { IPage } from '../../types.js'; 4 import { stepFetch } from './fetch.js'; 5 6 afterEach(() => { 7 vi.restoreAllMocks(); 8 vi.unstubAllGlobals(); 9 }); 10 11 describe('stepFetch', () => { 12 // W1 + W4: non-browser single fetch throws CliError with FETCH_ERROR code and full message 13 it('throws CliError with FETCH_ERROR code on non-ok responses without a browser session', async () => { 14 const jsonMock = vi.fn().mockResolvedValue({ error: 'rate limited' }); 15 const fetchMock = vi.fn().mockResolvedValue({ 16 ok: false, 17 status: 429, 18 statusText: 'Too Many Requests', 19 json: jsonMock, 20 }); 21 vi.stubGlobal('fetch', fetchMock); 22 23 const err = await stepFetch(null, { url: 'https://api.example.com/items' }, null, {}).catch((e: unknown) => e); 24 expect(err).toBeInstanceOf(CliError); 25 expect((err as CliError).code).toBe('FETCH_ERROR'); 26 expect((err as CliError).message).toBe('HTTP 429 Too Many Requests from https://api.example.com/items'); 27 expect(jsonMock).not.toHaveBeenCalled(); 28 }); 29 30 // W1 + W3: browser single fetch returns error status from evaluate, outer code throws CliError 31 it('throws CliError with FETCH_ERROR code on non-ok responses inside the browser session', async () => { 32 const jsonMock = vi.fn().mockResolvedValue({ error: 'auth required' }); 33 const fetchMock = vi.fn().mockResolvedValue({ 34 ok: false, 35 status: 401, 36 statusText: 'Unauthorized', 37 json: jsonMock, 38 }); 39 vi.stubGlobal('fetch', fetchMock); 40 41 // Simulate real CDP behavior: evaluate returns a value, errors are thrown outside 42 const page = { 43 evaluate: vi.fn(async (js: string) => Function(`return (${js})`)()()), 44 } as unknown as IPage; 45 46 const err = await stepFetch(page, { url: 'https://api.example.com/items' }, null, {}).catch((e: unknown) => e); 47 expect(err).toBeInstanceOf(CliError); 48 expect((err as CliError).code).toBe('FETCH_ERROR'); 49 expect((err as CliError).message).toBe('HTTP 401 Unauthorized from https://api.example.com/items'); 50 expect(jsonMock).not.toHaveBeenCalled(); 51 }); 52 53 it('returns per-item HTTP errors for batch fetches without a browser session', async () => { 54 const jsonMock = vi.fn().mockResolvedValue({ error: 'upstream unavailable' }); 55 const fetchMock = vi.fn().mockResolvedValue({ 56 ok: false, 57 status: 503, 58 statusText: 'Service Unavailable', 59 json: jsonMock, 60 }); 61 vi.stubGlobal('fetch', fetchMock); 62 63 await expect(stepFetch( 64 null, 65 { url: 'https://api.example.com/items/${{ item.id }}' }, 66 [{ id: 1 }], 67 {}, 68 )).resolves.toEqual([ 69 { error: 'HTTP 503 Service Unavailable from https://api.example.com/items/1' }, 70 ]); 71 expect(jsonMock).not.toHaveBeenCalled(); 72 }); 73 74 it('returns per-item HTTP errors for batch browser fetches', async () => { 75 const jsonMock = vi.fn().mockResolvedValue({ error: 'upstream unavailable' }); 76 const fetchMock = vi.fn().mockResolvedValue({ 77 ok: false, 78 status: 503, 79 statusText: 'Service Unavailable', 80 json: jsonMock, 81 }); 82 vi.stubGlobal('fetch', fetchMock); 83 84 const page = { 85 evaluate: vi.fn(async (js: string) => Function(`return (${js})`)()()), 86 } as unknown as IPage; 87 88 await expect(stepFetch( 89 page, 90 { url: 'https://api.example.com/items/${{ item.id }}' }, 91 [{ id: 1 }], 92 {}, 93 )).resolves.toEqual([ 94 { error: 'HTTP 503 Service Unavailable from https://api.example.com/items/1' }, 95 ]); 96 expect(jsonMock).not.toHaveBeenCalled(); 97 }); 98 99 it('stringifies non-Error batch browser failures consistently', async () => { 100 vi.stubGlobal('fetch', vi.fn().mockRejectedValue('socket hang up')); 101 102 const page = { 103 evaluate: vi.fn(async (js: string) => Function(`return (${js})`)()()), 104 } as unknown as IPage; 105 106 await expect(stepFetch( 107 page, 108 { url: 'https://api.example.com/items/${{ item.id }}' }, 109 [{ id: 1 }], 110 {}, 111 )).resolves.toEqual([ 112 { error: 'socket hang up' }, 113 ]); 114 }); 115 116 it('stringifies non-Error batch non-browser failures consistently', async () => { 117 vi.stubGlobal('fetch', vi.fn().mockRejectedValue('socket hang up')); 118 119 await expect(stepFetch( 120 null, 121 { url: 'https://api.example.com/items/${{ item.id }}' }, 122 [{ id: 1 }], 123 {}, 124 )).resolves.toEqual([ 125 { error: 'socket hang up' }, 126 ]); 127 }); 128 129 // W2: batch item failures emit a warning log 130 it('logs a warning for each failed batch item in non-browser mode', async () => { 131 const { log } = await import('../../logger.js'); 132 const warnSpy = vi.spyOn(log, 'warn'); 133 134 vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ 135 ok: false, 136 status: 503, 137 statusText: 'Service Unavailable', 138 json: vi.fn(), 139 })); 140 141 await stepFetch( 142 null, 143 { url: 'https://api.example.com/items/${{ item.id }}' }, 144 [{ id: 1 }, { id: 2 }], 145 {}, 146 ); 147 148 expect(warnSpy).toHaveBeenCalledTimes(2); 149 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.example.com/items/1')); 150 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.example.com/items/2')); 151 }); 152 153 it('logs a warning for each failed batch item in browser mode', async () => { 154 const { log } = await import('../../logger.js'); 155 const warnSpy = vi.spyOn(log, 'warn'); 156 157 vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ 158 ok: false, 159 status: 502, 160 statusText: 'Bad Gateway', 161 json: vi.fn(), 162 })); 163 164 const page = { 165 evaluate: vi.fn(async (js: string) => Function(`return (${js})`)()()), 166 } as unknown as IPage; 167 168 await stepFetch( 169 page, 170 { url: 'https://api.example.com/items/${{ item.id }}' }, 171 [{ id: 1 }, { id: 2 }], 172 {}, 173 ); 174 175 expect(warnSpy).toHaveBeenCalledTimes(2); 176 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.example.com/items/1')); 177 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.example.com/items/2')); 178 }); 179 });