Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions test/uts/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { DefaultRest } from '../../src/common/lib/client/defaultrest';
import { DefaultRealtime } from '../../src/common/lib/client/defaultrealtime';
import ErrorInfo from '../../src/common/lib/types/errorinfo';
import { makeFromDeserializedWithDependencies as makeProtocolMessageFromDeserialized } from '../../src/common/lib/types/protocolmessage';
import { IPlatformHttpStatic } from '../../src/common/types/http';
import { IPlatformConfig } from '../../src/common/types/IPlatformConfig';

const Ably = {
Rest: DefaultRest,
Expand All @@ -24,21 +26,23 @@ const Ably = {

const Platform = DefaultRest.Platform;

type Http = typeof Platform.Http; // = IPlatformHttpStatic

// Saved originals for teardown
let _savedHttp: any = null;
let _savedWebSocket: any = null;
let _savedSetTimeout: any = null;
let _savedClearTimeout: any = null;
let _savedNow: any = null;
let _savedHttp: Http | null = null;
let _savedWebSocket: IPlatformConfig['WebSocket'] | null = null;
let _savedSetTimeout: IPlatformConfig['setTimeout'] | null = null;
let _savedClearTimeout: IPlatformConfig['clearTimeout'] | null = null;
let _savedNow: IPlatformConfig['now'] | null = null;

// Tracked clients for cleanup — ensures timers are released even if a test crashes
const _trackedClients: any[] = [];
const _trackedClients: (DefaultRest | DefaultRealtime)[] = [];

/**
* Install a MockHttpClient as the platform HTTP implementation.
* Call uninstallMockHttp() in afterEach to restore the original.
*/
function installMockHttp(mockHttpClient: { asPlatformHttp(): any }): void {
function installMockHttp(mockHttpClient: { asPlatformHttp(): IPlatformHttpStatic }): void {
if (_savedHttp) throw new Error('Mock HTTP already installed — call uninstallMockHttp() first');
_savedHttp = Platform.Http;
Platform.Http = mockHttpClient.asPlatformHttp();
Expand All @@ -58,7 +62,7 @@ function uninstallMockHttp(): void {
* Install a mock WebSocket constructor.
* Call uninstallMockWebSocket() in afterEach to restore the original.
*/
function installMockWebSocket(mockWsConstructor: any): void {
function installMockWebSocket(mockWsConstructor: IPlatformConfig['WebSocket']): void {
if (_savedWebSocket) throw new Error('Mock WebSocket already installed');
_savedWebSocket = Platform.Config.WebSocket;
Platform.Config.WebSocket = mockWsConstructor;
Expand Down Expand Up @@ -175,8 +179,8 @@ class FakeClock {
uninstall(): void {
if (_savedSetTimeout) {
Platform.Config.setTimeout = _savedSetTimeout;
Platform.Config.clearTimeout = _savedClearTimeout;
Platform.Config.now = _savedNow;
Platform.Config.clearTimeout = _savedClearTimeout!;
Platform.Config.now = _savedNow!;
_savedSetTimeout = null;
_savedClearTimeout = null;
_savedNow = null;
Expand All @@ -202,7 +206,7 @@ function enableFakeTimers(): FakeClock {
* restoreAll() will close all tracked clients, preventing timer leaks
* even if the test throws before reaching its own cleanup code.
*/
function trackClient(client: any): void {
function trackClient(client: DefaultRest | DefaultRealtime): void {
_trackedClients.push(client);
}

Expand All @@ -213,9 +217,9 @@ function restoreAll(): void {
// Close all tracked clients first (before restoring mocks/timers)
// so their internal timers are cancelled while mocks are still in place.
while (_trackedClients.length > 0) {
const client = _trackedClients.pop();
const client = _trackedClients.pop()!;
try {
if (typeof client.close === 'function') {
if (client instanceof DefaultRealtime) {
client.close();
}
} catch (_) {
Expand All @@ -227,8 +231,8 @@ function restoreAll(): void {
// Restore fake timers if installed
if (_savedSetTimeout) {
Platform.Config.setTimeout = _savedSetTimeout;
Platform.Config.clearTimeout = _savedClearTimeout;
Platform.Config.now = _savedNow;
Platform.Config.clearTimeout = _savedClearTimeout!;
Platform.Config.now = _savedNow!;
_savedSetTimeout = null;
_savedClearTimeout = null;
_savedNow = null;
Expand All @@ -237,6 +241,7 @@ function restoreAll(): void {

export {
Ably,
ErrorInfo,
Platform,
installMockHttp,
uninstallMockHttp,
Expand Down
87 changes: 48 additions & 39 deletions test/uts/mock_http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
* See: specification/uts/rest/unit/helpers/mock_http.md
*/

import {
IPlatformHttpStatic,
RequestResult,
RequestResultError,
ErrnoException,
RequestBody,
RequestParams,
} from '../../src/common/types/http';
import HttpMethods from '../../src/common/constants/HttpMethods';
import ErrorInfo from '../../src/common/lib/types/errorinfo';

interface ConnectionResult {
success: boolean;
error?: { code: string; statusCode: number; message: string };
}

interface RequestResult {
error: { message: string; code: number; statusCode: number } | null;
body: string | null;
headers: Record<string, string>;
unpacked: boolean;
statusCode: number;
error?: ErrnoException;
}

/**
Expand Down Expand Up @@ -50,17 +53,20 @@ class PendingConnection {

/** Connection refused at network level */
respond_with_refused(): void {
this._resolve!({ success: false, error: { code: 'ECONNREFUSED', statusCode: 500, message: 'Connection refused' } });
const err = Object.assign(new Error('Connection refused'), { code: 'ECONNREFUSED', statusCode: 500 }) as ErrnoException;
this._resolve!({ success: false, error: err });
}

/** Connection times out (unresponsive) */
respond_with_timeout(): void {
this._resolve!({ success: false, error: { code: 'ETIMEDOUT', statusCode: 500, message: 'Connection timed out' } });
const err = Object.assign(new Error('Connection timed out'), { code: 'ETIMEDOUT', statusCode: 500 }) as ErrnoException;
this._resolve!({ success: false, error: err });
}

/** DNS resolution fails */
respond_with_dns_error(): void {
this._resolve!({ success: false, error: { code: 'ENOTFOUND', statusCode: 500, message: 'DNS resolution failed' } });
const err = Object.assign(new Error('DNS resolution failed'), { code: 'ENOTFOUND', statusCode: 500 }) as ErrnoException;
this._resolve!({ success: false, error: err });
}
}

Expand All @@ -73,7 +79,7 @@ class PendingRequest {
url: URL;
path: string;
headers: Record<string, string>;
body: any;
body: string | null; // always a text representation; Buffer/ArrayBuffer bodies are toString'd by the mock
params: Record<string, string> | null;
timestamp: number;
_resolve: ((value: RequestResult) => void) | null;
Expand All @@ -83,14 +89,14 @@ class PendingRequest {
method: string,
uri: string,
headers?: Record<string, string>,
body?: any,
body?: RequestBody | null,
params?: Record<string, string> | null,
) {
this.method = method;
this.url = new URL(uri);
this.path = this.url.pathname;
this.headers = headers || {};
this.body = body;
this.body = body == null ? null : typeof body === 'string' ? body : body.toString();
this.params = params || null;
this.timestamp = Date.now();
this._resolve = null;
Expand All @@ -100,19 +106,20 @@ class PendingRequest {
}

/** Respond with an HTTP response */
respond_with(status: number, body: any, headers?: Record<string, string>): void {
respond_with(status: number, body: unknown, headers?: Record<string, string>): void {
const responseHeaders = headers || {};
const isError = status >= 400;
let error: RequestResult['error'] = null;

if (isError) {
// Extract error info from body if present
const errBody = typeof body === 'object' && body !== null && body.error ? body.error : null;
error = {
message: errBody ? errBody.message : `HTTP ${status}`,
code: errBody ? errBody.code : status * 100,
statusCode: errBody ? errBody.statusCode || status : status,
};
const bodyObj = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : null;
const errBody = (bodyObj?.error as { message?: string; code?: number; statusCode?: number } | null) ?? null;
error = new ErrorInfo(
errBody?.message ?? `HTTP ${status}`,
errBody?.code ?? status * 100,
errBody?.statusCode ?? status,
);
}

this._resolve!({
Expand All @@ -126,8 +133,10 @@ class PendingRequest {

/** Request times out after connection established */
respond_with_timeout(): void {
// code '408' (non-POSIX string) keeps shouldFallback() returning false
const err = Object.assign(new Error('Request timed out'), { code: '408', statusCode: 408 }) as ErrnoException;
this._resolve!({
error: { code: 408, statusCode: 408, message: 'Request timed out' } as any,
error: err,
body: null,
headers: {},
unpacked: false,
Expand Down Expand Up @@ -212,13 +221,13 @@ class MockHttpClient {
* Returns an object conforming to IPlatformHttpStatic that can be assigned
* to Platform.Http.
*/
asPlatformHttp(): any {
asPlatformHttp(): IPlatformHttpStatic {
const mock = this;

class MockPlatformHttp {
static methods = ['get', 'delete', 'post', 'put', 'patch'];
static methodsWithBody = ['post', 'put', 'patch'];
static methodsWithoutBody = ['get', 'delete'];
static methods: HttpMethods[] = [HttpMethods.Get, HttpMethods.Delete, HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch];
static methodsWithBody: HttpMethods[] = [HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch];
static methodsWithoutBody: HttpMethods[] = [HttpMethods.Get, HttpMethods.Delete];

supportsAuthHeaders: boolean;
supportsLinkHeaders: boolean;
Expand All @@ -229,11 +238,11 @@ class MockHttpClient {
}

async doUri(
method: string,
method: HttpMethods,
uri: string,
headers: Record<string, string>,
body: any,
params: Record<string, string>,
headers: Record<string, string> | null,
body: RequestBody | null,
params: RequestParams,
): Promise<RequestResult> {
// Phase 1: Connection attempt
let parsedUrl: URL;
Expand All @@ -252,7 +261,7 @@ class MockHttpClient {
parsedUrl = new URL(fullUri);
} catch (e) {
return {
error: { message: 'Invalid URI: ' + uri, statusCode: 400, code: 40000 },
error: new ErrorInfo('Invalid URI: ' + uri, 40000, 400),
body: null,
headers: {},
unpacked: false,
Expand All @@ -279,11 +288,11 @@ class MockHttpClient {
const connResult = await conn._promise;

if (!connResult.success) {
return { error: connResult.error as any, body: null, headers: {}, unpacked: false, statusCode: 0 };
return { error: connResult.error ?? null, body: null, headers: {}, unpacked: false, statusCode: 0 };
}

// Phase 2: HTTP request (use parsedUrl which includes params)
const req = new PendingRequest(method, parsedUrl.href, headers, body, params);
const req = new PendingRequest(method, parsedUrl.href, headers ?? undefined, body, params);
mock.captured_requests.push(req);

// Notify handler or waiter
Expand All @@ -302,13 +311,13 @@ class MockHttpClient {
async checkConnectivity(): Promise<boolean> {
// Perform the connectivity check via doUri (same as real implementation)
const url = 'https://internet-up.ably-realtime.com/is-the-internet-up.txt';
const { error, body } = await this.doUri('get', url, {}, null, null as any);
const { error, body } = await this.doUri(HttpMethods.Get, url, {}, null, null);
return !error && (body as string)?.toString().trim() === 'yes';
}

shouldFallback(error: any): boolean {
shouldFallback(error: RequestResultError): boolean {
if (!error) return false;
const code = error.code;
const code = (error as ErrnoException).code;
const statusCode = error.statusCode;
if (
code === 'ECONNREFUSED' ||
Expand All @@ -320,11 +329,11 @@ class MockHttpClient {
) {
return true;
}
return statusCode >= 500 && statusCode <= 504;
return (statusCode ?? 0) >= 500 && (statusCode ?? 0) <= 504;
}
}

return MockPlatformHttp;
return MockPlatformHttp as unknown as IPlatformHttpStatic;
}
}

Expand Down
18 changes: 9 additions & 9 deletions test/uts/realtime/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
*/

import { expect } from 'chai';
import { MockHttpClient } from '../mock_http';
import { Ably, installMockHttp, restoreAll } from '../helpers';
import { MockHttpClient, PendingRequest } from '../mock_http';
import { Ably, ErrorInfo, installMockHttp, restoreAll } from '../helpers';

describe('uts/realtime/time', function () {
let mock;
Expand All @@ -24,7 +24,7 @@ describe('uts/realtime/time', function () {
* RTC6a - time() returns server time (proxied from REST)
*/
it('RTC6a - time() returns server time', async function () {
const captured: any[] = [];
const captured: PendingRequest[] = [];
const serverTimeMs = 1704067200000;

mock = new MockHttpClient({
Expand All @@ -51,7 +51,7 @@ describe('uts/realtime/time', function () {
* RTC6a - time() request format (proxied from REST)
*/
it('RTC6a - time() request format', async function () {
const captured: any[] = [];
const captured: PendingRequest[] = [];

mock = new MockHttpClient({
onConnectionAttempt: (conn) => conn.respond_with_success(),
Expand Down Expand Up @@ -80,7 +80,7 @@ describe('uts/realtime/time', function () {
* RTC6a - time() does not require authentication (proxied from REST)
*/
it('RTC6a - time() does not require authentication', async function () {
const captured: any[] = [];
const captured: PendingRequest[] = [];

mock = new MockHttpClient({
onConnectionAttempt: (conn) => conn.respond_with_success(),
Expand All @@ -104,7 +104,7 @@ describe('uts/realtime/time', function () {
* RTC6a - time() works without TLS (proxied from REST)
*/
it('RTC6a - time() works without TLS', async function () {
const captured: any[] = [];
const captured: PendingRequest[] = [];

mock = new MockHttpClient({
onConnectionAttempt: (conn) => conn.respond_with_success(),
Expand Down Expand Up @@ -153,9 +153,9 @@ describe('uts/realtime/time', function () {
try {
await client.time();
expect.fail('Expected time() to throw');
} catch (error: any) {
expect(error.statusCode).to.equal(500);
expect(error.code).to.equal(50000);
} catch (error) {
expect((error as ErrorInfo).statusCode).to.equal(500);
expect((error as ErrorInfo).code).to.equal(50000);
}
});
});
Loading
Loading