Files
openhuman/scripts/mock-api-core.mjs
T
Steven EnamakelandGitHub 7bcf43314a Improve service lifecycle E2E coverage and align CI workflows (#62)
* Add JSON-RPC schema definition and HTTP schema endpoint

- Introduced a new `schema.json` file containing detailed definitions for various JSON-RPC methods, including their inputs, outputs, and descriptions.
- Implemented a new HTTP endpoint `/schema` in the core server to serve the JSON-RPC schema, enhancing API documentation and accessibility.
- Updated the core HTTP router to include the new schema route, improving the overall structure and usability of the API.
- Enhanced error handling and response formatting in the server to ensure consistent feedback for schema requests.

* Update TypeScript configuration and refactor core RPC client

- Changed TypeScript target from ES2020 to ESNext and updated library references in `tsconfig.json` for improved compatibility with modern features.
- Refactored `coreRpcClient.ts` to enhance JSON-RPC request handling, including the introduction of legacy method aliases and improved error handling.
- Updated API service methods in `authApi.ts` and `channelConnectionsApi.ts` to utilize the new core RPC client structure, streamlining authentication and channel connection processes.
- Added new utility functions for managing JSON-RPC requests and responses, improving code organization and maintainability.
- Enhanced test coverage for the new RPC client methods and refactored existing tests to align with the updated structure.

* Enhance Tauri configuration and refactor daemon program arguments

- Updated `tauri.conf.json` to include additional macOS infoPlist settings for better application identification and icon management.
- Refactored the `daemon_program_args` function in `common.rs` to improve clarity by renaming the parameter to `_exe`, indicating it is unused. This change enhances code readability and maintainability.

* Refactor Tauri configuration by removing unused macOS infoPlist settings

- Updated `tauri.conf.json` to streamline macOS configuration by removing unnecessary infoPlist entries while retaining essential settings for the application.
- This change enhances clarity and maintainability of the Tauri configuration file.

* Enhance authentication flow and testing documentation

- Introduced a new `isAuthBootstrapComplete` state in the authentication slice to manage the completion of the authentication bootstrap process.
- Updated the `UserProvider` to set the `isAuthBootstrapComplete` state based on the authentication status, improving session restoration logic.
- Modified route components (`DefaultRedirect`, `ProtectedRoute`, `PublicRoute`) to conditionally render based on the `isAuthBootstrapComplete` state, enhancing user experience during the authentication process.
- Added a comprehensive testing guide in `CLAUDE.md`, detailing unit and E2E testing practices, including setup, authoring rules, and a checklist for test coverage.
- Updated the `SettingsHome` component to redirect to the home page instead of the login page upon logout, streamlining user navigation.
- Enhanced the `LocalModelPanel` to track download progress and manage local AI assets more effectively, improving overall functionality.

* Add resolutions for @tauri-apps/api dependency in package.json

- Introduced a resolutions field in package.json to enforce the use of @tauri-apps/api version 2.10.1, ensuring compatibility across workspaces.
- Updated dependencies in app/package.json to include @tauri-apps/api version 2.10.1, aligning with the new resolution.
- Adjusted yarn.lock to reflect the updated version of @tauri-apps/api, enhancing dependency management and consistency.

* Refactor Tauri configuration and enhance E2E build process

- Updated `tauri.conf.json` to remove unused resource paths, streamlining the configuration for better maintainability.
- Modified `wdio.conf.ts` to improve application path resolution for macOS, allowing for multiple bundle base checks to enhance compatibility.
- Refactored `e2e-build.sh` to disable updater artifacts for E2E builds and introduced a conditional cargo clean mechanism, improving build efficiency and clarity.

* Enhance E2E testing setup and documentation

- Updated `CLAUDE.md` to clarify the default behavior of `OPENHUMAN_WORKSPACE` in `e2e-run-spec.sh`, emphasizing automatic creation and cleanup for reproducible E2E runs.
- Modified `e2e-run-spec.sh` to implement automatic temporary workspace creation when `OPENHUMAN_WORKSPACE` is not set, improving usability for debugging and testing.
- Enhanced cleanup logic in `e2e-run-spec.sh` to ensure proper removal of temporary workspaces after tests, contributing to a cleaner testing environment.

* Implement shared mock backend for testing and enhance documentation

- Introduced a shared mock backend for unit and integration tests, allowing for deterministic API behavior across app and Rust tests.
- Updated `CLAUDE.md` to include detailed instructions on using the shared mock backend, including key admin endpoints and manual run commands.
- Modified `package.json` to add scripts for running the mock API server and Rust tests with the mock backend.
- Refactored test setup to utilize the new mock backend, improving test reliability and isolation.
- Removed obsolete MSW handlers and server setup, streamlining the testing framework.

* Enhance authentication state management and testing coverage

- Introduced `isAuthBootstrapComplete` state in the authentication slice to track the completion of the authentication process.
- Updated `ProtectedRoute` and `PublicRoute` components to utilize the new state, improving user experience during authentication.
- Enhanced test cases for `ProtectedRoute` and `PublicRoute` to reflect the updated authentication state structure.
- Added a new end-to-end test for the authentication flow, ensuring proper handling of OAuth tokens and session management.
- Improved mock setup in tests to better simulate authentication scenarios, enhancing test reliability and coverage.

* Enhance README.md with architecture overview and component roles

- Added detailed descriptions of the OpenHuman architecture, highlighting the separation of business logic and UI components.
- Explained the roles of Rust and the UI in the monorepo, including the use of JSON-RPC, QuickJS, Vite, React, and Tauri.
- Documented the structure of controllers and the RPC surface, emphasizing the shared contract for automation and testing.
- Provided links to further documentation for architecture, frontend structure, and Tauri commands.

* Integrate ServiceBlockingGate component and enhance loading states

- Added the `ServiceBlockingGate` component to manage service availability and display appropriate loading screens.
- Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to show a loading indicator while authentication bootstrap is in progress.
- Implemented timeout handling in `UserProvider` for improved authentication state management.
- Introduced tests for `ServiceBlockingGate` to ensure proper rendering and functionality under various service states.

* Implement core RPC URL resolution and default hash route handling

- Added a function to resolve the core RPC URL based on the environment, improving flexibility for Tauri and non-Tauri contexts.
- Introduced a default hash route handler in the main application entry point to ensure proper navigation behavior.
- Updated the core RPC command to expose the resolved RPC URL, enhancing the integration with the frontend.
- Refactored the core RPC client to utilize the new URL resolution logic, ensuring consistent API calls.

* Refactor backend URL usage to API_BASE_URL

- Replaced all instances of BACKEND_URL with API_BASE_URL across various components and services to standardize API endpoint references.
- Updated OAuth provider configurations, settings panels, hooks, and services to ensure consistent API calls.
- Enhanced test setups to reflect the new API_BASE_URL, improving test reliability and alignment with the updated configuration.

* Implement RouteLoadingScreen for improved loading states

- Introduced a new `RouteLoadingScreen` component to provide a consistent loading experience across various routes.
- Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to utilize `RouteLoadingScreen` while waiting for authentication bootstrap completion.
- Enhanced `MiniSidebar` to hide on additional public/setup routes, improving user navigation experience.
- Refactored `UserProvider` to streamline authentication state management by removing unnecessary references.

* Add feature design workflow section to CLAUDE.md

- Introduced a comprehensive workflow for feature design, outlining steps from specification to UI implementation and testing.
- Emphasized the importance of grounding designs in existing codebases and defined planning rules for E2E scenarios.
- Provided detailed instructions for implementing features in Rust, conducting JSON-RPC tests, and building UI components in the Tauri app.

* Refactor backend URL handling and improve OAuth flow

- Replaced static API_BASE_URL references with dynamic backend URL resolution across various components and services, enhancing flexibility for Tauri and non-Tauri environments.
- Updated OAuth provider configurations to utilize the new backend URL logic, ensuring consistent login URL generation.
- Refactored API client and socket service to fetch the backend URL dynamically, improving reliability in different deployment contexts.
- Introduced a new service for resolving the backend URL, streamlining the configuration and enhancing test setups.

* Add debug logging guidelines to CLAUDE.md

- Introduced comprehensive guidelines for implementing development-oriented debug logging in both Rust and the app.
- Emphasized the importance of logging at appropriate levels (`debug`/`trace`) and following existing patterns for consistency.
- Provided instructions on avoiding sensitive information in logs and ensuring terminal output is grep-friendly for easier debugging during development.

* Enhance service management with new mock functionality and E2E tests

- Added a mock service manager to facilitate deterministic service behavior during end-to-end tests, enabling better simulation of service states.
- Introduced new buttons in the `ServiceBlockingGate` component for restarting and uninstalling services, improving user control over service management.
- Implemented a comprehensive E2E test suite for the service connectivity flow, covering installation, starting, stopping, restarting, and uninstalling services.
- Updated package scripts to include a new E2E test for service connectivity, enhancing testing coverage and reliability.
- Refactored service operations to support mock functionality, ensuring consistent behavior across testing and production environments.

* Enhance ServiceBlockingGate with improved logging and periodic health polling

- Introduced periodic health polling in the `ServiceBlockingGate` component to refresh service status every 3 seconds, enhancing responsiveness to service state changes.
- Added detailed logging for various operations, including service status checks and error handling, to improve traceability and debugging.
- Updated E2E tests to include logging steps for better visibility during service connectivity flow tests.
- Refactored error handling to ensure consistent logging of error messages across service operations.

* Refactor E2E testing scripts and enhance CLAUDE.md documentation

- Updated paths in CLAUDE.md to reflect the new location of the E2E run script, ensuring accurate instructions for running tests.
- Removed outdated E2E test scripts from package.json and migrated relevant functionality to app/package.json for better organization.
- Introduced new E2E testing scripts for specific flows (auth, login, payment, etc.) to streamline testing processes and improve modularity.
- Added a script to run all E2E flows sequentially, enhancing test coverage and simplifying execution.
- Improved documentation for E2E testing procedures in CLAUDE.md, providing clearer guidance for developers.

* Refactor imports and enhance code readability

- Removed duplicate import of `ServiceBlockingGate` in `App.tsx` for cleaner code.
- Improved readability in `MiniSidebar.tsx` by formatting conditional statements.
- Reordered imports in `PublicRoute.tsx` for consistency.
- Enhanced formatting in `ServiceBlockingGate.tsx` for better clarity in asynchronous operations.
- Streamlined import statements in various test files and components for improved organization.
- Updated `LocalModelPanel.tsx` to enhance button disable logic readability.
- Refactored CORS headers in `jsonrpc.rs` for better formatting.

* ci: align build and test workflows with app workspace e2e

* ci: align release and typecheck workflows with current workspace

* Enhance ServiceBlockingGate functionality with improved refresh options

- Introduced a new `RefreshOptions` type to customize the behavior of the `refreshStatus` function, allowing for conditional error clearing and status checking.
- Updated the `refreshStatus` function to utilize the new options, enhancing control over service state updates.
- Modified the button click handler to pass the new options, improving user experience during service refresh operations.
- Adjusted state updates to prevent unnecessary re-renders and maintain consistency in service state management.

* Refactor RefreshOptions type for improved clarity in ServiceBlockingGate

- Consolidated the definition of the `RefreshOptions` type into a single line for better readability.
- Maintained existing functionality while enhancing code clarity in the `ServiceBlockingGate` component.
2026-03-29 18:14:13 -07:00

706 lines
20 KiB
JavaScript

import crypto from "node:crypto";
import http from "node:http";
const DEFAULT_PORT = 18473;
const MOCK_JWT = "e2e-mock-jwt-token";
let requestLog = [];
let mockBehavior = {};
let server = null;
const openSockets = new Set();
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
function setCors(res) {
for (const [key, value] of Object.entries(CORS_HEADERS)) {
res.setHeader(key, value);
}
}
function json(res, status, body) {
setCors(res);
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
function html(res, status, body) {
setCors(res);
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
res.end(body);
}
function requestOrigin(req) {
const host = req.headers.host || "127.0.0.1:18473";
return `http://${host}`;
}
function getMockUser() {
return {
_id: "user-123",
telegramId: 12345678,
hasAccess: true,
magicWord: "alpha",
firstName: "Test",
lastName: "User",
username: "testuser",
role: "user",
activeTeamId: "team-1",
referral: {},
subscription: { hasActiveSubscription: false, plan: "FREE" },
settings: {
dailySummariesEnabled: false,
dailySummaryChatIds: [],
autoCompleteEnabled: false,
autoCompleteVisibility: "always",
autoCompleteWhitelistChatIds: [],
autoCompleteBlacklistChatIds: [],
},
usage: {
cycleBudgetUsd: 10,
spentThisCycleUsd: 0,
spentTodayUsd: 0,
cycleStartDate: new Date().toISOString(),
},
autoDeleteTelegramMessagesAfterDays: 30,
autoDeleteThreadsAfterDays: 30,
};
}
function getMockTeam() {
const plan = mockBehavior.plan || "FREE";
const isActive = mockBehavior.planActive === "true";
const expiry = mockBehavior.planExpiry || null;
return {
team: {
_id: "team-1",
name: "Personal",
slug: "personal",
createdBy: "test-user-123",
isPersonal: true,
maxMembers: 1,
subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry },
usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
role: "ADMIN",
};
}
function getRequestLog() {
return [...requestLog];
}
function clearRequestLog() {
requestLog = [];
}
function setMockBehavior(key, value) {
mockBehavior[key] = String(value);
}
function setMockBehaviors(behavior, mode = "merge") {
if (mode === "replace") {
mockBehavior = {};
}
for (const [key, value] of Object.entries(behavior || {})) {
mockBehavior[key] = String(value);
}
}
function resetMockBehavior() {
mockBehavior = {};
}
function getMockBehavior() {
return { ...mockBehavior };
}
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
});
}
function tryParseJson(raw) {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function handleRequest(req, res) {
const method = req.method ?? "GET";
const url = req.url ?? "/";
const body = await readBody(req);
const parsedBody = tryParseJson(body);
const origin = requestOrigin(req);
requestLog.push({ method, url, body, timestamp: Date.now() });
if (method === "OPTIONS") {
setCors(res);
res.writeHead(204);
res.end();
return;
}
if (method === "GET" && /^\/__admin\/health\/?$/.test(url)) {
json(res, 200, { ok: true, port: server?.address()?.port ?? null });
return;
}
if (method === "GET" && /^\/__admin\/requests\/?$/.test(url)) {
json(res, 200, { success: true, data: getRequestLog() });
return;
}
if (method === "GET" && /^\/__admin\/behavior\/?$/.test(url)) {
json(res, 200, { success: true, data: getMockBehavior() });
return;
}
if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) {
const keepBehavior = parsedBody?.keepBehavior === true;
const keepRequests = parsedBody?.keepRequests === true;
if (!keepBehavior) resetMockBehavior();
if (!keepRequests) clearRequestLog();
json(res, 200, {
success: true,
data: { behavior: getMockBehavior(), requestCount: getRequestLog().length },
});
return;
}
if (method === "POST" && /^\/__admin\/behavior\/?$/.test(url)) {
if (parsedBody?.behavior && typeof parsedBody.behavior === "object") {
setMockBehaviors(parsedBody.behavior, parsedBody.mode);
} else if (parsedBody?.key) {
setMockBehavior(parsedBody.key, parsedBody.value ?? "");
}
json(res, 200, { success: true, data: getMockBehavior() });
return;
}
if (url.startsWith("/socket.io/")) {
const eioOpen = JSON.stringify({
sid: "mock-sid-" + Date.now(),
upgrades: ["websocket"],
pingInterval: 25000,
pingTimeout: 20000,
});
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
setCors(res);
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(packet);
return;
}
if (method === "POST" && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) {
if (mockBehavior.token === "expired") {
json(res, 401, { success: false, error: "Token expired or invalid" });
return;
}
if (mockBehavior.token === "invalid") {
json(res, 401, { success: false, error: "Invalid token" });
return;
}
const jwt = mockBehavior.jwt ? `${MOCK_JWT}-${mockBehavior.jwt}` : MOCK_JWT;
json(res, 200, { success: true, data: { jwtToken: jwt } });
return;
}
if (method === "POST" && /^\/auth\/desktop-exchange\/?$/.test(url)) {
json(res, 200, {
sessionToken: "mock-session-token",
user: { id: "user-123", firstName: "Test", username: "testuser" },
});
return;
}
if (method === "GET" && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
if (mockBehavior.telegramMeStatus) {
const status = Number(mockBehavior.telegramMeStatus) || 500;
json(res, status, {
success: false,
error: mockBehavior.telegramMeError || "Mock telegram/me failure",
});
return;
}
if (mockBehavior.session === "revoked") {
json(res, 401, { success: false, error: "Unauthorized" });
return;
}
json(res, 200, { success: true, data: getMockUser() });
return;
}
if (method === "GET" && /^\/settings\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: { _id: "e2e-user-1", username: "e2e" } });
return;
}
if (method === "GET" && /^\/teams\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [getMockTeam()] });
return;
}
if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
return;
}
if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) {
json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] });
return;
}
if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) {
json(res, 200, {
choices: [{ message: { role: "assistant", content: "Hello from e2e mock agent" } }],
});
return;
}
if (method === "GET" && /^\/auth\/[^/]+\/login\/?(\?.*)?$/.test(url)) {
const redirectUrl = `${origin}/mock-oauth`;
if (url.includes("responseType=json")) {
json(res, 200, { success: true, data: { oauthUrl: redirectUrl } });
return;
}
setCors(res);
res.writeHead(302, { Location: redirectUrl });
res.end();
return;
}
if (method === "GET" && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior.telegramDuplicate === "true") {
json(res, 409, { success: false, error: "Telegram account already linked to another user" });
return;
}
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-telegram-oauth` } });
return;
}
if (method === "GET" && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior.notionTokenRevoked === "true") {
json(res, 401, { success: false, error: "OAuth token has been revoked" });
return;
}
const workspace = mockBehavior.notionWorkspace || "Test User's Workspace";
json(res, 200, {
success: true,
data: { oauthUrl: `${origin}/mock-notion-oauth`, workspace },
});
return;
}
if (method === "GET" && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior.gmailTokenRevoked === "true") {
json(res, 401, { success: false, error: "OAuth token has been revoked" });
return;
}
if (mockBehavior.gmailTokenExpired === "true") {
json(res, 401, { success: false, error: "OAuth token has expired" });
return;
}
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-google-oauth` } });
return;
}
if (method === "POST" && /^\/telegram\/command\/?$/.test(url)) {
if (mockBehavior.telegramUnauthorized === "true") {
json(res, 403, { success: false, error: "Unauthorized: insufficient permissions" });
return;
}
if (mockBehavior.telegramCommandError === "true") {
json(res, 400, { success: false, error: "Invalid command format" });
return;
}
json(res, 200, { success: true, data: { result: "Command executed successfully" } });
return;
}
if (method === "GET" && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior.telegramPermission || "read";
json(res, 200, {
success: true,
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
});
return;
}
if (method === "POST" && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
json(res, 200, {
success: true,
data: { webhookUrl: "https://api.example.com/webhook/telegram", active: true },
});
return;
}
if (method === "POST" && /^\/telegram\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
if (method === "GET" && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior.notionPermission || "read";
json(res, 200, {
success: true,
data: { level, canRead: true, canWrite: level !== "read", canCreate: level !== "read" },
});
return;
}
if (method === "GET" && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior.gmailPermission || "read";
json(res, 200, {
success: true,
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
});
return;
}
if (method === "POST" && /^\/gmail\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
if (method === "GET" && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: "msg-1",
subject: "Welcome to OpenHuman",
from: "team@openhuman.com",
date: new Date().toISOString(),
snippet: "Welcome to the platform!",
hasAttachments: false,
},
],
});
return;
}
if (method === "GET" && /^\/skills\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: "telegram",
name: "Telegram",
status: mockBehavior.telegramSkillStatus || "installed",
setupComplete: mockBehavior.telegramSetupComplete === "true",
},
{
id: "notion",
name: "Notion",
status: mockBehavior.notionSkillStatus || "installed",
setupComplete: mockBehavior.notionSetupComplete === "true",
},
{
id: "email",
name: "Email",
status: mockBehavior.gmailSkillStatus || "installed",
setupComplete: mockBehavior.gmailSetupComplete === "true",
},
],
});
return;
}
if (method === "POST" && /^\/invite\/redeem\/?$/.test(url)) {
json(res, 200, { success: true, data: { message: "Invite code redeemed successfully" } });
return;
}
if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
return;
}
if (method === "GET" && /^\/invite\/status/.test(url)) {
json(res, 200, { success: true, data: { valid: true } });
return;
}
if (method === "POST" && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
json(res, 200, { success: true, data: {} });
return;
}
if (
(method === "GET" && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) ||
(method === "GET" && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url))
) {
const plan = mockBehavior.plan || "FREE";
const isActive = mockBehavior.planActive === "true";
const expiry = mockBehavior.planExpiry || null;
json(res, 200, {
success: true,
data: {
plan,
hasActiveSubscription: isActive,
planExpiry: expiry,
subscription: isActive
? {
id: "sub_mock_123",
status: "active",
currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(),
}
: null,
},
});
return;
}
if (method === "POST" && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
if (mockBehavior.purchaseError === "true") {
json(res, 500, { success: false, error: "Payment service unavailable" });
return;
}
json(res, 200, {
success: true,
data: { checkoutUrl: `${origin}/mock-checkout`, sessionId: `cs_mock_${Date.now()}` },
});
return;
}
if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) {
json(res, 200, { success: true, data: { portalUrl: `${origin}/mock-portal` } });
return;
}
if (method === "POST" && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
if (mockBehavior.coinbaseError === "true") {
json(res, 500, { success: false, error: "Coinbase service unavailable" });
return;
}
const chargeId = `coinbase_mock_${Date.now()}`;
json(res, 200, {
success: true,
data: {
gatewayTransactionId: chargeId,
hostedUrl: `${origin}/mock-coinbase-checkout`,
status: "NEW",
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
});
return;
}
if (method === "GET" && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
const status = mockBehavior.cryptoStatus || "NEW";
json(res, 200, {
success: true,
data: {
status,
payment: {
status,
amountPaid: status === "UNDERPAID" ? "150.00" : status === "OVERPAID" ? "350.00" : "250.00",
amountExpected: "250.00",
currency: "USDC",
underpaidAmount: mockBehavior.cryptoUnderpaidAmount || "0",
overpaidAmount: mockBehavior.cryptoOverpaidAmount || "0",
},
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
});
return;
}
if (method === "GET" && /^\/mock-(telegram|notion|google)-oauth\/?(\?.*)?$/.test(url)) {
html(res, 200, "<html><body><h1>Mock OAuth</h1></body></html>");
return;
}
if (method === "GET" && /^\/mock-oauth\/?(\?.*)?$/.test(url)) {
html(res, 200, "<html><body><h1>Mock OAuth Redirect</h1></body></html>");
return;
}
json(res, 200, { success: true, data: {} });
}
function handleSocketIOMessage(socket, text, sid) {
if (text === "2") {
sendWsText(socket, "3");
return;
}
if (text.startsWith("40")) {
sendWsText(socket, `40{"sid":"${sid}"}`);
}
}
function sendWsText(socket, text) {
sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8"));
}
function sendWsFrame(socket, opcode, payload) {
if (socket.destroyed) return;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[0] = 0x80 | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
try {
socket.write(header);
socket.write(payload);
} catch {
// noop
}
}
function handleWebSocketUpgrade(req, socket) {
if (!req.url?.startsWith("/socket.io/")) {
socket.destroy();
return;
}
const key = req.headers["sec-websocket-key"];
if (!key) {
socket.destroy();
return;
}
const acceptKey = crypto
.createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11")
.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
"\r\n",
);
const mockSid = "mock-ws-" + Date.now();
const eioOpen = JSON.stringify({
sid: mockSid,
upgrades: [],
pingInterval: 25000,
pingTimeout: 60000,
maxPayload: 1000000,
});
sendWsText(socket, `0${eioOpen}`);
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 2) {
const firstByte = buffer[0];
const opcode = firstByte & 0x0f;
const secondByte = buffer[1];
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return;
payloadLen = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return;
payloadLen = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
const maskSize = masked ? 4 : 0;
const totalLen = offset + maskSize + payloadLen;
if (buffer.length < totalLen) return;
let payload = buffer.subarray(offset + maskSize, totalLen);
if (masked) {
const mask = buffer.subarray(offset, offset + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) {
payload[i] ^= mask[i % 4];
}
}
buffer = buffer.subarray(totalLen);
if (opcode === 0x08) {
socket.end();
return;
}
if (opcode === 0x09) {
sendWsFrame(socket, 0x0a, payload);
continue;
}
if (opcode === 0x01) {
handleSocketIOMessage(socket, payload.toString("utf-8"), mockSid);
}
}
});
socket.on("error", () => {});
socket.on("close", () => {});
}
function startMockServer(port = DEFAULT_PORT) {
return new Promise((resolve, reject) => {
if (server) {
resolve({ port: server.address()?.port ?? port, alreadyRunning: true });
return;
}
server = http.createServer((req, res) => {
handleRequest(req, res).catch((err) => {
console.error("[MockServer] Unhandled error:", err);
json(res, 500, { success: false, error: "Internal mock error" });
});
});
server.on("connection", (socket) => {
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});
server.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
server.on("error", reject);
server.listen(port, "127.0.0.1", () => {
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
resolve({ port });
});
});
}
function stopMockServer() {
return new Promise((resolve) => {
if (!server) {
resolve();
return;
}
for (const socket of openSockets) {
socket.destroy();
}
openSockets.clear();
server.close(() => {
console.log("[MockServer] Stopped");
server = null;
resolve();
});
});
}
export {
DEFAULT_PORT,
clearRequestLog,
getMockBehavior,
getRequestLog,
resetMockBehavior,
setMockBehavior,
setMockBehaviors,
startMockServer,
stopMockServer,
};