@aerostack/sdk-node 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock the generated APIs and realtime
4
+ vi.mock('../_generated/index.js', () => {
5
+ const createMockApi = () => ({});
6
+ class MockConfiguration {
7
+ basePath: string;
8
+ headers: Record<string, string>;
9
+ apiKey?: string;
10
+ constructor(opts: any = {}) {
11
+ this.basePath = opts.basePath || '';
12
+ this.headers = opts.headers || {};
13
+ this.apiKey = opts.apiKey;
14
+ }
15
+ }
16
+ return {
17
+ Configuration: MockConfiguration,
18
+ AuthenticationApi: vi.fn().mockImplementation(() => ({ auth: true })),
19
+ CacheApi: vi.fn().mockImplementation(() => ({
20
+ cacheGet: vi.fn().mockResolvedValue({ _exists: true, value: 'cached' }),
21
+ cacheSet: vi.fn().mockResolvedValue({}),
22
+ cacheDelete: vi.fn().mockResolvedValue({}),
23
+ cacheList: vi.fn().mockResolvedValue({ keys: [], cursor: null }),
24
+ cacheKeys: vi.fn().mockResolvedValue({ keys: ['k1', 'k2'] }),
25
+ cacheGetMany: vi.fn().mockResolvedValue({ results: [] }),
26
+ cacheSetMany: vi.fn().mockResolvedValue({}),
27
+ cacheDeleteMany: vi.fn().mockResolvedValue({}),
28
+ cacheFlush: vi.fn().mockResolvedValue({}),
29
+ cacheExpire: vi.fn().mockResolvedValue({}),
30
+ cacheIncrement: vi.fn().mockResolvedValue({ value: 5 }),
31
+ })),
32
+ DatabaseApi: vi.fn().mockImplementation(() => ({
33
+ dbQuery: vi.fn().mockResolvedValue({ results: [] }),
34
+ })),
35
+ QueueApi: vi.fn().mockImplementation(() => ({ queue: true })),
36
+ StorageApi: vi.fn().mockImplementation(() => ({ storage: true })),
37
+ AIApi: vi.fn().mockImplementation(() => ({ ai: true })),
38
+ ServicesApi: vi.fn().mockImplementation(() => ({ services: true })),
39
+ GatewayApi: vi.fn().mockImplementation(() => ({ gateway: true })),
40
+ };
41
+ });
42
+
43
+ vi.mock('../realtime.js', () => {
44
+ return {
45
+ NodeRealtimeClient: vi.fn().mockImplementation(() => ({
46
+ connect: vi.fn(),
47
+ disconnect: vi.fn(),
48
+ channel: vi.fn(),
49
+ })),
50
+ };
51
+ });
52
+
53
+ vi.mock('@aerostack/core', () => {
54
+ return {
55
+ AerostackClient: vi.fn().mockImplementation(() => ({
56
+ db: { query: vi.fn() },
57
+ cache: { get: vi.fn() },
58
+ })),
59
+ };
60
+ });
61
+
62
+ import { SDK, Aerostack, createClient } from '../sdk.js';
63
+
64
+ describe('SDK', () => {
65
+ describe('constructor', () => {
66
+ it('should initialize with default options', () => {
67
+ const sdk = new SDK();
68
+ expect(sdk).toBeDefined();
69
+ expect(sdk.database).toBeDefined();
70
+ expect(sdk.auth).toBeDefined();
71
+ expect(sdk.cache).toBeDefined();
72
+ expect(sdk.queue).toBeDefined();
73
+ expect(sdk.storage).toBeDefined();
74
+ expect(sdk.ai).toBeDefined();
75
+ expect(sdk.services).toBeDefined();
76
+ expect(sdk.gateway).toBeDefined();
77
+ expect(sdk.realtime).toBeDefined();
78
+ expect(sdk.rpc).toBeDefined();
79
+ });
80
+
81
+ it('should accept apiKey option', () => {
82
+ const sdk = new SDK({ apiKey: 'my-key' });
83
+ expect(sdk).toBeDefined();
84
+ });
85
+
86
+ it('should accept apiKeyAuth alias', () => {
87
+ const sdk = new SDK({ apiKeyAuth: 'my-key' });
88
+ expect(sdk).toBeDefined();
89
+ });
90
+
91
+ it('should accept serverUrl option', () => {
92
+ const sdk = new SDK({ serverUrl: 'https://custom.com/v1' });
93
+ expect(sdk).toBeDefined();
94
+ });
95
+
96
+ it('should accept serverURL alias', () => {
97
+ const sdk = new SDK({ serverURL: 'https://custom.com/v1' });
98
+ expect(sdk).toBeDefined();
99
+ });
100
+
101
+ it('should accept projectId', () => {
102
+ const sdk = new SDK({ projectId: 'proj-1' });
103
+ expect(sdk).toBeDefined();
104
+ });
105
+
106
+ it('should accept maxReconnectAttempts', () => {
107
+ const sdk = new SDK({ maxReconnectAttempts: 5 });
108
+ expect(sdk).toBeDefined();
109
+ });
110
+ });
111
+
112
+ describe('CacheFacade', () => {
113
+ it('should get a cached value', async () => {
114
+ const sdk = new SDK({ apiKey: 'key' });
115
+ const result = await sdk.cache.get('test-key');
116
+ expect(result).toBe('cached');
117
+ });
118
+
119
+ it('should return null when key not found', async () => {
120
+ const sdk = new SDK({ apiKey: 'key' });
121
+ // Override the mock for this test
122
+ const cacheApi = (sdk.cache as any).api;
123
+ cacheApi.cacheGet.mockResolvedValueOnce({ _exists: false });
124
+ const result = await sdk.cache.get('missing');
125
+ expect(result).toBeNull();
126
+ });
127
+
128
+ it('should set a value', async () => {
129
+ const sdk = new SDK({ apiKey: 'key' });
130
+ await sdk.cache.set('key', 'value');
131
+ const cacheApi = (sdk.cache as any).api;
132
+ expect(cacheApi.cacheSet).toHaveBeenCalled();
133
+ });
134
+
135
+ it('should set a value with TTL', async () => {
136
+ const sdk = new SDK({ apiKey: 'key' });
137
+ await sdk.cache.set('key', 'value', 3600);
138
+ const cacheApi = (sdk.cache as any).api;
139
+ const call = cacheApi.cacheSet.mock.calls[0][0];
140
+ expect(call.cacheSetRequest.ttl).toBe(3600);
141
+ });
142
+
143
+ it('should delete a key', async () => {
144
+ const sdk = new SDK({ apiKey: 'key' });
145
+ await sdk.cache.delete('key');
146
+ const cacheApi = (sdk.cache as any).api;
147
+ expect(cacheApi.cacheDelete).toHaveBeenCalled();
148
+ });
149
+
150
+ it('should check if key exists', async () => {
151
+ const sdk = new SDK({ apiKey: 'key' });
152
+ const result = await sdk.cache.exists('key');
153
+ expect(result).toBe(true);
154
+ });
155
+
156
+ it('should list keys', async () => {
157
+ const sdk = new SDK({ apiKey: 'key' });
158
+ await sdk.cache.list('prefix:', 10, 'cursor');
159
+ });
160
+
161
+ it('should get all keys', async () => {
162
+ const sdk = new SDK({ apiKey: 'key' });
163
+ const keys = await sdk.cache.keys('prefix:');
164
+ expect(keys).toEqual(['k1', 'k2']);
165
+ });
166
+
167
+ it('should get many keys', async () => {
168
+ const sdk = new SDK({ apiKey: 'key' });
169
+ const result = await sdk.cache.getMany(['k1', 'k2']);
170
+ expect(result).toEqual([]);
171
+ });
172
+
173
+ it('should set many entries', async () => {
174
+ const sdk = new SDK({ apiKey: 'key' });
175
+ await sdk.cache.setMany([{ key: 'k1', value: 'v1' }]);
176
+ });
177
+
178
+ it('should delete many keys', async () => {
179
+ const sdk = new SDK({ apiKey: 'key' });
180
+ await sdk.cache.deleteMany(['k1', 'k2']);
181
+ });
182
+
183
+ it('should flush cache', async () => {
184
+ const sdk = new SDK({ apiKey: 'key' });
185
+ await sdk.cache.flush('prefix:');
186
+ });
187
+
188
+ it('should expire a key', async () => {
189
+ const sdk = new SDK({ apiKey: 'key' });
190
+ await sdk.cache.expire('key', 300);
191
+ });
192
+
193
+ it('should increment a counter', async () => {
194
+ const sdk = new SDK({ apiKey: 'key' });
195
+ const result = await sdk.cache.increment('counter', 1, 0, 3600);
196
+ expect(result).toBe(5);
197
+ });
198
+ });
199
+
200
+ describe('DatabaseFacade', () => {
201
+ it('should execute a query', async () => {
202
+ const sdk = new SDK({ apiKey: 'key' });
203
+ const result = await sdk.database.dbQuery({
204
+ dbQueryRequest: { sql: 'SELECT 1', params: [] },
205
+ });
206
+ expect(result).toEqual({ results: [] });
207
+ });
208
+
209
+ it('should accept requestBody alias', async () => {
210
+ const sdk = new SDK({ apiKey: 'key' });
211
+ await sdk.database.dbQuery({
212
+ requestBody: { sql: 'SELECT 1', params: [] },
213
+ });
214
+ });
215
+ });
216
+
217
+ describe('setApiKey', () => {
218
+ it('should update all service instances', () => {
219
+ const sdk = new SDK({ apiKey: 'old-key' });
220
+ sdk.setApiKey('new-key');
221
+ // After setApiKey, all services should be recreated
222
+ expect(sdk.database).toBeDefined();
223
+ expect(sdk.auth).toBeDefined();
224
+ expect(sdk.cache).toBeDefined();
225
+ });
226
+ });
227
+
228
+ describe('streamGateway', () => {
229
+ it('should make POST request to gateway endpoint', async () => {
230
+ const mockFetch = vi.fn().mockResolvedValue({
231
+ ok: true,
232
+ body: createMockStream('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n'),
233
+ });
234
+ vi.stubGlobal('fetch', mockFetch);
235
+
236
+ const sdk = new SDK({ apiKey: 'key', serverUrl: 'https://api.test.com/v1' });
237
+ const result = await sdk.streamGateway({
238
+ apiSlug: 'my-chatbot',
239
+ messages: [{ role: 'user', content: 'Hi' }],
240
+ consumerKey: 'ask_live_123',
241
+ });
242
+
243
+ expect(result.text).toBe('Hello');
244
+ expect(mockFetch).toHaveBeenCalledOnce();
245
+ const [url, opts] = mockFetch.mock.calls[0];
246
+ expect(url).toBe('https://api.test.com/api/gateway/my-chatbot/v1/chat/completions');
247
+ expect(opts.headers.Authorization).toBe('Bearer ask_live_123');
248
+ });
249
+
250
+ it('should use token when consumerKey not provided', async () => {
251
+ const mockFetch = vi.fn().mockResolvedValue({
252
+ ok: true,
253
+ body: createMockStream('data: [DONE]\n\n'),
254
+ });
255
+ vi.stubGlobal('fetch', mockFetch);
256
+
257
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
258
+ await sdk.streamGateway({
259
+ apiSlug: 'bot',
260
+ messages: [{ role: 'user', content: 'Hi' }],
261
+ token: 'jwt-token',
262
+ });
263
+
264
+ expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer jwt-token');
265
+ });
266
+
267
+ it('should prepend system prompt', async () => {
268
+ const mockFetch = vi.fn().mockResolvedValue({
269
+ ok: true,
270
+ body: createMockStream('data: [DONE]\n\n'),
271
+ });
272
+ vi.stubGlobal('fetch', mockFetch);
273
+
274
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
275
+ await sdk.streamGateway({
276
+ apiSlug: 'bot',
277
+ messages: [{ role: 'user', content: 'Hi' }],
278
+ systemPrompt: 'You are helpful',
279
+ });
280
+
281
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
282
+ expect(body.messages[0]).toEqual({ role: 'system', content: 'You are helpful' });
283
+ expect(body.messages[1]).toEqual({ role: 'user', content: 'Hi' });
284
+ });
285
+
286
+ it('should call onToken callback', async () => {
287
+ const mockFetch = vi.fn().mockResolvedValue({
288
+ ok: true,
289
+ body: createMockStream('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: {"choices":[{"delta":{"content":" there"}}]}\n\ndata: [DONE]\n\n'),
290
+ });
291
+ vi.stubGlobal('fetch', mockFetch);
292
+
293
+ const tokens: string[] = [];
294
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
295
+ await sdk.streamGateway({
296
+ apiSlug: 'bot',
297
+ messages: [{ role: 'user', content: 'Hi' }],
298
+ onToken: (delta: string) => tokens.push(delta),
299
+ });
300
+
301
+ expect(tokens).toEqual(['Hi', ' there']);
302
+ });
303
+
304
+ it('should call onDone callback', async () => {
305
+ const mockFetch = vi.fn().mockResolvedValue({
306
+ ok: true,
307
+ body: createMockStream('data: {"choices":[{"delta":{"content":"Hi"}}],"usage":{"total_tokens":10}}\n\ndata: [DONE]\n\n'),
308
+ });
309
+ vi.stubGlobal('fetch', mockFetch);
310
+
311
+ let doneResult: any;
312
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
313
+ await sdk.streamGateway({
314
+ apiSlug: 'bot',
315
+ messages: [{ role: 'user', content: 'Hi' }],
316
+ onDone: (result: any) => { doneResult = result; },
317
+ });
318
+
319
+ expect(doneResult.tokensUsed).toBe(10);
320
+ });
321
+
322
+ it('should throw on non-OK response', async () => {
323
+ const mockFetch = vi.fn().mockResolvedValue({
324
+ ok: false,
325
+ status: 500,
326
+ json: vi.fn().mockResolvedValue({ error: 'Server error' }),
327
+ });
328
+ vi.stubGlobal('fetch', mockFetch);
329
+
330
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
331
+ await expect(sdk.streamGateway({
332
+ apiSlug: 'bot',
333
+ messages: [{ role: 'user', content: 'Hi' }],
334
+ })).rejects.toThrow('Server error');
335
+ });
336
+
337
+ it('should call onError on failure', async () => {
338
+ const mockFetch = vi.fn().mockResolvedValue({
339
+ ok: false,
340
+ status: 500,
341
+ json: vi.fn().mockResolvedValue({ error: 'boom' }),
342
+ });
343
+ vi.stubGlobal('fetch', mockFetch);
344
+
345
+ let capturedError: Error | null = null;
346
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
347
+
348
+ try {
349
+ await sdk.streamGateway({
350
+ apiSlug: 'bot',
351
+ messages: [{ role: 'user', content: 'Hi' }],
352
+ onError: (err: Error) => { capturedError = err; },
353
+ });
354
+ } catch {
355
+ // Expected
356
+ }
357
+
358
+ expect(capturedError).not.toBeNull();
359
+ expect(capturedError!.message).toBe('boom');
360
+ });
361
+
362
+ it('should handle abort gracefully', async () => {
363
+ const abortController = new AbortController();
364
+ const mockFetch = vi.fn().mockRejectedValue(
365
+ Object.assign(new Error('Aborted'), { name: 'AbortError' })
366
+ );
367
+ vi.stubGlobal('fetch', mockFetch);
368
+
369
+ const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
370
+ const result = await sdk.streamGateway({
371
+ apiSlug: 'bot',
372
+ messages: [{ role: 'user', content: 'Hi' }],
373
+ signal: abortController.signal,
374
+ });
375
+
376
+ expect(result.text).toBe('');
377
+ expect(result.tokensUsed).toBe(0);
378
+ });
379
+ });
380
+
381
+ describe('Aerostack alias', () => {
382
+ it('should be the same as SDK', () => {
383
+ expect(Aerostack).toBe(SDK);
384
+ });
385
+ });
386
+
387
+ describe('createClient', () => {
388
+ it('should return an SDK instance', () => {
389
+ const client = createClient({ apiKey: 'key' });
390
+ expect(client).toBeInstanceOf(SDK);
391
+ });
392
+ });
393
+ });
394
+
395
+ // Helper to create a mock ReadableStream
396
+ function createMockStream(text: string) {
397
+ const encoder = new TextEncoder();
398
+ const data = encoder.encode(text);
399
+ let read = false;
400
+ return {
401
+ getReader: () => ({
402
+ read: async () => {
403
+ if (!read) {
404
+ read = true;
405
+ return { done: false, value: data };
406
+ }
407
+ return { done: true, value: undefined };
408
+ },
409
+ cancel: vi.fn(),
410
+ }),
411
+ };
412
+ }
package/src/realtime.ts CHANGED
@@ -99,6 +99,7 @@ export class RealtimeSubscription<T = any> {
99
99
  });
100
100
  this._isSubscribed = false;
101
101
  this.callbacks.clear();
102
+ this.client._removeSubscription(this.topic);
102
103
  }
103
104
 
104
105
  get isSubscribed() { return this._isSubscribed; }
@@ -227,8 +228,6 @@ export class NodeRealtimeClient {
227
228
  private async _doConnect(): Promise<void> {
228
229
  this._setStatus('connecting');
229
230
  const url = new URL(this.wsUrl);
230
- if (this.apiKey) url.searchParams.set('apiKey', this.apiKey);
231
- if (this.token) url.searchParams.set('token', this.token);
232
231
  if (this.projectId) url.searchParams.set('projectId', this.projectId);
233
232
 
234
233
  return new Promise(async (resolve, reject) => {
@@ -245,7 +244,14 @@ export class NodeRealtimeClient {
245
244
  }
246
245
  }
247
246
 
248
- this.ws = new WsClass(url.toString());
247
+ // SECURITY: Pass credentials via Sec-WebSocket-Protocol header — never as URL query params
248
+ // (URL params appear in CDN logs, browser history, and Referer headers).
249
+ const protocols: string[] = [];
250
+ if (this.apiKey) protocols.push(`aerostack-key.${this.apiKey}`);
251
+ if (this.token) protocols.push(`aerostack-token.${this.token}`);
252
+ if (protocols.length > 0) protocols.push('aerostack-v1');
253
+ const protocolsArg = protocols.length > 0 ? protocols : undefined;
254
+ this.ws = protocolsArg ? new WsClass(url.toString(), protocolsArg) : new WsClass(url.toString());
249
255
 
250
256
  this.ws!.onopen = () => {
251
257
  this._setStatus('connected');
@@ -302,6 +308,9 @@ export class NodeRealtimeClient {
302
308
  }
303
309
 
304
310
  channel<T = any>(topic: string, options: RealtimeSubscriptionOptions = {}): RealtimeSubscription<T> {
311
+ if (!this.projectId) {
312
+ throw new Error('projectId is required for channel subscriptions. Set it in NodeRealtimeOptions.');
313
+ }
305
314
  let fullTopic: string;
306
315
  if (!topic.includes('/')) {
307
316
  fullTopic = `table/${topic}/${this.projectId}`;
@@ -328,6 +337,11 @@ export class NodeRealtimeClient {
328
337
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
329
338
  }
330
339
 
340
+ /** @internal — Remove a subscription from the map (called on unsubscribe) */
341
+ _removeSubscription(topic: string): void {
342
+ this.subscriptions.delete(topic);
343
+ }
344
+
331
345
  /** @internal */
332
346
  _send(data: any): void {
333
347
  if (this.ws && this._status === 'connected') {