diff --git a/packages/cli/src/__tests__/digitalocean-token.test.ts b/packages/cli/src/__tests__/digitalocean-token.test.ts index e0d329129..e671b78d2 100644 --- a/packages/cli/src/__tests__/digitalocean-token.test.ts +++ b/packages/cli/src/__tests__/digitalocean-token.test.ts @@ -88,34 +88,33 @@ describe("doApi 401 OAuth recovery", () => { it("attempts OAuth recovery on 401 before throwing", async () => { state.token = "expired-token"; - let callCount = 0; + let apiCalls = 0; + let oauthChecks = 0; globalThis.fetch = mock((url: string | URL | Request) => { - callCount++; const urlStr = String(url); - // First call: the actual API call returning 401 - if (callCount === 1) { + // OAuth connectivity check — fail it so tryDoOAuth returns null quickly + if (urlStr.includes("cloud.digitalocean.com")) { + oauthChecks++; + return Promise.reject(new Error("network unavailable")); + } + // DigitalOcean API calls — always return 401 + if (urlStr.includes("api.digitalocean.com")) { + apiCalls++; return Promise.resolve( new Response("Unauthorized", { status: 401, }), ); } - // Second call: OAuth connectivity check — fail it so tryDoOAuth returns null quickly - // (avoids starting a real Bun.serve OAuth server) - if (urlStr.includes("cloud.digitalocean.com")) { - return Promise.reject(new Error("network unavailable")); - } - return Promise.resolve( - new Response("Unauthorized", { - status: 401, - }), - ); + // Unrelated fetch calls from concurrent tests — return empty 200 + return Promise.resolve(new Response("")); }); // OAuth recovery fails (connectivity check fails), so doApi throws the 401 await expect(doApi("GET", "/account", undefined, 1)).rejects.toThrow("DigitalOcean API error 401"); - // Verify recovery was attempted: 1 API call + 1 connectivity check = 2 - expect(callCount).toBe(2); + // Verify: 1 API call triggered recovery, 1 OAuth connectivity check was attempted + expect(apiCalls).toBe(1); + expect(oauthChecks).toBe(1); }); it("succeeds after OAuth recovery provides a new token", async () => { diff --git a/packages/cli/src/__tests__/hetzner-cov.test.ts b/packages/cli/src/__tests__/hetzner-cov.test.ts index ed1c050f1..5076385e8 100644 --- a/packages/cli/src/__tests__/hetzner-cov.test.ts +++ b/packages/cli/src/__tests__/hetzner-cov.test.ts @@ -585,83 +585,93 @@ describe("hetzner/createServer", () => { }, }, }; - let callCount = 0; - global.fetch = mock(() => { - callCount++; - if (callCount <= 1) { - // Token validation - return Promise.resolve( - new Response( - JSON.stringify({ - servers: [], - }), - ), - ); - } - if (callCount <= 2) { - // SSH keys - return Promise.resolve( - new Response( - JSON.stringify({ - ssh_keys: [], - }), - ), - ); - } - if (callCount <= 3) { - // First create attempt — resource_limit_exceeded (HTTP 403) - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - code: "resource_limit_exceeded", - message: "primary_ip_limit", - }, - }), - { - status: 403, - }, - ), - ); - } - if (callCount <= 4) { - // List primary IPs for cleanup - return Promise.resolve( - new Response( - JSON.stringify({ - primary_ips: [ - { - id: 100, - ip: "1.2.3.4", - assignee_id: 0, - }, + let createAttempts = 0; + global.fetch = mock((url: string | URL | Request, opts?: RequestInit) => { + const urlStr = String(url); + const method = (opts?.method ?? "GET").toUpperCase(); + + // Hetzner API calls — route by URL path and method + if (urlStr.includes("api.hetzner.cloud")) { + if (urlStr.includes("/servers") && method === "GET") { + // Token validation (GET /servers?per_page=1) + return Promise.resolve( + new Response( + JSON.stringify({ + servers: [], + }), + ), + ); + } + if (urlStr.includes("/ssh_keys")) { + // SSH keys listing + return Promise.resolve( + new Response( + JSON.stringify({ + ssh_keys: [], + }), + ), + ); + } + if (urlStr.includes("/servers") && method === "POST") { + createAttempts++; + if (createAttempts <= 1) { + // First create attempt — resource_limit_exceeded (HTTP 403) + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "resource_limit_exceeded", + message: "primary_ip_limit", + }, + }), { - id: 200, - ip: "5.6.7.8", - assignee_id: 42, + status: 403, }, - ], + ), + ); + } + // Retry create — success + return Promise.resolve(new Response(JSON.stringify(serverResp))); + } + if (urlStr.includes("/primary_ips") && method === "GET") { + // List primary IPs for cleanup + return Promise.resolve( + new Response( + JSON.stringify({ + primary_ips: [ + { + id: 100, + ip: "1.2.3.4", + assignee_id: 0, + }, + { + id: 200, + ip: "5.6.7.8", + assignee_id: 42, + }, + ], + }), + ), + ); + } + if (urlStr.includes("/primary_ips") && method === "DELETE") { + // Delete orphaned IP + return Promise.resolve( + new Response("", { + status: 204, }), - ), - ); - } - if (callCount <= 5) { - // Delete orphaned IP 100 - return Promise.resolve( - new Response("", { - status: 204, - }), - ); + ); + } } - // Retry create — success - return Promise.resolve(new Response(JSON.stringify(serverResp))); + // Unrelated fetch calls from concurrent tests — return empty 200 + return Promise.resolve(new Response("")); }); const { ensureHcloudToken, createServer } = await import("../hetzner/hetzner"); await ensureHcloudToken(); const conn = await createServer("test-retry", "cx23", "fsn1"); expect(conn.ip).toBe("10.0.0.5"); - // Should have called: token(1), ssh_keys(2), create-fail(3), list-ips(4), delete-ip(5), create-ok(6) - expect(callCount).toBeGreaterThanOrEqual(6); + // Should have created twice: first failed with resource_limit, second succeeded + expect(createAttempts).toBe(2); }); it("throws with guidance when resource limit hit and no orphaned IPs to clean", async () => {