SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
6.5 KB · 165 lines typescript
Raw Blame History
1import { createServer, type Server } from "node:http";2import { afterAll, beforeAll, describe, expect, it } from "vitest";3import { DecodoProvider, DirectProvider, OxylabsProvider, ProviderError, ProviderRegistry, SoaxProvider, type ProviderRequest, type ProxyProvider } from "../src/index";45const env = {6  OXYLABS_USERNAME: "customer-acme",7  OXYLABS_PASSWORD: "pw1",8  DECODO_USERNAME: "acme",9  DECODO_PASSWORD: "pw2",10  SOAX_USERNAME: "package-123",11  SOAX_PASSWORD: "pw3",12  FETCHA_DIRECT_EGRESS: "1",13} as NodeJS.ProcessEnv;1415const baseReq = (url: string, extra: Partial<ProviderRequest> = {}): ProviderRequest => ({16  requestId: "req_test",17  attemptId: "att_test",18  url,19  method: "GET",20  headers: {},21  timeoutMs: 5000,22  network: "residential",23  geo: { country: null, region: null, city: null },24  followRedirects: true,25  maxRedirects: 5,26  maxResponseBytes: 1_000_000,27  ...extra,28});2930/** Contract every adapter must satisfy. */31function contract(name: string, make: () => ProxyProvider) {32  describe(`${name} contract`, () => {33    const p = make();34    it("is configured when credentials are present", () => expect(p.isConfigured()).toBe(true));35    it("declares at least one network", () => expect(p.networks.length).toBeGreaterThan(0));36    it("prices bandwidth", () => {37      expect(p.pricePerGb(p.networks[0]!)).toBeGreaterThan(0);38      expect(p.estimateCost(p.networks[0]!, 1_073_741_824)).toBeCloseTo(p.pricePerGb(p.networks[0]!));39      expect(p.estimateCost(p.networks[0]!, 0)).toBe(0);40    });41    it("supports worldwide geo", () => expect(p.supportsGeo({ country: "CA", region: null, city: null })).toBe(p.id !== "direct"));42  });43}4445contract("Oxylabs", () => new OxylabsProvider(env));46contract("Decodo", () => new DecodoProvider(env));47contract("SOAX", () => new SoaxProvider(env));48contract("Direct", () => new DirectProvider(env));4950describe("username grammar (geo + sessions)", () => {51  it("oxylabs", () => {52    const p = new OxylabsProvider(env);53    expect(p.endpoint({ geo: { country: "CA", region: "quebec", city: "montreal" }, sessionKey: "abc-123", sessionMinutes: 10 })).toEqual({54      host: "pr.oxylabs.io",55      port: 7777,56      username: "customer-acme-cc-CA-st-ca_quebec-city-montreal-sessid-abc123-sesstime-10",57      password: "pw1",58    });59    expect(p.endpoint({ geo: { country: null, region: null, city: null } }).username).toBe("customer-acme");60  });61  it("decodo", () => {62    const p = new DecodoProvider(env);63    expect(p.endpoint({ geo: { country: "US", region: "new_york", city: null }, sessionKey: "s1", sessionMinutes: 5 })).toEqual({64      host: "gate.decodo.com",65      port: 7000,66      username: "user-acme-country-us-state-us_new_york-session-s1-sessionduration-5",67      password: "pw2",68    });69  });70  it("soax", () => {71    const p = new SoaxProvider(env);72    expect(p.endpoint({ geo: { country: "GB", region: null, city: "london" }, sessionKey: "k", sessionMinutes: 2 }).username).toBe("package-123-country-gb-city-london-sessionid-k-sessionlength-120");73  });74});7576describe("registry", () => {77  it("excludes unconfigured and disabled providers", () => {78    const r = new ProviderRegistry({ env: { OXYLABS_USERNAME: "a", OXYLABS_PASSWORD: "b" } as NodeJS.ProcessEnv });79    expect(r.available().map((p) => p.id)).toEqual(["oxylabs"]);80    expect(r.available("mobile")).toHaveLength(0);81    r.setDisabled("oxylabs", true);82    expect(r.available()).toHaveLength(0);83  });84});8586describe("direct HTTP execution (local server)", () => {87  let server: Server;88  let base = "";89  beforeAll(async () => {90    server = createServer((req, res) => {91      if (req.url === "/redirect") {92        res.writeHead(302, { location: "/final" });93        return res.end();94      }95      if (req.url === "/final") {96        res.writeHead(200, { "content-type": "text/html", "set-cookie": "a=1; Path=/" });97        return res.end("<html><body>final</body></html>");98      }99      if (req.url === "/big") {100        res.writeHead(200, { "content-type": "application/octet-stream" });101        return res.end(Buffer.alloc(50_000, 1));102      }103      if (req.url === "/slow") {104        return setTimeout(() => {105          res.writeHead(200);106          res.end("late");107        }, 2000);108      }109      if (req.url === "/echo") {110        let body = "";111        req.on("data", (c) => (body += c));112        return req.on("end", () => {113          res.writeHead(200, { "content-type": "application/json" });114          res.end(JSON.stringify({ method: req.method, body, ua: req.headers["user-agent"], x: req.headers["x-test"] }));115        });116      }117      res.writeHead(404);118      res.end("nope");119    });120    await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));121    const addr = server.address();122    base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;123  });124  afterAll(() => new Promise<void>((r) => server.close(() => r())));125126  const p = new DirectProvider(env);127128  it("follows redirects and validates each hop via onRedirect", async () => {129    const hops: string[] = [];130    const res = await p.fetch(baseReq(`${base}/redirect`, { onRedirect: async (u) => void hops.push(u) }));131    expect(res.status).toBe(200);132    expect(res.redirects).toBe(1);133    expect(hops[0]).toBe(`${base}/final`);134    expect(res.finalUrl).toBe(`${base}/final`);135    expect(res.headers["set-cookie"]).toContain("a=1");136    expect(res.bytesIn).toBeGreaterThan(30);137  });138139  it("aborts redirect chains when the callback throws", async () => {140    await expect(p.fetch(baseReq(`${base}/redirect`, { onRedirect: async () => { throw new Error("blocked"); } }))).rejects.toThrow("blocked");141  });142143  it("enforces the response size cap", async () => {144    await expect(p.fetch(baseReq(`${base}/big`, { maxResponseBytes: 10_000 }))).rejects.toMatchObject({ kind: "too_large" });145  });146147  it("times out", async () => {148    await expect(p.fetch(baseReq(`${base}/slow`, { timeoutMs: 300 }))).rejects.toBeInstanceOf(ProviderError);149  });150151  it("performs POST with headers and body", async () => {152    const res = await p.fetch(baseReq(`${base}/echo`, { method: "POST", body: '{"a":1}', headers: { "x-test": "yes", host: "evil" } }));153    const j = JSON.parse(res.body.toString()) as { method: string; body: string; x: string };154    expect(j.method).toBe("POST");155    expect(j.body).toBe('{"a":1}');156    expect(j.x).toBe("yes");157  });158159  it("records timing", async () => {160    const res = await p.fetch(baseReq(`${base}/final`));161    expect(res.timing.total_ms).toBeGreaterThanOrEqual(0);162    expect(res.timing.origin_ms).toBeGreaterThanOrEqual(0);163  });164});165