415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
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<string, string> = {},
|
|
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<typeof vi.spyOn>;
|
|
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<typeof vi.spyOn>;
|
|
|
|
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<typeof vi.spyOn>;
|
|
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<typeof setTimeout>;
|
|
}
|
|
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([]);
|
|
});
|
|
});
|