mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
766 lines
27 KiB
TypeScript
766 lines
27 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { KnownErrors } from "../known-errors";
|
||
import { InternalSession, RefreshToken } from "../sessions";
|
||
import { Result } from "../utils/results";
|
||
import { HexclaveClientInterface } from "./client-interface";
|
||
|
||
function createClientInterface(options?: {
|
||
baseUrl?: string,
|
||
apiUrls?: string[],
|
||
probeRate?: number,
|
||
}) {
|
||
const apiUrls = options?.apiUrls ?? [options?.baseUrl ?? "https://api.example.com"];
|
||
return new HexclaveClientInterface({
|
||
clientVersion: "test",
|
||
getBaseUrl: () => apiUrls[0],
|
||
getApiUrls: () => apiUrls,
|
||
probeRate: options?.probeRate,
|
||
extraRequestHeaders: {},
|
||
projectId: "project-id",
|
||
publishableClientKey: "publishable-client-key",
|
||
});
|
||
}
|
||
|
||
function createSession() {
|
||
return new InternalSession({
|
||
refreshAccessTokenCallback: async () => null,
|
||
refreshToken: null,
|
||
accessToken: null,
|
||
});
|
||
}
|
||
|
||
function createJsonResponse(body: unknown): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status: 200,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
}
|
||
|
||
function createKnownErrorResponse(error: InstanceType<typeof KnownErrors[keyof typeof KnownErrors]>): Response {
|
||
return new Response(JSON.stringify({
|
||
code: error.errorCode,
|
||
message: error.message,
|
||
details: error.details,
|
||
}), {
|
||
status: error.statusCode,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"x-stack-known-error": error.errorCode,
|
||
},
|
||
});
|
||
}
|
||
|
||
function createTextResponse(body: string, options: { status: number, headers?: Record<string, string> }): Response {
|
||
return new Response(body, options);
|
||
}
|
||
|
||
function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
|
||
const requestInit = fetchMock.mock.calls[0]?.[1];
|
||
if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) {
|
||
throw new Error("Expected request init to include a body");
|
||
}
|
||
|
||
const requestBody = requestInit.body;
|
||
if (requestBody == null || typeof requestBody !== "string") {
|
||
throw new Error("Expected request body to be a JSON string");
|
||
}
|
||
|
||
const parsed = JSON.parse(requestBody);
|
||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("Expected parsed request body to be an object");
|
||
}
|
||
|
||
return parsed;
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("HexclaveClientInterface bot challenge compatibility", () => {
|
||
it("omits bot challenge from magic link requests when no token is provided", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback");
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
});
|
||
});
|
||
|
||
it("serializes visible bot challenge retry fields for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
token: " visible-token ",
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_token: "visible-token",
|
||
bot_challenge_phase: "visible",
|
||
});
|
||
});
|
||
|
||
it("serializes bot challenge unavailability for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("serializes explicit bot challenge unavailability for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
unavailable: true,
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected magic link request to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("omits bot challenge from credential signup requests when no token is provided", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({
|
||
access_token: "access-token",
|
||
refresh_token: "refresh-token",
|
||
}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
undefined,
|
||
);
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
password: "password",
|
||
});
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for credential signup requests", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
{
|
||
phase: "visible",
|
||
},
|
||
);
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected credential signup to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("omits bot challenge from OAuth URLs when no token is provided", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(new URL(oauthUrl).searchParams.has("bot_challenge_token")).toBe(false);
|
||
});
|
||
|
||
it("serializes visible bot challenge retry fields in OAuth URLs", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
botChallenge: {
|
||
token: "visible-token",
|
||
phase: "visible",
|
||
},
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
|
||
bot_challenge_token: "visible-token",
|
||
bot_challenge_phase: "visible",
|
||
});
|
||
});
|
||
|
||
it("serializes bot challenge unavailability in OAuth URLs", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
botChallenge: {
|
||
phase: "visible",
|
||
},
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("authorizes OAuth via a JSON response instead of relying on manual redirects", async () => {
|
||
const fetchCalls: [input: RequestInfo | URL, init?: RequestInit][] = [];
|
||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
fetchCalls.push([input, init]);
|
||
return createJsonResponse({
|
||
location: "https://accounts.example.com/oauth/authorize",
|
||
});
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
vi.stubGlobal("window", {} as Window & typeof globalThis);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.authorizeOAuth({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Result.orThrow(result)).toBe("https://accounts.example.com/oauth/authorize");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const [requestUrl, requestInit] = fetchCalls[0] ?? [];
|
||
if (!(typeof requestUrl === "string" || requestUrl instanceof URL)) {
|
||
throw new Error("Expected authorizeOAuth to call fetch with a URL");
|
||
}
|
||
expect(new URL(requestUrl.toString()).searchParams.get("stack_response_mode")).toBe("json");
|
||
expect(requestInit).toMatchObject({
|
||
method: "GET",
|
||
});
|
||
expect(requestInit).not.toHaveProperty("credentials");
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for OAuth authorization", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
vi.stubGlobal("window", {} as Window & typeof globalThis);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.authorizeOAuth({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected OAuth authorization to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("serializes bot challenge unavailability for credential signup requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({
|
||
access_token: "access-token",
|
||
refresh_token: "refresh-token",
|
||
}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
{
|
||
phase: "visible",
|
||
},
|
||
);
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
password: "password",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("_withFallback", () => {
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers — reduce boilerplate across tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Builds a list of N URL bases: ["https://url-0.test", "https://url-1.test", ...] */
|
||
function urlList(n: number): string[] {
|
||
return Array.from({ length: n }, (_, i) => `https://url-${i}.test`);
|
||
}
|
||
|
||
/** Returns the index of the URL base that `fullUrl` starts with, or -1. */
|
||
function urlIndex(urls: string[], fullUrl: string): number {
|
||
return urls.findIndex(base => fullUrl.startsWith(base));
|
||
}
|
||
|
||
/** Records every fetch URL and calls `handler` to decide the outcome. */
|
||
function mockFetch(handler: (url: string) => "ok" | "fail") {
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input.toString();
|
||
log.push(url);
|
||
if (handler(url) === "fail") throw new TypeError("Failed to fetch");
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
return log;
|
||
}
|
||
|
||
function sendRequest(iface: HexclaveClientInterface) {
|
||
const session = iface.createSession({ refreshToken: null, accessToken: null });
|
||
return iface.sendClientRequest("/users/me", { method: "GET" }, session);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Single URL — no fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("single URL uses standard 5-retry behavior", async () => {
|
||
let attempts = 0;
|
||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||
attempts++;
|
||
if (attempts < 3) throw new TypeError("Failed to fetch");
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await sendRequest(iface);
|
||
expect(attempts).toBe(3);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Normal mode — iterating through URLs in order
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("uses primary when it is healthy", async () => {
|
||
const urls = urlList(3);
|
||
const log = mockFetch(() => "ok");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
|
||
expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true);
|
||
});
|
||
|
||
it("tries URLs in order and succeeds on first working one", async () => {
|
||
const urls = urlList(4);
|
||
// url-0 and url-1 are down, url-2 is up
|
||
const log = mockFetch((u) => urlIndex(urls, u) < 2 ? "fail" : "ok");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
expect(urlIndex(urls, log[1])).toBe(1);
|
||
expect(urlIndex(urls, log[2])).toBe(2);
|
||
expect(log.length).toBe(3);
|
||
});
|
||
|
||
it("does not fall back on KnownError", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createKnownErrorResponse(new KnownErrors.UserNotFound());
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true);
|
||
});
|
||
|
||
it("does not retry or fall back on non-KnownError 4xx responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createTextResponse("Payments are not set up", { status: 402 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
});
|
||
|
||
it("wraps non-KnownError 4xx responses as normal errors", async () => {
|
||
const response = createTextResponse("Payments are not set up", { status: 402 });
|
||
vi.stubGlobal("fetch", vi.fn(async () => response));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await expect(sendRequest(iface)).rejects.toMatchObject({
|
||
name: "Error",
|
||
message: expect.stringContaining("402 Payments are not set up"),
|
||
cause: response,
|
||
});
|
||
});
|
||
|
||
it("does not retry non-KnownError 5xx responses on a single URL", async () => {
|
||
let attempts = 0;
|
||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||
attempts++;
|
||
return createTextResponse("Server unavailable", { status: 503 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await expect(sendRequest(iface)).rejects.toThrow("503 Server unavailable");
|
||
expect(attempts).toBe(1);
|
||
});
|
||
|
||
it("falls back on non-KnownError 5xx responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input.toString();
|
||
log.push(url);
|
||
if (urlIndex(urls, url) === 0) {
|
||
return createTextResponse("Server unavailable", { status: 503 });
|
||
}
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
expect(log.length).toBe(2);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
expect(urlIndex(urls, log[1])).toBe(1);
|
||
});
|
||
|
||
it("does not fall back on wrapped non-KnownError 4xx refresh token responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input instanceof Request ? input.url : input.toString();
|
||
log.push(url);
|
||
return createTextResponse("Payments are not set up", { status: 402 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(iface.fetchNewAccessToken(new RefreshToken("refresh-token"))).rejects.toThrow("Payments are not set up");
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
});
|
||
|
||
it("makes 2 passes × N URLs attempts before throwing", async () => {
|
||
for (const n of [2, 3, 5]) {
|
||
const urls = urlList(n);
|
||
const log = mockFetch(() => "fail");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
|
||
expect(log.length).toBe(2 * n);
|
||
for (let i = 0; i < n; i++) {
|
||
expect(log.filter(u => urlIndex(urls, u) === i).length).toBe(2);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("bypasses fallback when apiUrlOverride is provided", async () => {
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(3) });
|
||
const session = iface.createSession({ refreshToken: null, accessToken: null });
|
||
await iface.sendClientRequest("/users/me", { method: "GET" }, session, "client", "https://override.test/api/v1");
|
||
|
||
expect(log.every(u => u.startsWith("https://override.test"))).toBe(true);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sticky mode — remembering the working fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("enters sticky mode: subsequent requests skip straight to the working fallback", async () => {
|
||
const urls = urlList(4);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// url-0,1,2 down → sticky on url-3
|
||
mockFetch((u) => urlIndex(urls, u) === 3 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Next request goes directly to url-3 (probeRate=0 means no probe)
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(3);
|
||
});
|
||
|
||
it("probes primary and exits sticky mode when probe succeeds", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// url-0 down → sticky on url-1
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// url-0 recovers. probeRate=1 → always probes → probe succeeds → exits sticky
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
|
||
// Next request should go to url-0 directly (no longer sticky)
|
||
const log2 = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
expect(log2.length).toBe(1);
|
||
expect(urlIndex(urls, log2[0])).toBe(0);
|
||
});
|
||
|
||
it("halves probe rate on each failed probe", async () => {
|
||
const urls = urlList(2);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// Enter sticky on url-1, url-0 stays permanently down
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// probeRate=1 → probes url-0, fails → rate becomes 0.5
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// probeRate=0.5 → probes (random < 0.5), fails → rate becomes 0.25
|
||
const randomMock = vi.spyOn(Math, "random").mockReturnValue(0.4);
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// rate=0.25, random=0.3 ≥ 0.25 → should NOT probe primary
|
||
randomMock.mockReturnValue(0.3);
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(1);
|
||
|
||
randomMock.mockRestore();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sticky URL goes down — falls through to full iteration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("falls through to full iteration when sticky URL also goes down", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// url-0,1 down → sticky on url-2
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Now url-2 also down, url-1 recovers
|
||
const log = mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Should have tried sticky url-2 (failed), then iterated 0→1 (found url-1)
|
||
expect(log.some(u => urlIndex(urls, u) === 2)).toBe(true);
|
||
expect(log.some(u => urlIndex(urls, u) === 1)).toBe(true);
|
||
});
|
||
|
||
it("re-enters sticky on the new working URL after fallthrough", async () => {
|
||
const urls = urlList(4);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// sticky on url-3
|
||
mockFetch((u) => urlIndex(urls, u) === 3 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// url-3 dies, url-2 recovers → should re-sticky on url-2
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Next request goes directly to url-2
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(2);
|
||
});
|
||
|
||
it("throws when sticky URL fails and all URLs fail in iteration", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// sticky on url-1
|
||
mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Everything is now down
|
||
const log = mockFetch(() => "fail");
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
|
||
// sticky attempt (1) + 2 passes × 3 URLs (6) = 7
|
||
expect(log.length).toBe(7);
|
||
});
|
||
|
||
it("does not probe primary when sticky URL fails (probe only before sticky attempt)", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// sticky on url-2, url-0 stays down
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// url-0 still down, url-2 also dies, url-1 is the only one up
|
||
// probeRate=1 → probes url-0 first (fails), then tries sticky url-2 (fails),
|
||
// then full iteration finds url-1
|
||
const log = mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
const hitOrder = log.map(u => urlIndex(urls, u));
|
||
// probe url-0, sticky url-2, then iteration: 0, 1 (succeeds)
|
||
expect(hitOrder[0]).toBe(0); // probe
|
||
expect(hitOrder[1]).toBe(2); // sticky attempt
|
||
expect(hitOrder).toContain(1); // found during iteration
|
||
});
|
||
});
|
||
|
||
describe("sendAnalyticsEventBatch encoding", () => {
|
||
function captureFetch() {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ inserted: 0 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
return fetchMock;
|
||
}
|
||
|
||
function getRequestInit(fetchMock: { mock: { calls: unknown[][] } }): RequestInit {
|
||
const init = fetchMock.mock.calls[0]?.[1];
|
||
if (init == null || typeof init !== "object") throw new Error("expected RequestInit");
|
||
return init as RequestInit;
|
||
}
|
||
|
||
async function gunzipToText(body: unknown): Promise<string> {
|
||
if (!(body instanceof Uint8Array)) throw new Error("expected Uint8Array body");
|
||
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||
return await new Response(stream).text();
|
||
}
|
||
|
||
it("gzips body and sends application/octet-stream when keepalive is false", async () => {
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [{ event_type: "$click" }] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
const contentType = new Headers(init.headers).get("Content-Type");
|
||
expect(contentType).toBe("application/octet-stream");
|
||
expect(init.body).toBeInstanceOf(Uint8Array);
|
||
await expect(gunzipToText(init.body)).resolves.toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when keepalive is true (avoids racing pagehide tear-down)", async () => {
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: true });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when CompressionStream is unavailable", async () => {
|
||
vi.stubGlobal("CompressionStream", undefined);
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when CompressionStream throws at runtime", async () => {
|
||
class ThrowingCompressionStream {
|
||
constructor() { throw new Error("compression unsupported"); }
|
||
}
|
||
vi.stubGlobal("CompressionStream", ThrowingCompressionStream);
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
});
|