import { Readable } from 'stream'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ERROR_CODES } from '@insforge/shared-schemas'; import { VercelProvider } from '../../src/providers/deployments/vercel.provider'; import axios, { AxiosError, AxiosHeaders } from 'axios'; const VERCEL_UPLOAD_TIMEOUT_MS = 120_000; // Mock dependencies so we don't hit real APIs vi.mock('../../src/utils/environment.js', () => ({ isCloudEnvironment: () => false, })); vi.mock('../../src/services/secrets/secret.service.js', () => ({ SecretService: { getInstance: () => ({}), }, })); /** * Helper to create a mock Axios error with a given status code and optional headers */ function makeAxiosError( status: number, headers: Record = {}, data: unknown = {} ): AxiosError { const error = new Error(`Request failed with status code ${status}`) as AxiosError; error.isAxiosError = true; error.response = { status, statusText: status === 429 ? 'Too Many Requests' : 'Request Failed', headers, data, config: { headers: new AxiosHeaders() }, }; error.config = { headers: new AxiosHeaders() }; return error; } describe('VercelProvider.uploadFiles batching', () => { let provider: VercelProvider; let uploadFileSpy: ReturnType; let concurrentCount: number; let peakConcurrent: number; beforeEach(() => { // Reset singleton for clean tests // @ts-expect-error accessing private static for test reset VercelProvider.instance = undefined; provider = VercelProvider.getInstance(); concurrentCount = 0; peakConcurrent = 0; // Mock uploadFile to track concurrency uploadFileSpy = vi.spyOn(provider, 'uploadFile').mockImplementation(async (content: Buffer) => { concurrentCount++; if (concurrentCount > peakConcurrent) { peakConcurrent = concurrentCount; } // Simulate network delay so concurrent calls overlap await new Promise((r) => setTimeout(r, 10)); concurrentCount--; return `sha-${content.length}`; }); }); it('uploads all files and returns correct results', async () => { const files = Array.from({ length: 25 }, (_, i) => ({ path: `file-${i}.txt`, content: Buffer.from(`content-${i}`), })); const results = await provider.uploadFiles(files); expect(results).toHaveLength(25); expect(uploadFileSpy).toHaveBeenCalledTimes(25); results.forEach((r, i) => { expect(r.file).toBe(`file-${i}.txt`); expect(r.sha).toMatch(/^sha-/); expect(r.size).toBeGreaterThan(0); }); }); it('limits concurrency to 5 at a time', async () => { const files = Array.from({ length: 25 }, (_, i) => ({ path: `file-${i}.txt`, content: Buffer.from(`content-${i}`), })); await provider.uploadFiles(files); expect(peakConcurrent).toBeLessThanOrEqual(5); expect(peakConcurrent).toBeGreaterThan(1); }); it('handles fewer files than batch size', async () => { const files = Array.from({ length: 3 }, (_, i) => ({ path: `file-${i}.txt`, content: Buffer.from(`content-${i}`), })); const results = await provider.uploadFiles(files); expect(results).toHaveLength(3); expect(uploadFileSpy).toHaveBeenCalledTimes(3); expect(peakConcurrent).toBeLessThanOrEqual(3); }); it('handles empty file list', async () => { const results = await provider.uploadFiles([]); expect(results).toHaveLength(0); expect(uploadFileSpy).not.toHaveBeenCalled(); }); it('handles exactly one batch (5 files)', async () => { const files = Array.from({ length: 5 }, (_, i) => ({ path: `file-${i}.txt`, content: Buffer.from(`content-${i}`), })); const results = await provider.uploadFiles(files); expect(results).toHaveLength(5); expect(peakConcurrent).toBeLessThanOrEqual(5); }); it('propagates upload errors without swallowing them', async () => { uploadFileSpy.mockRejectedValueOnce(new Error('rate limited')); const files = [{ path: 'fail.txt', content: Buffer.from('fail') }]; await expect(provider.uploadFiles(files)).rejects.toThrow('rate limited'); }); }); describe('VercelProvider.uploadFileStream', () => { let provider: VercelProvider; let axiosPostSpy: ReturnType; beforeEach(() => { // Reset singleton for clean tests // @ts-expect-error accessing private static for test reset VercelProvider.instance = undefined; provider = VercelProvider.getInstance(); vi.spyOn(provider, 'getCredentials').mockResolvedValue({ token: 'test-token', teamId: 'test-team', projectId: 'test-project', expiresAt: null, slug: null, }); axiosPostSpy = vi.spyOn(axios, 'post'); }); afterEach(() => { vi.restoreAllMocks(); }); it('streams file content with Vercel digest headers', async () => { axiosPostSpy.mockResolvedValueOnce({ status: 200, data: {} }); const content = Readable.from([Buffer.from('hello')]); const sha = 'a'.repeat(40); const abortController = new AbortController(); const result = await provider.uploadFileStream({ content, sha, size: 5, signal: abortController.signal, }); expect(result).toBe(sha); expect(axiosPostSpy).toHaveBeenCalledWith( 'https://api.vercel.com/v2/files?teamId=test-team', content, expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token', 'Content-Type': 'application/octet-stream', 'Content-Length': '5', 'x-vercel-digest': sha, }), timeout: VERCEL_UPLOAD_TIMEOUT_MS, maxBodyLength: Infinity, maxContentLength: Infinity, signal: expect.any(AbortSignal), }) ); }); it('uses the upload timeout constant and aborts timed out uploads', async () => { const timeoutError = new AxiosError( `timeout of ${VERCEL_UPLOAD_TIMEOUT_MS}ms exceeded`, 'ECONNABORTED' ); timeoutError.config = { headers: new AxiosHeaders() }; axiosPostSpy.mockRejectedValueOnce(timeoutError); const content = Readable.from([Buffer.from('hello')]); await expect( provider.uploadFileStream({ content, sha: 'e'.repeat(40), size: 5, }) ).rejects.toMatchObject({ statusCode: 504, code: ERROR_CODES.UPSTREAM_FAILURE, message: `Vercel file upload timed out after ${VERCEL_UPLOAD_TIMEOUT_MS}ms. Retry the file upload.`, }); const requestConfig = axiosPostSpy.mock.calls[0]?.[2] as | { timeout?: number; signal?: AbortSignal } | undefined; expect(requestConfig?.timeout).toBe(VERCEL_UPLOAD_TIMEOUT_MS); expect(requestConfig?.signal?.aborted).toBe(true); }); it('treats existing streamed files as success', async () => { axiosPostSpy.mockRejectedValueOnce(makeAxiosError(409)); const sha = 'b'.repeat(40); const result = await provider.uploadFileStream({ content: Readable.from([Buffer.from('hello')]), sha, size: 5, }); expect(result).toBe(sha); expect(axiosPostSpy).toHaveBeenCalledTimes(1); }); it('does not retry rate-limited streamed uploads', async () => { axiosPostSpy.mockRejectedValueOnce(makeAxiosError(429)); await expect( provider.uploadFileStream({ content: Readable.from([Buffer.from('hello')]), sha: 'c'.repeat(40), size: 5, }) ).rejects.toMatchObject({ statusCode: 429, code: ERROR_CODES.RATE_LIMITED, }); expect(axiosPostSpy).toHaveBeenCalledTimes(1); }); it('surfaces Vercel 400 responses as upstream failures', async () => { axiosPostSpy.mockRejectedValueOnce(makeAxiosError(400, {}, 'digest does not match content')); await expect( provider.uploadFileStream({ content: Readable.from([Buffer.from('hello')]), sha: 'd'.repeat(40), size: 5, }) ).rejects.toMatchObject({ statusCode: 400, code: ERROR_CODES.UPSTREAM_FAILURE, message: 'digest does not match content', }); }); it('maps caller-cancelled streamed uploads to a deployment upload cancellation code', async () => { const cancelledError = new AxiosError('canceled', 'ERR_CANCELED'); cancelledError.config = { headers: new AxiosHeaders() }; axiosPostSpy.mockRejectedValueOnce(cancelledError); await expect( provider.uploadFileStream({ content: Readable.from([Buffer.from('hello')]), sha: 'f'.repeat(40), size: 5, }) ).rejects.toMatchObject({ statusCode: 499, code: ERROR_CODES.DEPLOYMENT_UPLOAD_CANCELED, }); }); }); describe('VercelProvider.uploadFile retry logic', () => { let provider: VercelProvider; let axiosPostSpy: ReturnType; const setTimeoutCalls: number[] = []; beforeEach(() => { // Deterministic jitter: Math.random always returns 0 vi.spyOn(Math, 'random').mockReturnValue(0); // @ts-expect-error accessing private static for test reset VercelProvider.instance = undefined; provider = VercelProvider.getInstance(); // Mock getCredentials to avoid real API calls // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(provider as any, 'getCredentials').mockResolvedValue({ token: 'test-token', teamId: 'test-team', projectId: 'test-project', expiresAt: null, slug: null, }); axiosPostSpy = vi.spyOn(axios, 'post'); // Intercept setTimeout: record retry delays and resolve immediately. // Pass through short timeouts (vitest internals) to the real implementation. setTimeoutCalls.length = 0; const realSetTimeout = globalThis.setTimeout.bind(globalThis); vi.spyOn(globalThis, 'setTimeout').mockImplementation((( fn: (...args: unknown[]) => void, ms?: number ) => { if (ms !== undefined && ms >= 1000) { setTimeoutCalls.push(ms); // Resolve on next microtask to keep async flow correct Promise.resolve().then(() => fn()); return 0 as unknown as ReturnType; } return realSetTimeout(fn, ms); }) as typeof setTimeout); }); afterEach(() => { vi.restoreAllMocks(); }); it('retries on 429 with exponential backoff', async () => { axiosPostSpy .mockRejectedValueOnce(makeAxiosError(429)) .mockRejectedValueOnce(makeAxiosError(429)) .mockResolvedValueOnce({ status: 200, data: {} }); const sha = await provider.uploadFile(Buffer.from('test-content')); expect(sha).toBeTruthy(); expect(axiosPostSpy).toHaveBeenCalledTimes(3); // With Math.random()=0, jitter is 0. Backoff: attempt 0 = 1000ms, attempt 1 = 2000ms expect(setTimeoutCalls).toEqual([1000, 2000]); }); it('respects X-RateLimit-Reset header on 429', async () => { // Vercel sends X-RateLimit-Reset as Unix epoch seconds const nowSec = Math.floor(Date.now() / 1000); const resetEpoch = nowSec + 5; // 5 seconds from now axiosPostSpy .mockRejectedValueOnce(makeAxiosError(429, { 'x-ratelimit-reset': String(resetEpoch) })) .mockResolvedValueOnce({ status: 200, data: {} }); const sha = await provider.uploadFile(Buffer.from('test-content')); expect(sha).toBeTruthy(); expect(axiosPostSpy).toHaveBeenCalledTimes(2); // Delay should be based on reset epoch, not exponential backoff // resetMs - Date.now() ≈ 5000ms (with jitter=0) expect(setTimeoutCalls.length).toBe(1); expect(setTimeoutCalls[0]).toBeGreaterThanOrEqual(4000); expect(setTimeoutCalls[0]).toBeLessThanOrEqual(5500); }); it('throws RATE_LIMITED after max retries exhausted', async () => { axiosPostSpy.mockRejectedValue(makeAxiosError(429)); await expect(provider.uploadFile(Buffer.from('test-content'))).rejects.toMatchObject({ message: expect.stringContaining('rate limit'), statusCode: 429, code: ERROR_CODES.RATE_LIMITED, }); expect(axiosPostSpy).toHaveBeenCalledTimes(4); // 3 retries with backoff: 1000, 2000, 4000 expect(setTimeoutCalls).toEqual([1000, 2000, 4000]); }); it('does not retry on non-429 errors', async () => { axiosPostSpy.mockRejectedValueOnce(makeAxiosError(500)); await expect(provider.uploadFile(Buffer.from('test-content'))).rejects.toMatchObject({ statusCode: 500, code: ERROR_CODES.UPSTREAM_FAILURE, }); expect(axiosPostSpy).toHaveBeenCalledTimes(1); expect(setTimeoutCalls).toEqual([]); }); it('still handles 409 (file exists) without retrying', async () => { axiosPostSpy.mockRejectedValueOnce(makeAxiosError(409)); const sha = await provider.uploadFile(Buffer.from('test-content')); expect(sha).toBeTruthy(); expect(axiosPostSpy).toHaveBeenCalledTimes(1); expect(setTimeoutCalls).toEqual([]); }); it('succeeds on first attempt without retry delay', async () => { axiosPostSpy.mockResolvedValueOnce({ status: 200, data: {} }); const sha = await provider.uploadFile(Buffer.from('test-content')); expect(sha).toBeTruthy(); expect(axiosPostSpy).toHaveBeenCalledTimes(1); expect(setTimeoutCalls).toEqual([]); }); });