/ src / pipeline / steps / fetch.test.ts
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  });