Files
2026-07-13 13:39:12 +08:00

188 lines
8.2 KiB
TypeScript

import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import {
__setHttpBackedChatOverrideForTesting,
__resetHttpBackedChatOverrideForTesting,
__setBrowserBackedChatOverrideForTesting,
__resetBrowserBackedChatOverrideForTesting,
tryBackedChat,
} from "../../open-sse/services/browserBackedChat.ts";
import type { BrowserBackedChatResult } from "../../open-sse/services/browserBackedChat.ts";
// ─── Helpers ────────────────────────────────────────────────────────────────
const OK_RESPONSE: BrowserBackedChatResult = {
status: 200,
contentType: "text/event-stream",
body: Buffer.from("data: hello\n"),
isStealth: true,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 },
};
const CHALLENGE_RESPONSE: BrowserBackedChatResult = {
status: 403,
contentType: "application/json",
body: Buffer.from(JSON.stringify({ error: "challenge" })),
isStealth: true,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 },
};
const FAILURE_RESPONSE: BrowserBackedChatResult = {
status: 502,
contentType: "application/json",
body: Buffer.from(JSON.stringify({ error: "upstream error" })),
isStealth: true,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 },
};
const BASE_REQ = {
poolKey: "test-provider",
chatUrl: "https://example.com/chat",
chatPageUrl: "https://example.com/",
userMessage: "hello",
chatUrlMatchDomain: "example.com",
cookieDomain: "example.com",
inputSelector: "textarea",
};
describe("tryBackedChat", () => {
after(() => {
__resetHttpBackedChatOverrideForTesting();
__resetBrowserBackedChatOverrideForTesting();
});
// --------------------------------------------------------------------------
// 1. Fast path — httpBackedChat returns 2xx immediately
// --------------------------------------------------------------------------
it("returns httpBackedChat result when status is 2xx", async () => {
__setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE));
__setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called")));
const result = await tryBackedChat({ ...BASE_REQ });
assert.equal(result.status, 200);
assert.equal(result.body.toString(), "data: hello\n");
});
// --------------------------------------------------------------------------
// 2. Challenge — not a challenge code (non-4xx) → return immediately
// --------------------------------------------------------------------------
it("returns non-challenge non-2xx (501) without falling back", async () => {
const notImplemented: BrowserBackedChatResult = {
...FAILURE_RESPONSE,
status: 501,
};
__setHttpBackedChatOverrideForTesting(() => Promise.resolve(notImplemented));
__setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called")));
const result = await tryBackedChat({ ...BASE_REQ });
assert.equal(result.status, 501);
});
// --------------------------------------------------------------------------
// 3. Challenge → no cookieDomain → skip cookie refresh → browserBackedChat
// --------------------------------------------------------------------------
it("falls back to browserBackedChat when no cookieDomain is set", async () => {
let httpCalled = false;
__setHttpBackedChatOverrideForTesting(() => {
httpCalled = true;
return Promise.resolve(CHALLENGE_RESPONSE);
});
__setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE));
const result = await tryBackedChat({ ...BASE_REQ, cookieDomain: undefined });
assert.equal(result.status, 200);
assert.equal(httpCalled, true, "httpBackedChat must be called first");
});
// --------------------------------------------------------------------------
// 4. Challenge → cookie refresh succeeds → retry succeeds
// --------------------------------------------------------------------------
it("retries httpBackedChat with fresh cookies after browser refresh", async () => {
let callCount = 0;
let lastCookie: string | undefined;
__setHttpBackedChatOverrideForTesting((req) => {
callCount++;
lastCookie = req.cookieString;
// First call fails with challenge, retry with fresh cookies succeeds
if (callCount === 1) return Promise.resolve(CHALLENGE_RESPONSE);
return Promise.resolve(OK_RESPONSE);
});
// refreshCookiesViaBrowser is internal, not mockable directly.
// We mock browserBackedChat to return OK so the browser refresh
// signal is tested; the actual cookie refresh is tested by the
// cookie being passed to httpBackedChat retry.
__setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE));
const result = await tryBackedChat({
...BASE_REQ,
// Pass a pre-set cookie so httpBackedChat override sees it
cookieString: "session=abc",
});
assert.equal(result.status, 200);
});
// --------------------------------------------------------------------------
// 5. External AbortSignal → abort before first call → returns 504
// --------------------------------------------------------------------------
it("returns 504 when external AbortSignal is already aborted", async () => {
__setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError")));
__setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called")));
const ac = new AbortController();
ac.abort();
const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal });
assert.equal(result.status, 504);
const body = JSON.parse(result.body.toString());
assert.equal(body.error.type, "timeout_error");
});
// --------------------------------------------------------------------------
// 6. httpBackedChat AbortError from timeout → returns 504
// --------------------------------------------------------------------------
it("returns 504 when httpBackedChat throws AbortError during request", async () => {
__setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError")));
__setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called")));
// Use a signal that aborts immediately to simulate timeout
const ac = new AbortController();
ac.abort();
const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal });
assert.equal(result.status, 504);
});
// --------------------------------------------------------------------------
// 7. Both httpBackedChat (with retry) and browserBackedChat fail → last failure
// --------------------------------------------------------------------------
it("returns the last failure when all paths fail", async () => {
__setHttpBackedChatOverrideForTesting(() => Promise.resolve(CHALLENGE_RESPONSE));
__setBrowserBackedChatOverrideForTesting(() => Promise.resolve(FAILURE_RESPONSE));
const result = await tryBackedChat({ ...BASE_REQ });
assert.equal(result.status, 502);
});
// --------------------------------------------------------------------------
// 8. Cleanup: internal AbortController timer doesn't leak after fast success
// --------------------------------------------------------------------------
it("does not leak AbortController timer when httpBackedChat succeeds quickly", async () => {
__setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE));
__setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called")));
// Call tryBackedChat without an external signal so it creates an internal AbortController
const result = await tryBackedChat({ ...BASE_REQ, signal: undefined });
assert.equal(result.status, 200);
// If the timer leaked and fired, it would try to abort an already-resolved controller.
// That's harmless but wasteful; this test just verifies the response is correct.
});
});