Line data Source code
1 : import { HttpClient, HttpFetchResult } from '../../platform/http/http_client.js';
2 : import {
3 : ColumnDefinition,
4 : CompletionStatus,
5 : ExecuteQueryRequest,
6 : ExecutionStats,
7 : HyperDatabaseHttpClient,
8 : HyperHttpAuthProvider,
9 : QueryErrorResponse,
10 : QueryStatus,
11 : } from './hyperdb_http_client.js';
12 : import { TestLogger } from '../../platform/logger/test_logger.js';
13 :
14 1 : const ARROW_CONTENT_TYPE = "application/vnd.apache.arrow.stream";
15 :
16 : // ---------------------------------------------------------------------------
17 : // Helpers
18 : // ---------------------------------------------------------------------------
19 :
20 1 : function makeArrowResult(status: number, bytes: Uint8Array, extraHeaders?: Record<string, string>): HttpFetchResult {
21 1 : const headers = new Headers({ 'Content-Type': ARROW_CONTENT_TYPE, ...extraHeaders });
22 1 : return {
23 1 : headers,
24 1 : status,
25 1 : statusText: status === 200 ? 'OK' : String(status),
26 1 : json: async () => { throw new Error('response is Arrow, not JSON'); },
27 1 : text: async () => { throw new Error('response is Arrow, not text'); },
28 1 : arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
29 1 : };
30 1 : }
31 :
32 1 : function makeErrorResult(status: number, body: QueryErrorResponse): HttpFetchResult {
33 1 : const text = JSON.stringify(body);
34 1 : const headers = new Headers({ 'Content-Type': 'application/json' });
35 1 : return {
36 1 : headers,
37 1 : status,
38 1 : statusText: String(status),
39 1 : json: async () => JSON.parse(text),
40 1 : text: async () => text,
41 1 : arrayBuffer: async () => new TextEncoder().encode(text).buffer,
42 1 : };
43 1 : }
44 :
45 1 : function statusHeader(qs: QueryStatus): Record<string, string> {
46 1 : return { 'x-hyperdb-status': JSON.stringify(qs) };
47 1 : }
48 :
49 : // ---------------------------------------------------------------------------
50 : // Programmable request handler
51 : // ---------------------------------------------------------------------------
52 :
53 : export type MockQueryHandler = (sql: string, request: ExecuteQueryRequest) => MockQueryResult;
54 :
55 : export interface MockQueryResult {
56 : queryId?: string;
57 : completionStatus?: CompletionStatus;
58 : columns?: ColumnDefinition[];
59 : arrowBytes?: Uint8Array;
60 : errorStatus?: number;
61 : errorResponse?: QueryErrorResponse;
62 : }
63 :
64 : // ---------------------------------------------------------------------------
65 : // Stored query state
66 : // ---------------------------------------------------------------------------
67 :
68 : export interface MockPendingQuery {
69 : queryId: string;
70 : completionStatus: CompletionStatus;
71 : columns: ColumnDefinition[];
72 : arrowBytes: Uint8Array;
73 : chunkCount: number;
74 : rowCount: number;
75 : expirationTime: string;
76 : executionStats: ExecutionStats;
77 : }
78 :
79 : // ---------------------------------------------------------------------------
80 : // Mock HttpClient
81 : // ---------------------------------------------------------------------------
82 :
83 1 : export class HyperHttpClientMock implements HttpClient {
84 1 : private queries = new Map<string, MockPendingQuery>();
85 1 : private handler: MockQueryHandler | null = null;
86 1 : private nextQueryId = 1;
87 :
88 1 : setHandler(handler: MockQueryHandler): void {
89 1 : this.handler = handler;
90 1 : }
91 :
92 1 : registerQuery(query: MockPendingQuery): void {
93 1 : this.queries.set(query.queryId, query);
94 1 : }
95 :
96 1 : private newQueryId(): string {
97 1 : return `mock-query-${this.nextQueryId++}`;
98 1 : }
99 :
100 1 : private buildQueryStatus(q: MockPendingQuery): QueryStatus {
101 1 : return {
102 1 : queryId: q.queryId,
103 1 : completionStatus: q.completionStatus,
104 1 : chunkCount: q.chunkCount,
105 1 : rowCount: q.rowCount,
106 1 : progress: q.completionStatus === 'RUNNING_OR_UNSPECIFIED' ? 0 : 100,
107 1 : expirationTime: q.expirationTime,
108 1 : executionStats: q.executionStats,
109 1 : };
110 1 : }
111 :
112 1 : async fetch(input: URL | Request | string, init?: RequestInit): Promise<HttpFetchResult> {
113 1 : const url = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input.url);
114 1 : const method = (init?.method ?? 'GET').toUpperCase();
115 1 : const path = url.pathname;
116 :
117 1 : if (method === 'POST' && path === '/api/v3/query') {
118 1 : return this.handleExecuteQuery(init);
119 : }
120 :
121 1 : const statusMatch = path.match(/^\/api\/v3\/query\/([^/]+)$/);
122 1 : if (statusMatch) {
123 1 : if (method === 'GET') return this.handleGetStatus(decodeURIComponent(statusMatch[1]));
124 1 : if (method === 'DELETE') return this.handleCancelQuery(decodeURIComponent(statusMatch[1]));
125 1 : }
126 :
127 1 : const chunkMatch = path.match(/^\/api\/v3\/query\/([^/]+)\/chunk\/(\d+)$/);
128 1 : if (method === 'GET' && chunkMatch) {
129 1 : return this.handleGetChunk(decodeURIComponent(chunkMatch[1]), parseInt(chunkMatch[2], 10));
130 : }
131 :
132 1 : const rowMatch = path.match(/^\/api\/v3\/query\/([^/]+)\/row$/);
133 1 : if (method === 'GET' && rowMatch) {
134 1 : return this.handleGetRows(decodeURIComponent(rowMatch[1]), url);
135 : }
136 :
137 0 : return makeErrorResult(404, { error: 'NOT_FOUND', message: `No mock for ${method} ${path}` });
138 1 : }
139 :
140 1 : private handleExecuteQuery(init?: RequestInit): HttpFetchResult {
141 1 : const request: ExecuteQueryRequest = init?.body ? JSON.parse(init.body as string) : { sql: '' };
142 :
143 1 : let result: MockQueryResult = { completionStatus: 'RESULTS_PRODUCED' };
144 1 : if (this.handler) {
145 1 : result = this.handler(request.sql, request);
146 : }
147 :
148 1 : if (result.errorStatus !== undefined) {
149 1 : return makeErrorResult(result.errorStatus, result.errorResponse ?? { error: 'HY000', message: 'mock error' });
150 : }
151 :
152 1 : const queryId = result.queryId ?? this.newQueryId();
153 1 : const completionStatus = result.completionStatus ?? 'RESULTS_PRODUCED';
154 1 : const arrowBytes = result.arrowBytes ?? new Uint8Array(0);
155 :
156 1 : const pending: MockPendingQuery = {
157 1 : queryId,
158 1 : completionStatus,
159 1 : columns: result.columns ?? [],
160 1 : arrowBytes,
161 1 : chunkCount: completionStatus === 'RUNNING_OR_UNSPECIFIED' ? 0 : 1,
162 1 : rowCount: 0,
163 1 : expirationTime: new Date(Date.now() + 300_000).toISOString(),
164 1 : executionStats: { wallClockTime: 1, rowsProcessed: 0 },
165 1 : };
166 1 : this.queries.set(queryId, pending);
167 :
168 1 : const qs = this.buildQueryStatus(pending);
169 1 : return makeArrowResult(200, arrowBytes, statusHeader(qs));
170 1 : }
171 :
172 1 : private handleGetStatus(queryId: string): HttpFetchResult {
173 1 : const q = this.queries.get(queryId);
174 1 : if (!q) {
175 1 : return makeErrorResult(404, { error: 'NOT_FOUND', message: `Query ${queryId} not found` });
176 : }
177 : // Status endpoint returns JSON (not Arrow)
178 1 : const text = JSON.stringify(this.buildQueryStatus(q));
179 1 : return {
180 1 : headers: new Headers({ 'Content-Type': 'application/json' }),
181 1 : status: 200,
182 1 : statusText: 'OK',
183 1 : json: async () => JSON.parse(text),
184 1 : text: async () => text,
185 1 : arrayBuffer: async () => new TextEncoder().encode(text).buffer,
186 1 : };
187 1 : }
188 :
189 1 : private handleCancelQuery(queryId: string): HttpFetchResult {
190 1 : this.queries.delete(queryId);
191 1 : return {
192 1 : headers: new Headers(),
193 1 : status: 204,
194 1 : statusText: 'No Content',
195 1 : json: async () => null,
196 1 : text: async () => '',
197 1 : arrayBuffer: async () => new ArrayBuffer(0),
198 1 : };
199 1 : }
200 :
201 1 : private handleGetChunk(queryId: string, _chunkId: number): HttpFetchResult {
202 1 : const q = this.queries.get(queryId);
203 1 : if (!q) {
204 1 : return makeErrorResult(404, { error: 'NOT_FOUND', message: `Query ${queryId} not found` });
205 : }
206 1 : const qs = this.buildQueryStatus(q);
207 1 : return makeArrowResult(200, q.arrowBytes, statusHeader(qs));
208 1 : }
209 :
210 1 : private handleGetRows(queryId: string, url: URL): HttpFetchResult {
211 1 : const q = this.queries.get(queryId);
212 1 : if (!q) {
213 1 : return makeErrorResult(404, { error: 'NOT_FOUND', message: `Query ${queryId} not found` });
214 : }
215 : // offset/limit/byteLimit are query params the real server uses; the mock
216 : // returns the full arrowBytes slice since Arrow streaming handles framing.
217 1 : return makeArrowResult(200, q.arrowBytes, {});
218 1 : }
219 1 : }
220 :
221 : // ---------------------------------------------------------------------------
222 : // Factory
223 : // ---------------------------------------------------------------------------
224 :
225 1 : const MOCK_BASE_URL = new URL('https://mock.hyperdb.local');
226 :
227 1 : const MOCK_AUTH: HyperHttpAuthProvider = {
228 1 : getAuthHeaders: async () => ({ Authorization: 'Bearer mock-token' }),
229 1 : };
230 :
231 1 : export function createMockHyperHttpClient(httpMock?: HyperHttpClientMock): {
232 : client: HyperDatabaseHttpClient;
233 : mock: HyperHttpClientMock;
234 1 : } {
235 1 : const mock = httpMock ?? new HyperHttpClientMock();
236 1 : const client = new HyperDatabaseHttpClient(mock, MOCK_BASE_URL, MOCK_AUTH, new TestLogger());
237 1 : return { client, mock };
238 1 : }
|