Files
6628ce177d feat(data-connect): Align SQL Connect Stream Transport Exponential Backoff Parameters with Other Platform SDKs (#10221)
* udpate exponential backoff params to match other SDKs

* add changeset

* update changeset

* Apply reviewer suggestion

* yarn run format

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 13:48:25 -07:00

2166 lines
84 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect } from 'chai';
import * as chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import * as sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { DataConnectOptions } from '../../src/api/DataConnect';
import { Code, DataConnectError } from '../../src/core/error';
import { AuthTokenProvider } from '../../src/core/FirebaseAuthProvider';
import * as logger from '../../src/logger';
import {
CallerSdkType,
CallerSdkTypeEnum,
DataConnectResponse,
getGoogApiClientValue
} from '../../src/network';
import {
AbstractDataConnectStreamTransport,
InvokeOperationPromise
} from '../../src/network/stream/streamTransport';
import {
DataConnectStreamRequest,
ExecuteStreamRequest,
ResumeStreamRequest,
SubscribeStreamRequest
} from '../../src/network/stream/wire';
import { expectIsNotSettled, flushMicrotasks, sleep } from './testUtils';
chai.use(sinonChai);
chai.use(chaiAsPromised);
class TestStreamTransport extends AbstractDataConnectStreamTransport {
private isTestConnected = true;
get streamIsReady(): boolean {
return this.isTestConnected;
}
get endpointUrl(): string {
return 'https://endpoint.url';
}
protected openConnection(): Promise<void> {
this.isTestConnected = true;
return Promise.resolve();
}
protected closeConnection(): Promise<void> {
this.isTestConnected = false;
return Promise.resolve();
}
protected ensureConnection(): Promise<void> {
this.isTestConnected = true;
return Promise.resolve();
}
protected sendMessage<Variables>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requestBody: DataConnectStreamRequest<Variables>
): Promise<void> {
return Promise.resolve();
}
/** Manually trigger onConnectionReady for testing purposes. */
triggerOnConnectionReady(): void {
this.onConnectionReady();
}
/** Manually invoke token change for testing purposes. */
invokeOnAuthTokenChanged(token: string | null): void {
this.onAuthTokenChanged(token);
}
/** Manually set auth token for testing purposes. */
setAuthToken(token: string | null): void {
this._authToken = token;
}
authProvider = {
getAuth: () => ({ getUid: () => this._authToken }),
getToken: (forceToken?: boolean) =>
Promise.resolve({ accessToken: this._authToken || 'token' })
} as unknown as AuthTokenProvider;
/** Manually set app check token for testing purposes. */
setAppCheckToken(token: string | null): void {
this._appCheckToken = token;
}
/** Manually resolve a request for testing purposes. */
invokeHandleResponse<Data>(
requestId: string,
response: DataConnectResponse<Data>
): Promise<void> {
return this.handleResponse(requestId, response);
}
}
/** Interface that exposes some private fields and methods of TestStreamTransport for testing purposes. */
interface TransportWithInternals {
_connectorResourcePath: string;
_isUsingGen: boolean;
appId: string | undefined;
_callerSdkType: CallerSdkType;
_setCallerSdkType(type: CallerSdkType): void;
invokeOnAuthTokenChanged(token: string | null): void;
setAuthToken(token: string | null): void;
authProvider: { getAuth: () => { getUid(): string | null } };
setAppCheckToken(token: string | null): void;
nextRequestId(): string;
triggerOnConnectionReady(): void;
closeConnection(): Promise<void>;
cancelClose(): void;
cleanupAndTerminate(code?: Code, reason?: string): Promise<void>;
sendRequestMessage<Variables>(
requestBody: DataConnectStreamRequest<Variables>
): Promise<void>;
getWithAuth(forceToken?: boolean): Promise<string | null>;
hasWaitedForInitialAuth: boolean;
prepareMessage<
Variables,
StreamBody extends DataConnectStreamRequest<Variables>
>(
requestBody: StreamBody
): StreamBody;
activeInvokeQueryRequests: Map<
string,
ExecuteStreamRequest<unknown> | ResumeStreamRequest
>;
queuedInvokeQueryRequests: Map<string, InvokeOperationPromise<unknown>>;
activeInvokeMutationRequests: Map<
string,
Array<ExecuteStreamRequest<unknown>>
>;
activeInvokeSubscribeRequests: Map<string, SubscribeStreamRequest<unknown>>;
executeRequestPromises: Map<string, InvokeOperationPromise<unknown>>;
resumeRequestPromises: Map<string, InvokeOperationPromise<unknown>>;
pendingCancellations: Map<
string,
{ operationName: string; variables: unknown }
>;
subscribeObservers: Map<string, unknown>;
getMapKey(operationName: string, variables?: unknown): string;
invokeQuery<_Data, Variables>(
queryName: string,
variables?: Variables
): Promise<unknown>;
invokeMutation<_Data, Variables>(
mutationName: string,
variables?: Variables
): Promise<unknown>;
invokeSubscribe<_Data, Variables>(
notify: unknown,
queryName: string,
variables: Variables
): void;
invokeUnsubscribe<Variables>(queryName: string, variables: Variables): void;
sendMessage<Variables>(
requestBody: DataConnectStreamRequest<Variables>
): Promise<void>;
invokeHandleResponse<Data>(
requestId: string,
response: DataConnectResponse<Data>
): Promise<void>;
onStreamClose(code: number, reason: string): void;
reconnectTimer: NodeJS.Timeout | null;
onOnline(): void;
onVisibilityChange(): void;
ensureConnection(): Promise<void>;
dispose(): Promise<void>;
attemptReconnect(): void;
onOnlineEventListener(): void;
onVisibilityChangeEventListener(): void;
onConnectionReady(): void;
isTestConnected: boolean;
}
describe('AbstractDataConnectStreamTransport', () => {
const dcOptions: DataConnectOptions = {
projectId: 'p',
location: 'l',
service: 's',
connector: 'c'
};
let transport: TransportWithInternals;
let expectedName: string;
let expectedInitialGoogApiClientValue: string;
const firstRequestId = '1';
const unpreparedMessage: DataConnectStreamRequest<unknown> = {
requestId: firstRequestId,
execute: { operationName: 'test' }
};
const initialAuthToken = 'initial-auth-token';
const newAuthToken = 'new-auth-token';
const initialAppCheckToken = 'initial-app-check-token';
const newAppCheckToken = 'new-app-check-token';
const initialAppId = 'initial-app-id';
const newAppId = 'new-app-id';
const queryName1 = 'testQuery1';
const queryName2 = 'testQuery2';
const variables1 = { foo: 'bar', num: 1 };
const variables2 = { abc: 'xyz', num: 2 };
const mutationName1 = 'testMutation1';
const mutationName2 = 'testMutation2';
const expectedError = new Error('test error');
beforeEach(() => {
transport = new TestStreamTransport(
dcOptions
) as unknown as TransportWithInternals;
expectedName = transport._connectorResourcePath;
transport._isUsingGen = true;
transport._callerSdkType = CallerSdkTypeEnum.Generated;
expectedInitialGoogApiClientValue = getGoogApiClientValue(
transport._isUsingGen,
transport._callerSdkType
);
transport.setAuthToken(initialAuthToken);
transport.setAppCheckToken(initialAppCheckToken);
transport.appId = initialAppId;
transport.hasWaitedForInitialAuth = true;
});
afterEach(() => {
sinon.restore();
});
describe('sendRequestMessage', () => {
it('should wait until auth token and app check token have been initialized before sending the message', async () => {
transport.hasWaitedForInitialAuth = false;
let resolveAuth!: () => void;
const authPromise = new Promise<string | null>(resolve => {
resolveAuth = () => resolve('token');
});
const getWithAuthStub = sinon
.stub(transport, 'getWithAuth')
.returns(authPromise);
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promise = transport.sendRequestMessage(unpreparedMessage);
expect(getWithAuthStub).to.have.been.calledOnce;
expect(sendMessageSpy).to.not.have.been.called;
resolveAuth();
await promise;
expect(sendMessageSpy).to.have.been.calledOnce;
expect(sendMessageSpy).to.have.been.calledAfter(getWithAuthStub);
});
});
describe('prepareMessage', () => {
it('should not change data fields', () => {
const preparedMessage = transport.prepareMessage(unpreparedMessage);
expect(preparedMessage.requestId).to.equal(unpreparedMessage.requestId);
expect(preparedMessage.execute).to.equal(unpreparedMessage.execute);
expect(preparedMessage.resume).to.equal(unpreparedMessage.resume);
expect(preparedMessage.subscribe).to.equal(unpreparedMessage.subscribe);
expect(preparedMessage.cancel).to.equal(unpreparedMessage.cancel);
expect(preparedMessage.dataEtag).to.equal(unpreparedMessage.dataEtag);
});
describe('should handle headers properly', () => {
describe('auth token', () => {
it('should add auth token to the first message', () => {
const preparedMessage = transport.prepareMessage(unpreparedMessage);
expect(preparedMessage.headers).to.exist;
expect(preparedMessage.headers?.['X-Firebase-Auth-Token']).to.equal(
initialAuthToken
);
});
it('should NOT add the same auth token to subsequent messages', () => {
transport.prepareMessage(unpreparedMessage);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['X-Firebase-Auth-Token']).to.be
.undefined;
});
it('should include auth token when it changes', () => {
transport.prepareMessage(unpreparedMessage);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['X-Firebase-Auth-Token']).to.be
.undefined;
transport.setAuthToken(newAuthToken);
const thirdPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(
thirdPreparedMessage.headers?.['X-Firebase-Auth-Token']
).to.equal(newAuthToken);
});
});
describe('app check token', () => {
it('should add app check token to the first message', () => {
const firstPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(firstPreparedMessage.headers).to.exist;
expect(
firstPreparedMessage.headers?.['X-Firebase-App-Check']
).to.equal(initialAppCheckToken);
});
it('should NOT add the same app check token to subsequent messages', () => {
transport.prepareMessage(unpreparedMessage);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['X-Firebase-App-Check']).to.be
.undefined;
});
it('should NOT include app check token when it changes', () => {
transport.prepareMessage(unpreparedMessage);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['X-Firebase-App-Check']).to.be
.undefined;
transport.setAppCheckToken(newAppCheckToken);
const thirdPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(thirdPreparedMessage.headers?.['X-Firebase-App-Check']).to.be
.undefined;
});
});
describe('x-firebase-gmpid', () => {
it('should add x-firebase-gmpid to every message if appId is set', () => {
const firstPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(firstPreparedMessage.headers).to.exist;
expect(firstPreparedMessage.headers?.['x-firebase-gmpid']).to.equal(
initialAppId
);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['x-firebase-gmpid']).to.equal(
initialAppId
);
transport.appId = newAppId;
const thirdPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(thirdPreparedMessage.headers?.['x-firebase-gmpid']).to.equal(
newAppId
);
});
});
it('should add X-Goog-Api-Client to every message based on caller sdk type', () => {
const firstPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(firstPreparedMessage.headers?.['X-Goog-Api-Client']).to.equal(
expectedInitialGoogApiClientValue
);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.headers?.['X-Goog-Api-Client']).to.equal(
expectedInitialGoogApiClientValue
);
transport._isUsingGen = true;
transport._setCallerSdkType(CallerSdkTypeEnum.GeneratedReact);
const expectedThirdGoogApiClientValue = getGoogApiClientValue(
true,
CallerSdkTypeEnum.GeneratedReact
);
const thirdPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(thirdPreparedMessage.headers?.['X-Goog-Api-Client']).to.equal(
expectedThirdGoogApiClientValue
);
});
});
describe('should handle name properly', () => {
it('should add name to the first message', () => {
const firstPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(firstPreparedMessage.name).to.equal(expectedName);
});
it('should NOT add name to subsequent messages', () => {
transport.prepareMessage(unpreparedMessage);
const secondPreparedMessage =
transport.prepareMessage(unpreparedMessage);
expect(secondPreparedMessage.name).to.be.undefined;
});
});
it('should reset connection state on onConnectionReady()', () => {
// First message receives the initial payload (name, appCheckToken, authToken)
transport.prepareMessage(unpreparedMessage);
// Second message should not have any of these initial fields
const secondMessage = transport.prepareMessage(unpreparedMessage);
expect(secondMessage.name).to.be.undefined;
expect(secondMessage.headers?.['X-Firebase-App-Check']).to.be.undefined;
expect(secondMessage.headers?.['X-Firebase-Auth-Token']).to.be.undefined;
// Trigger the physical connection reset
transport.triggerOnConnectionReady();
// The next message should be treated as a "first" message again
const thirdMessage = transport.prepareMessage(unpreparedMessage);
expect(thirdMessage.name).to.equal(expectedName);
expect(thirdMessage.headers?.['X-Firebase-App-Check']).to.equal(
initialAppCheckToken
);
expect(thirdMessage.headers?.['X-Firebase-Auth-Token']).to.equal(
initialAuthToken
);
});
});
describe('Request Tracking', () => {
it('getMapKey should sort keys consistently for map lookups', () => {
const queryName = 'sortQuery';
const variables1 = { a: 1, b: 2, c: 3, d: 4 };
const variables2 = { b: 2, a: 1, d: 4, c: 3 };
expect(transport.getMapKey(queryName, variables1)).to.equal(
transport.getMapKey(queryName, variables2)
);
});
it('should generate unique request IDs', () => {
const id1 = transport.nextRequestId();
const id2 = transport.nextRequestId();
expect(id1).to.not.equal(id2);
expect(Number(id1)).to.be.lessThan(Number(id2));
});
describe('Incoming Requests from Transport Layer', () => {
describe('invokeQuery', () => {
it('should populate tracking maps synchronously and then call sendMessage', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const queryPromise = transport.invokeQuery(queryName1, variables1);
const expectedKey = transport.getMapKey(queryName1, variables1);
expect(transport.activeInvokeQueryRequests.has(expectedKey)).to.be
.true;
const request = transport.activeInvokeQueryRequests.get(expectedKey);
expect(request?.execute?.operationName).to.equal(queryName1);
expect(request?.execute?.variables).to.deep.equal(variables1);
const requestId = request!.requestId;
expect(transport.executeRequestPromises.has(requestId)).to.be.true;
expect(sendMessageSpy).to.have.been.calledOnce;
const sentMessage = sendMessageSpy.firstCall.args[0];
expect(sentMessage.requestId).to.equal(requestId);
expect(sentMessage.execute).to.not.be.undefined;
expect(sentMessage.execute?.operationName).to.equal(queryName1);
expect(sentMessage.execute?.variables).to.deep.equal(variables1);
await expectIsNotSettled(queryPromise);
});
it('should asynchronously clean up and reject if sendMessage fails', async () => {
const sendMessageStub = sinon
.stub(transport, 'sendMessage')
.rejects(expectedError);
const queryPromise = transport.invokeQuery(queryName1, variables1);
await expect(queryPromise).to.be.rejectedWith(expectedError);
const mapKey = transport.getMapKey(queryName1, variables1);
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be.false;
const requestId = sendMessageStub.firstCall.args[0].requestId;
expect(transport.executeRequestPromises.has(requestId)).to.be.false;
});
describe('optimizations', () => {
describe('prefer resume over execute when executing already subscribed queries', () => {
it('should send a resume payload if there is an active subscription', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledOnce;
const subscribeMessage = sendMessageSpy.firstCall.args[0];
expect(subscribeMessage.subscribe).to.not.be.undefined;
const subscribeRequestId = subscribeMessage.requestId;
const queryPromise = transport.invokeQuery(
queryName1,
variables1
);
expect(sendMessageSpy).to.have.been.calledTwice;
const resumeMessage = sendMessageSpy.secondCall.args[0];
expect(resumeMessage.resume).to.not.be.undefined;
expect(resumeMessage.requestId).to.equal(subscribeRequestId);
const response = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(
subscribeRequestId,
response
);
expect(await queryPromise).to.deep.equal(response);
});
it('should resolve resume promise even if observer callback throws', async () => {
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.stub().throws(new Error('Callback crashed')),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const subscribeRequestId =
transport.activeInvokeSubscribeRequests.get(
transport.getMapKey(queryName1, variables1)
)!.requestId;
const queryPromise = transport.invokeQuery(
queryName1,
variables1
);
const response = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(
subscribeRequestId,
response
);
const result = await queryPromise;
expect(result).to.deep.equal(response);
});
});
describe('de-duplication of identical requests', () => {
it('should send the first request and queue subsequent requests when they are identical', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledOnce;
const sentMessage = sendMessageSpy.firstCall.args[0];
expect(sentMessage.execute?.operationName).to.equal(queryName1);
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.true;
const activeRequest =
transport.activeInvokeQueryRequests.get(mapKey);
expect(activeRequest?.execute?.operationName).to.equal(
queryName1
);
const queuedMap = transport.queuedInvokeQueryRequests;
expect(queuedMap.has(mapKey)).to.be.true;
// promises 1 and 2 should be different, but 2-20 should be the same!
expect(promises[0]).to.not.equal(promises[1]);
for (let i = 1; i < 20; i++) {
expect(promises[i]).to.equal(promises[1]);
}
});
it('should resolve only the first request when it completes', async () => {
sinon.stub(transport, 'sendMessage').resolves();
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const activeRequest =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId1 = activeRequest!.requestId;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId1, response1);
// verify promise 1 resolved, but other promises are not yet settled
const result1 = await promises[0];
expect(result1).to.deep.equal(response1);
for (let i = 1; i < 20; i++) {
await expectIsNotSettled(promises[i], 100);
}
});
it('should send the next request and clear queue when first request completes', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const activeRequest =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId1 = activeRequest!.requestId;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId1, response1);
// verify queued request was popped + sent
expect(transport.queuedInvokeQueryRequests.has(mapKey)).to.be
.false;
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.true;
expect(sendMessageSpy).to.have.been.calledTwice;
const secondSentMessage = sendMessageSpy.secondCall.args[0];
expect(secondSentMessage.execute?.operationName).to.equal(
queryName1
);
expect(secondSentMessage.requestId).to.not.equal(requestId1);
});
it('should send the next request even if the first request fails with response errors', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const activeRequest =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId1 = activeRequest!.requestId;
const promise1 = promises[0];
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.true;
expect(transport.queuedInvokeQueryRequests.has(mapKey)).to.be
.true;
const errorResponse = {
data: null,
errors: [new Error('Query failed')],
extensions: {}
};
await transport.invokeHandleResponse(requestId1, errorResponse);
await expect(promise1).to.be.rejectedWith(
/DataConnect error while performing request/
);
expect(transport.queuedInvokeQueryRequests.has(mapKey)).to.be
.false;
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.true;
expect(sendMessageSpy).to.have.been.calledTwice;
// queued request should not be affected by the failure of request 1
const secondSentMessage = sendMessageSpy.secondCall.args[0];
expect(secondSentMessage.execute?.operationName).to.equal(
queryName1
);
expect(secondSentMessage.requestId).to.not.equal(requestId1);
await expectIsNotSettled(promises[1], 100);
const activeRequest2 =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId2 = activeRequest2!.requestId;
const response2 = {
data: { result: '2' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId2, response2);
const result2 = await promises[1];
expect(result2).to.deep.equal(response2);
});
it('should send the next request even if the first request fails with errors', async () => {
const sendMessageStub = sinon.stub(transport, 'sendMessage');
sendMessageStub.onFirstCall().rejects(expectedError);
sendMessageStub.onSecondCall().resolves();
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const activeRequest =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId1 = activeRequest!.requestId;
await expect(promises[0]).to.be.rejectedWith(expectedError);
expect(transport.queuedInvokeQueryRequests.has(mapKey)).to.be
.false;
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.true;
expect(sendMessageStub).to.have.been.calledTwice;
const secondSentMessage = sendMessageStub.secondCall.args[0];
expect(secondSentMessage.execute?.operationName).to.equal(
queryName1
);
expect(secondSentMessage.requestId).to.not.equal(requestId1);
});
it('should resolve all waiting promises when a queued request completes', async () => {
sinon.stub(transport, 'sendMessage').resolves();
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const activeRequest1 =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId1 = activeRequest1!.requestId;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId1, response1);
const activeRequest2 =
transport.activeInvokeQueryRequests.get(mapKey);
const requestId2 = activeRequest2!.requestId;
expect(requestId2).to.not.equal(requestId1);
const response2 = {
data: { result: '2' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId2, response2);
// verify all queued promises resolved to response2, and nothing active or in queue
for (let i = 1; i < 20; i++) {
const result = await promises[i];
expect(result).to.deep.equal(response2);
}
expect(transport.activeInvokeQueryRequests.has(mapKey)).to.be
.false;
expect(transport.queuedInvokeQueryRequests.has(mapKey)).to.be
.false;
});
it('should de-duplicate identical resume requests', async () => {
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const promises = [];
for (let i = 1; i <= 20; i++) {
promises.push(transport.invokeQuery(queryName1, variables1));
}
const mapKey = transport.getMapKey(queryName1, variables1);
const queuedMap = transport.queuedInvokeQueryRequests;
expect(queuedMap.has(mapKey)).to.be.true;
// promises 1 and 2 should be different, but 2-20 should be the same!
expect(promises[0]).to.not.equal(promises[1]);
for (let i = 1; i < 20; i++) {
expect(promises[i]).to.equal(promises[1]);
}
});
it('should execute first, and then pop as resume if subscribed while waiting', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const queryPromise1 = transport.invokeQuery(
queryName1,
variables1
);
expect(sendMessageSpy).to.have.been.calledOnce;
const executeMessage = sendMessageSpy.firstCall.args[0];
expect(executeMessage.execute).to.not.be.undefined;
const executeRequestId = executeMessage.requestId;
void transport.invokeQuery(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledOnce;
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledTwice;
const subscribeRequestId =
sendMessageSpy.secondCall.args[0].requestId;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(executeRequestId, response1);
await queryPromise1;
expect(sendMessageSpy).to.have.been.calledThrice;
const resumeMessage = sendMessageSpy.thirdCall.args[0];
expect(resumeMessage.resume).to.not.be.undefined;
expect(resumeMessage.requestId).to.equal(subscribeRequestId);
});
it('should resume first, and then pop as execute if unsubscribed while waiting', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledOnce;
const subscribeRequestId =
sendMessageSpy.firstCall.args[0].requestId;
const queryPromise1 = transport.invokeQuery(
queryName1,
variables1
);
expect(sendMessageSpy).to.have.been.calledTwice;
void transport.invokeQuery(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledTwice;
transport.invokeUnsubscribe(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledTwice;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(
subscribeRequestId,
response1
);
await queryPromise1;
expect(sendMessageSpy.callCount).to.equal(4);
const cancelMessage = sendMessageSpy.getCall(2).args[0];
expect(cancelMessage.cancel).to.not.be.undefined;
const executeMessage = sendMessageSpy.getCall(3).args[0];
expect(executeMessage.execute).to.not.be.undefined;
expect(executeMessage.requestId).to.not.equal(subscribeRequestId);
});
});
});
});
describe('invokeMutation', () => {
it('should populate tracking maps synchronously and then call sendMessage', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const mutationPromise = transport.invokeMutation(
mutationName1,
variables1
);
const expectedKey = transport.getMapKey(mutationName1, variables1);
const requests =
transport.activeInvokeMutationRequests.get(expectedKey);
expect(requests).to.have.lengthOf(1);
expect(requests![0].execute?.operationName).to.equal(mutationName1);
const requestId = requests![0].requestId;
expect(transport.executeRequestPromises.has(requestId)).to.be.true;
expect(sendMessageSpy).to.have.been.calledOnce;
const sentMessage = sendMessageSpy.firstCall.args[0];
expect(sentMessage.requestId).to.equal(requestId);
expect(sentMessage.execute).to.not.be.undefined;
expect(sentMessage.execute?.operationName).to.equal(mutationName1);
expect(sentMessage.execute?.variables).to.deep.equal(variables1);
await expectIsNotSettled(mutationPromise);
});
it('should asynchronously clean up and reject if sendMessage fails', async () => {
const sendMessageStub = sinon
.stub(transport, 'sendMessage')
.rejects(expectedError);
const mutationPromise = transport.invokeMutation(
mutationName1,
variables1
);
await expect(mutationPromise).to.be.rejectedWith(expectedError);
const mapKey = transport.getMapKey(mutationName1, variables1);
expect(transport.activeInvokeMutationRequests.has(mapKey)).to.be
.false;
const requestId = sendMessageStub.firstCall.args[0].requestId;
expect(transport.executeRequestPromises.has(requestId)).to.be.false;
});
describe('de-duplication', () => {
it('should NOT de-duplicate identical mutation requests', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promises = [];
for (let i = 1; i <= 5; i++) {
promises.push(
transport.invokeMutation(mutationName1, variables1)
);
}
expect(sendMessageSpy.callCount).to.equal(5);
const mapKey = transport.getMapKey(mutationName1, variables1);
const requests = transport.activeInvokeMutationRequests.get(mapKey);
expect(requests).to.have.lengthOf(5);
for (let i = 0; i < 5; i++) {
for (let j = i + 1; j < 5; j++) {
expect(promises[i]).to.not.equal(promises[j]);
}
}
});
it('mutation requests should resolve totally independent of one another', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const promise1 = transport.invokeMutation(
mutationName1,
variables1
);
const promise2 = transport.invokeMutation(
mutationName1,
variables1
);
const promise3 = transport.invokeMutation(
mutationName1,
variables1
);
expect(sendMessageSpy.callCount).to.equal(3);
const mapKey = transport.getMapKey(mutationName1, variables1);
const requests = transport.activeInvokeMutationRequests.get(mapKey);
expect(requests).to.have.lengthOf(3);
const requestId1 = requests![0].requestId;
const requestId2 = requests![1].requestId;
const requestId3 = requests![2].requestId;
const response1 = {
data: { result: '1' },
errors: [],
extensions: {}
};
const response2 = {
data: { result: '2' },
errors: [],
extensions: {}
};
const response3 = {
data: { result: '3' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId1, response1);
expect(await promise1).to.deep.equal(response1);
await expectIsNotSettled(promise2, 100);
await expectIsNotSettled(promise3, 100);
await transport.invokeHandleResponse(requestId2, response2);
expect(await promise1).to.deep.equal(response1);
expect(await promise2).to.deep.equal(response2);
await expectIsNotSettled(promise3, 100);
await transport.invokeHandleResponse(requestId3, response3);
expect(await promise1).to.deep.equal(response1);
expect(await promise2).to.deep.equal(response2);
expect(await promise3).to.deep.equal(response3);
expect(transport.activeInvokeMutationRequests.has(mapKey)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId1)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId2)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId3)).to.be
.false;
});
});
});
describe('invokeSubscribe', () => {
it('should populate tracking maps synchronously and then call sendMessage', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const expectedKey = transport.getMapKey(queryName1, variables1);
expect(transport.activeInvokeSubscribeRequests.has(expectedKey)).to.be
.true;
const request =
transport.activeInvokeSubscribeRequests.get(expectedKey);
expect(request?.subscribe?.operationName).to.equal(queryName1);
expect(request?.subscribe?.variables).to.deep.equal(variables1);
const requestId = request!.requestId;
expect(transport.subscribeObservers.has(requestId)).to.be.true;
expect(sendMessageSpy).to.have.been.calledOnce;
const sentMessage = sendMessageSpy.firstCall.args[0];
expect(sentMessage.requestId).to.equal(requestId);
expect(sentMessage.subscribe).to.not.be.undefined;
expect(sentMessage.subscribe?.operationName).to.equal(queryName1);
expect(sentMessage.subscribe?.variables).to.deep.equal(variables1);
});
it('should de-duplicate identical subscribe requests', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer1 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
const observer2 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer1, queryName1, variables1);
transport.invokeSubscribe(observer2, queryName1, variables1);
expect(sendMessageSpy.callCount).to.equal(1);
});
it('should asynchronously call observer with error and clean up if sendMessage fails', async () => {
const sendMessageStub = sinon
.stub(transport, 'sendMessage')
.rejects(expectedError);
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
// invokeSubscribe's sendMessage is fire and forget
await sleep(500);
expect(observer.onError).to.have.been.calledOnce;
const result = observer.onError.firstCall.args[0];
expect(result).to.equal(expectedError);
const mapKey = transport.getMapKey(queryName1, variables1);
expect(transport.activeInvokeSubscribeRequests.has(mapKey)).to.be
.false;
const requestId = sendMessageStub.firstCall.args[0].requestId;
expect(transport.subscribeObservers.has(requestId)).to.be.false;
});
});
describe('invokeUnsubscribe', () => {
it('should de-populate tracking maps and call sendMessage', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const expectedKey = transport.getMapKey(queryName1, variables1);
expect(transport.activeInvokeSubscribeRequests.has(expectedKey)).to.be
.true;
const subscribeRequest =
transport.activeInvokeSubscribeRequests.get(expectedKey);
const subscribeRequestId = subscribeRequest!.requestId;
expect(transport.subscribeObservers.has(subscribeRequestId)).to.be
.true;
transport.invokeUnsubscribe(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledTwice;
const unsubscribeMessage = sendMessageSpy.secondCall.args[0];
expect(transport.activeInvokeSubscribeRequests.has(expectedKey)).to.be
.false;
expect(transport.subscribeObservers.has(subscribeRequestId)).to.be
.false;
expect(unsubscribeMessage.cancel).to.not.be.undefined;
});
it('should asynchronously clean up and log error if sendMessage fails', async () => {
const logErrorStub = sinon.stub(logger, 'logError');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
const sendMessageStub = sinon.stub(transport, 'sendMessage');
sendMessageStub.onFirstCall().resolves();
sendMessageStub.onSecondCall().rejects(expectedError);
transport.invokeSubscribe(observer, queryName1, variables1);
const expectedKey = transport.getMapKey(queryName1, variables1);
const subscribeRequest =
transport.activeInvokeSubscribeRequests.get(expectedKey);
const subscribeRequestId = subscribeRequest?.requestId!;
transport.invokeUnsubscribe(queryName1, variables1);
// invokeUnsubscribe's sendMessage is fire and forget
await sleep(500);
expect(sendMessageStub).to.have.been.calledTwice;
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
'Stream Transport failed to send unsubscribe message'
);
expect(transport.activeInvokeSubscribeRequests.has(expectedKey)).to.be
.false;
expect(transport.subscribeObservers.has(subscribeRequestId)).to.be
.false;
});
it('should defer cancellation if there is a pending resume request', async () => {
const sendMessageSpy = sinon.spy(transport, 'sendMessage');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const subscribeRequestId = sendMessageSpy.firstCall.args[0].requestId;
const queryPromise = transport.invokeQuery(queryName1, variables1);
expect(sendMessageSpy).to.have.been.calledTwice;
// Unsubscribe while resume is pending
transport.invokeUnsubscribe(queryName1, variables1);
// Should NOT have sent cancel message yet
expect(sendMessageSpy).to.have.been.calledTwice;
const response = {
data: { result: '1' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(subscribeRequestId, response);
expect(await queryPromise).to.deep.equal(response);
// Now cancel message should be sent
expect(sendMessageSpy).to.have.been.calledThrice;
const cancelMessage = sendMessageSpy.thirdCall.args[0];
expect(cancelMessage.cancel).to.not.be.undefined;
expect(cancelMessage.requestId).to.equal(subscribeRequestId);
});
it('should resurrect a subscription if re-subscribed while pending cancellation', async () => {
sinon.stub(transport, 'sendMessage').resolves();
const observer1 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
const observer2 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer1, queryName1, variables1);
const subscribeRequestId =
transport.activeInvokeSubscribeRequests.get(
transport.getMapKey(queryName1, variables1)
)!.requestId;
// Start a resume to put it in flight
void transport.invokeQuery(queryName1, variables1);
// Unsubscribe -> goes to pendingCancellations
transport.invokeUnsubscribe(queryName1, variables1);
expect(transport.pendingCancellations.has(subscribeRequestId)).to.be
.true;
expect(transport.subscribeObservers.has(subscribeRequestId)).to.be
.false;
// Re-subscribe -> should resurrect!
transport.invokeSubscribe(observer2, queryName1, variables1);
expect(transport.pendingCancellations.has(subscribeRequestId)).to.be
.false;
expect(transport.subscribeObservers.get(subscribeRequestId)).to.equal(
observer2
);
});
});
});
describe('Incoming Responses from Server', () => {
const expectedData1 = { result: 'result', num: 1 };
const expectedData2 = { result: 'result', num: 2 };
const expectedData3 = { result: 'result', num: 3 };
const expectedData4 = { result: 'result', num: 4 };
const response1: DataConnectResponse<typeof expectedData1> = {
data: expectedData1,
errors: [],
extensions: {}
};
const response2: DataConnectResponse<typeof expectedData2> = {
data: expectedData2,
errors: [],
extensions: {}
};
const response3: DataConnectResponse<typeof expectedData3> = {
data: expectedData3,
errors: [],
extensions: {}
};
const response4: DataConnectResponse<typeof expectedData4> = {
data: expectedData4,
errors: [],
extensions: {}
};
const errorResponse: DataConnectResponse<unknown> = {
data: {},
errors: [expectedError],
extensions: {}
};
it('should log an error if an unrecognized requestId is received', async () => {
const logErrorStub = sinon.stub(logger, 'logError');
const unknownRequestId = 'unknown-999';
const response: DataConnectResponse<unknown> = {
data: {},
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(unknownRequestId, response);
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
`Stream response contained unrecognized requestId '${unknownRequestId}'`
);
logErrorStub.restore();
});
describe('invokeQuery tracking', async () => {
it('should route data to resolve the correct query promise when response is received', async () => {
const queryPromise1 = transport.invokeQuery(queryName1, variables1);
const queryPromise2 = transport.invokeQuery(queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const request1 =
transport.activeInvokeQueryRequests.get(expectedKey1);
const requestId1 = request1!.requestId;
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request2 =
transport.activeInvokeQueryRequests.get(expectedKey2);
const requestId2 = request2!.requestId;
await transport.invokeHandleResponse(requestId1, response1);
const result1 = await queryPromise1;
expect(result1).to.deep.equal(response1);
await transport.invokeHandleResponse(requestId2, response2);
const result2 = await queryPromise2;
expect(result2).to.deep.equal(response2);
});
it('should clean map when response is received', async () => {
const queryPromise1 = transport.invokeQuery(queryName1, variables1);
const queryPromise2 = transport.invokeQuery(queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const request1 =
transport.activeInvokeQueryRequests.get(expectedKey1);
const requestId1 = request1!.requestId;
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request2 =
transport.activeInvokeQueryRequests.get(expectedKey2);
const requestId2 = request2!.requestId;
await transport.invokeHandleResponse(requestId1, response1);
await queryPromise1;
expect(transport.activeInvokeQueryRequests.has(expectedKey1)).to.be
.false;
expect(transport.activeInvokeQueryRequests.has(expectedKey2)).to.be
.true;
await transport.invokeHandleResponse(requestId2, response2);
await queryPromise2;
expect(transport.activeInvokeQueryRequests.has(expectedKey1)).to.be
.false;
expect(transport.activeInvokeQueryRequests.has(expectedKey2)).to.be
.false;
});
it('should reject the correct query promise with DataConnectOperationError if response has errors', async () => {
const queryPromise1 = transport.invokeQuery(queryName1, variables1);
const queryPromise2 = transport.invokeQuery(queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request1 =
transport.activeInvokeQueryRequests.get(expectedKey1);
const requestId1 = request1!.requestId;
const request2 =
transport.activeInvokeQueryRequests.get(expectedKey2);
const requestId2 = request2!.requestId;
await transport.invokeHandleResponse(requestId1, errorResponse);
await transport.invokeHandleResponse(requestId2, errorResponse);
await expect(queryPromise1).to.be.rejectedWith(
`DataConnect error while performing request: ${JSON.stringify([
expectedError
])}`
);
await expect(queryPromise2).to.be.rejectedWith(
`DataConnect error while performing request: ${JSON.stringify([
expectedError
])}`
);
});
it('should clean map correctly when handleResponse rejects', async () => {
void transport.invokeQuery(queryName1, variables1);
void transport.invokeQuery(queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const request1 =
transport.activeInvokeQueryRequests.get(expectedKey1);
const requestId1 = request1!.requestId;
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request2 =
transport.activeInvokeQueryRequests.get(expectedKey2);
const requestId2 = request2!.requestId;
await transport.invokeHandleResponse(requestId1, errorResponse);
expect(transport.activeInvokeQueryRequests.has(expectedKey1)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId1)).to.be.false;
expect(transport.activeInvokeQueryRequests.has(expectedKey2)).to.be
.true;
expect(transport.executeRequestPromises.has(requestId2)).to.be.true;
await transport.invokeHandleResponse(requestId2, errorResponse);
expect(transport.activeInvokeQueryRequests.has(expectedKey2)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId2)).to.be.false;
});
});
describe('invokeMutation tracking', async () => {
it('should route data to resolve the correct mutation promise when response is received', async () => {
const mutationPromise1 = transport.invokeMutation(
mutationName1,
variables1
);
const mutationPromise2 = transport.invokeMutation(
mutationName2,
variables2
);
const expectedKey1 = transport.getMapKey(mutationName1, variables1);
const activeRequests1 =
transport.activeInvokeMutationRequests.get(expectedKey1);
const requestId1 = activeRequests1![0].requestId;
const expectedKey2 = transport.getMapKey(mutationName2, variables2);
const activeRequests2 =
transport.activeInvokeMutationRequests.get(expectedKey2);
const requestId2 = activeRequests2![0].requestId;
await transport.invokeHandleResponse(requestId1, response1);
const result1 = await mutationPromise1;
expect(result1).to.deep.equal(response1);
await expectIsNotSettled(mutationPromise2);
await transport.invokeHandleResponse(requestId2, response2);
const result2 = await mutationPromise2;
expect(result2).to.deep.equal(response2);
});
it('should clean map of the correct tracked request when response is received', async () => {
const mutationPromise1 = transport.invokeMutation(
mutationName1,
variables1
);
const mutationPromise2 = transport.invokeMutation(
mutationName2,
variables2
);
const expectedKey1 = transport.getMapKey(mutationName1, variables1);
const activeRequests1 =
transport.activeInvokeMutationRequests.get(expectedKey1);
const requestId1 = activeRequests1![0].requestId;
const expectedKey2 = transport.getMapKey(mutationName2, variables2);
const activeRequests2 =
transport.activeInvokeMutationRequests.get(expectedKey2);
const requestId2 = activeRequests2![0].requestId;
await transport.invokeHandleResponse(requestId1, response1);
await mutationPromise1;
expect(transport.executeRequestPromises.has(requestId1)).to.be.false;
expect(transport.activeInvokeMutationRequests.has(expectedKey1)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId2)).to.be.true;
expect(transport.activeInvokeMutationRequests.has(expectedKey2)).to.be
.true;
await transport.invokeHandleResponse(requestId2, response2);
await mutationPromise2;
expect(transport.executeRequestPromises.has(requestId2)).to.be.false;
expect(transport.activeInvokeMutationRequests.has(expectedKey2)).to.be
.false;
});
it('should reject the correct mutation promise with DataConnectOperationError if response has errors', async () => {
const mutationPromise1 = transport.invokeMutation(
mutationName1,
variables1
);
const mutationPromise2 = transport.invokeMutation(
mutationName2,
variables2
);
const expectedKey1 = transport.getMapKey(mutationName1, variables1);
const activeRequests1 =
transport.activeInvokeMutationRequests.get(expectedKey1);
const requestId1 = activeRequests1![0].requestId;
const expectedKey2 = transport.getMapKey(mutationName2, variables2);
const activeRequests2 =
transport.activeInvokeMutationRequests.get(expectedKey2);
const requestId2 = activeRequests2![0].requestId;
await transport.invokeHandleResponse(requestId1, errorResponse);
await expect(mutationPromise1).to.be.rejectedWith(
`DataConnect error while performing request: ${JSON.stringify([
expectedError
])}`
);
await expectIsNotSettled(mutationPromise2);
await transport.invokeHandleResponse(requestId2, errorResponse);
await expect(mutationPromise2).to.be.rejectedWith(
`DataConnect error while performing request: ${JSON.stringify([
expectedError
])}`
);
});
it('should clean map correctly when handleResponse rejects', async () => {
transport.invokeMutation(mutationName1, variables1).catch(() => {});
transport.invokeMutation(mutationName2, variables2).catch(() => {});
const expectedKey1 = transport.getMapKey(mutationName1, variables1);
const expectedKey2 = transport.getMapKey(mutationName2, variables2);
const activeRequests1 =
transport.activeInvokeMutationRequests.get(expectedKey1);
const activeRequests2 =
transport.activeInvokeMutationRequests.get(expectedKey2);
const requestId1 = activeRequests1![0].requestId;
const requestId2 = activeRequests2![0].requestId;
await transport.invokeHandleResponse(requestId1, errorResponse);
expect(transport.activeInvokeMutationRequests.has(expectedKey1)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId1)).to.be.false;
expect(transport.activeInvokeMutationRequests.has(expectedKey2)).to.be
.true;
expect(transport.executeRequestPromises.has(requestId2)).to.be.true;
await transport.invokeHandleResponse(requestId2, errorResponse);
expect(transport.activeInvokeMutationRequests.has(expectedKey2)).to.be
.false;
expect(transport.executeRequestPromises.has(requestId2)).to.be.false;
});
});
describe('invokeSubscribe tracking', async () => {
const observer1 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
const observer2 = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
afterEach(() => {
observer1.onData.resetHistory();
observer1.onDisconnect.resetHistory();
observer1.onError.resetHistory();
observer2.onData.resetHistory();
observer2.onDisconnect.resetHistory();
observer2.onError.resetHistory();
});
it('should route data to the correct subscribe observer whenever a response is received', async () => {
transport.invokeSubscribe(observer1, queryName1, variables1);
transport.invokeSubscribe(observer2, queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const request1 =
transport.activeInvokeSubscribeRequests.get(expectedKey1);
const requestId1 = request1?.requestId!;
await transport.invokeHandleResponse(requestId1, response1);
expect(observer1.onData).to.have.been.calledOnce;
expect(observer1.onData).to.have.been.calledWithExactly(response1);
await transport.invokeHandleResponse(requestId1, response2);
expect(observer1.onData).to.have.been.calledTwice;
expect(observer1.onData).to.have.been.calledWithExactly(response2);
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request2 =
transport.activeInvokeSubscribeRequests.get(expectedKey2);
const requestId2 = request2?.requestId!;
await transport.invokeHandleResponse(requestId2, response3);
expect(observer2.onData).to.have.been.calledOnce;
expect(observer2.onData).to.have.been.calledWithExactly(response3);
await transport.invokeHandleResponse(requestId2, response4);
expect(observer2.onData).to.have.been.calledTwice;
expect(observer2.onData).to.have.been.calledWithExactly(response4);
});
it('should route error response to the correct subscribe observer whenever an error response is received', async () => {
transport.invokeSubscribe(observer1, queryName1, variables1);
transport.invokeSubscribe(observer2, queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const request1 =
transport.activeInvokeSubscribeRequests.get(expectedKey1);
const requestId1 = request1?.requestId!;
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request2 =
transport.activeInvokeSubscribeRequests.get(expectedKey2);
const requestId2 = request2?.requestId!;
await transport.invokeHandleResponse(requestId1, errorResponse);
expect(observer1.onData).to.have.been.calledOnce;
expect(observer1.onData).to.have.been.calledWithExactly(
errorResponse
);
await transport.invokeHandleResponse(requestId2, errorResponse);
expect(observer2.onData).to.have.been.calledOnce;
expect(observer2.onData).to.have.been.calledWithExactly(
errorResponse
);
});
it('should NOT clean map when handleResponse rejects', async () => {
transport.invokeSubscribe(observer1, queryName1, variables1);
transport.invokeSubscribe(observer2, queryName2, variables2);
const expectedKey1 = transport.getMapKey(queryName1, variables1);
const expectedKey2 = transport.getMapKey(queryName2, variables2);
const request1 =
transport.activeInvokeSubscribeRequests.get(expectedKey1);
const request2 =
transport.activeInvokeSubscribeRequests.get(expectedKey2);
const requestId1 = request1?.requestId!;
const requestId2 = request2?.requestId!;
await transport.invokeHandleResponse(requestId1, errorResponse);
await transport.invokeHandleResponse(requestId2, errorResponse);
expect(transport.activeInvokeSubscribeRequests.has(expectedKey1)).to
.be.true;
expect(transport.subscribeObservers.has(requestId1)).to.be.true;
expect(transport.activeInvokeSubscribeRequests.has(expectedKey2)).to
.be.true;
expect(transport.subscribeObservers.has(requestId2)).to.be.true;
});
});
});
});
describe('Disconnects', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
describe('Idle Timeout', () => {
const IDLE_CONNECTION_TIMEOUT_MS = 15 * 1000; // 15 seconds, copied from real implementation
it('should close connection after grace period when idle (no active subscriptions)', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
await transport.invokeSubscribe(observer, queryName1, variables1);
await transport.invokeUnsubscribe(queryName1, variables1);
expect(closeSpy).to.not.have.been.called;
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.have.been.calledOnce;
});
it('should not close connection if a new subscription arrives during timeout', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
await transport.invokeSubscribe(observer, queryName1, variables1);
await transport.invokeUnsubscribe(queryName1, variables1);
expect(closeSpy).to.not.have.been.called;
await transport.invokeSubscribe(observer, queryName2, variables2);
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.not.have.been.called;
});
it('should restart close timeout if subscribe fails and leaves no active subscriptions', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
const sendMessageStub = sinon.stub(transport, 'sendMessage');
sendMessageStub.resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
await transport.invokeSubscribe(observer, queryName1, variables1);
await transport.invokeUnsubscribe(queryName1, variables1);
sendMessageStub.rejects();
await transport.invokeSubscribe(observer, queryName2, variables2);
await flushMicrotasks(1);
expect(closeSpy).to.not.have.been.called;
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.have.been.calledOnce;
});
it('should not close connection if there are active execute requests', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
await transport.invokeSubscribe(observer, queryName1, variables1);
await transport.invokeUnsubscribe(queryName1, variables1);
void transport.invokeQuery(queryName2, variables2);
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.not.have.been.called;
});
it('should close connection when last execute request finishes after idle timeout', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
sinon.stub(transport, 'sendMessage').resolves();
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
await transport.invokeSubscribe(observer, queryName1, variables1);
await transport.invokeUnsubscribe(queryName1, variables1);
const queryPromise = transport.invokeQuery(queryName2, variables2);
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.not.have.been.called;
const expectedKey = transport.getMapKey(queryName2, variables2);
const request = transport.activeInvokeQueryRequests.get(expectedKey);
const requestId = request!.requestId;
const dummyResponse = {
data: { result: 'result' },
errors: [],
extensions: {}
};
await transport.invokeHandleResponse(requestId, dummyResponse);
await queryPromise;
// fast forward time again because the new idle timeout started when the query completed
await clock.tickAsync(IDLE_CONNECTION_TIMEOUT_MS + 100);
expect(closeSpy).to.have.been.calledOnce;
});
});
describe('Auth Disconnects', () => {
it('should close stream immediately on illegal auth change (login)', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.setAuthToken(null);
const getAuthStub = sinon.stub(transport.authProvider, 'getAuth');
getAuthStub.returns({ getUid: () => null });
transport.invokeOnAuthTokenChanged(null); // Establish baseline (unauth)
transport.invokeSubscribe(observer, queryName1, variables1);
transport.setAuthToken('new-token');
getAuthStub.returns({ getUid: () => 'some-uid' });
transport.invokeOnAuthTokenChanged('new-token'); // Change (login)
expect(closeSpy).to.have.been.calledOnce;
});
it('should close stream immediately on illegal auth change (logout)', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.setAuthToken('initial-token');
const getAuthStub = sinon.stub(transport.authProvider, 'getAuth');
getAuthStub.returns({ getUid: () => 'some-uid' });
transport.invokeOnAuthTokenChanged('initial-token'); // Establish baseline (auth)
transport.invokeSubscribe(observer, queryName1, variables1);
transport.setAuthToken(null);
getAuthStub.returns({ getUid: () => null });
transport.invokeOnAuthTokenChanged(null); // Change (logout)
expect(closeSpy).to.have.been.calledOnce;
});
it('should close stream immediately on illegal auth change (user change)', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.setAuthToken('token-a');
const getAuthStub = sinon.stub(transport.authProvider, 'getAuth');
getAuthStub.returns({ getUid: () => 'user-a' });
transport.invokeOnAuthTokenChanged('token-a'); // Establish baseline (user A)
transport.invokeSubscribe(observer, queryName1, variables1);
transport.setAuthToken('token-b');
getAuthStub.returns({ getUid: () => 'user-b' });
transport.invokeOnAuthTokenChanged('token-b'); // Change (user B)
expect(closeSpy).to.have.been.calledOnce;
});
it('should NOT close stream on valid auth token refresh (same user)', async () => {
const closeSpy = sinon.spy(transport, 'closeConnection');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.setAuthToken('initial-token');
// Stub getUid to return the same user ID regardless of token
sinon
.stub(transport.authProvider, 'getAuth')
.returns({ getUid: () => 'same-user' });
transport.invokeOnAuthTokenChanged('initial-token'); // Establish baseline
transport.invokeSubscribe(observer, queryName1, variables1);
transport.setAuthToken('refreshed-token');
transport.invokeOnAuthTokenChanged('refreshed-token'); // Refresh
expect(closeSpy).to.not.have.been.called;
});
});
it('should fail all active mutations on unexpected disconnect', async () => {
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
const mutationPromise = transport.invokeMutation(
mutationName1,
variables1
);
transport.onStreamClose(1006, 'Abnormal Closure');
await expect(mutationPromise).to.be.rejectedWith(
'Mutation aborted due to stream disconnect.'
);
expect(transport.activeInvokeMutationRequests.size).to.equal(0);
});
});
describe('Reconnects', () => {
let clock: sinon.SinonFakeTimers;
let sendMessageStub: sinon.SinonStub;
let ensureConnectionStub: sinon.SinonStub;
beforeEach(() => {
clock = sinon.useFakeTimers();
sendMessageStub = sinon.stub(transport, 'sendMessage').resolves();
ensureConnectionStub = sinon
.stub(transport, 'ensureConnection')
.callsFake(() => {
transport.isTestConnected = true;
return Promise.resolve();
});
});
afterEach(() => {
clock.restore();
sinon.restore();
});
describe('Automatic and Intelligent Reconnection', () => {
it('should start backoff on unexpected disconnect', async () => {
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
// Trigger unexpected disconnect (code 1006)
transport.onStreamClose(1006, 'Abnormal Closure');
// Should have started timer
expect(transport.reconnectTimer).to.not.be.null;
});
it('should NOT start backoff on graceful close with no subscriptions', async () => {
// No active subscriptions
transport.onStreamClose(1000, 'Normal Closure');
// Should NOT have started timer
expect(transport.reconnectTimer).to.be.null;
});
it('should re-request active subscriptions and queries on successful reconnect', async () => {
const attemptReconnectSpy = sinon.spy(transport, 'attemptReconnect');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
// Stagger an execute request
void transport.invokeQuery(queryName1, variables1);
sendMessageStub.resetHistory();
// Trigger unexpected disconnect
transport.onStreamClose(1006, 'Abnormal Closure');
// Fast forward time to trigger reconnect
await clock.tickAsync(2000); // Default is 1000 + jitter
expect(attemptReconnectSpy).to.have.been.calledOnce;
expect(ensureConnectionStub).to.have.been.calledOnce;
// Should re-send subscribe and then query
expect(sendMessageStub).to.have.been.calledTwice;
const firstSent = sendMessageStub.firstCall.args[0];
const secondSent = sendMessageStub.secondCall.args[0];
expect(firstSent.subscribe).to.not.be.undefined;
expect(secondSent.resume).to.not.be.undefined;
});
it('onOnline should trigger immediate reconnect if waiting', async () => {
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
transport.onStreamClose(1006, 'Abnormal Closure');
expect(transport.reconnectTimer).to.not.be.null;
transport.onOnlineEventListener();
expect(ensureConnectionStub).to.have.been.calledOnce;
expect(transport.reconnectTimer).to.be.null;
});
it('onVisibilityChange should trigger immediate reconnect if waiting', async () => {
const isBrowser = typeof window !== 'undefined';
if (!isBrowser) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).document = { visibilityState: 'visible' };
}
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
transport.onStreamClose(1006, 'Abnormal Closure');
expect(transport.reconnectTimer).to.not.be.null;
transport.onVisibilityChangeEventListener();
expect(ensureConnectionStub).to.have.been.calledOnce;
expect(transport.reconnectTimer).to.be.null;
if (!isBrowser) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (global as any).document;
}
});
it('should exponentially back off with 1.5 multiplier and [-0.5x, +0.5x] jitter capped at 60s', async () => {
ensureConnectionStub.rejects(
new DataConnectError(Code.OTHER, 'Connection failed')
);
const randomStub = sinon.stub(Math, 'random');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
// Attempt 1: base delay 1000ms, random=0 -> jitter -500ms -> delay 500ms
randomStub.returns(0);
transport.onStreamClose(1006, 'Abnormal Closure');
await clock.tickAsync(499);
expect(ensureConnectionStub).to.not.have.been.called;
// Set random=1 before attempt 1 failure triggers startReconnectBackoff for attempt 2
// Attempt 2: base delay 1500ms, random=1 -> jitter +750ms -> delay 2250ms
randomStub.returns(1);
await clock.tickAsync(1);
expect(ensureConnectionStub).to.have.been.calledOnce;
await clock.tickAsync(2249);
expect(ensureConnectionStub).to.have.been.calledOnce;
// Set random=0.5 before attempt 2 failure triggers startReconnectBackoff for attempt 3
// Attempt 3: base delay 2250ms, random=0.5 -> jitter 0ms -> delay 2250ms
randomStub.returns(0.5);
await clock.tickAsync(1);
expect(ensureConnectionStub).to.have.been.calledTwice;
await clock.tickAsync(2250);
expect(ensureConnectionStub).to.have.been.calledThrice;
});
it('should attempt reconnection indefinitely without a max attempt limit', async () => {
ensureConnectionStub.rejects(
new DataConnectError(Code.OTHER, 'Connection failed')
);
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
transport.onStreamClose(1006, 'Abnormal Closure');
// Fast-forward past 15 reconnection attempts
for (let i = 0; i < 15; i++) {
await clock.tickAsync(65000);
}
expect(ensureConnectionStub.callCount).to.be.at.least(15);
});
});
});
describe('Dispose', () => {
let clock: sinon.SinonFakeTimers;
let closeConnectionSpy: sinon.SinonSpy;
beforeEach(() => {
clock = sinon.useFakeTimers();
closeConnectionSpy = sinon.spy(transport, 'closeConnection');
});
afterEach(() => {
clock.restore();
sinon.restore();
});
it('should clean up event listeners and timers on dispose', async () => {
const isBrowser = typeof window !== 'undefined';
let hadAddEventListener = false;
let hadRemoveEventListener = false;
let removeEventListenerSpy: sinon.SinonSpy;
let removeDocEventListenerSpy: sinon.SinonSpy;
if (isBrowser) {
removeEventListenerSpy = sinon.spy(window, 'removeEventListener');
removeDocEventListenerSpy = sinon.spy(document, 'removeEventListener');
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyGlobalThis = globalThis as any;
hadAddEventListener = 'addEventListener' in anyGlobalThis;
hadRemoveEventListener = 'removeEventListener' in anyGlobalThis;
if (!hadAddEventListener) {
anyGlobalThis.addEventListener = () => {};
}
if (!hadRemoveEventListener) {
anyGlobalThis.removeEventListener = () => {};
}
removeEventListenerSpy = sinon.spy(
anyGlobalThis,
'removeEventListener'
);
anyGlobalThis.document = {
addEventListener: sinon.spy(),
removeEventListener: sinon.spy()
} as unknown as Document;
removeDocEventListenerSpy = anyGlobalThis.document.removeEventListener;
}
try {
// Recreate transport after mocking/spying to pick up the correct environment
transport = new TestStreamTransport(
dcOptions
) as unknown as TransportWithInternals;
transport._isUsingGen = true;
transport._callerSdkType = CallerSdkTypeEnum.Generated;
transport.setAuthToken(initialAuthToken);
transport.setAppCheckToken(initialAppCheckToken);
transport.appId = initialAppId;
transport.hasWaitedForInitialAuth = true;
closeConnectionSpy = sinon.spy(transport, 'closeConnection');
const observer = {
onData: sinon.spy(),
onDisconnect: sinon.spy(),
onError: sinon.spy()
};
transport.invokeSubscribe(observer, queryName1, variables1);
transport.onStreamClose(1006, 'Abnormal Closure');
expect(transport.reconnectTimer).to.not.be.null;
await transport.cleanupAndTerminate();
expect(removeEventListenerSpy).to.have.been.calledWith(
'online',
transport.onOnlineEventListener
);
expect(removeDocEventListenerSpy).to.have.been.calledWith(
'visibilitychange',
transport.onVisibilityChangeEventListener
);
} finally {
// 2. Cleanup
sinon.restore(); // Automatically restores window/document spies in the browser!
// Delete the mock objects we added in Node
if (!isBrowser) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyGlobalThis = globalThis as any;
delete anyGlobalThis.window;
delete anyGlobalThis.document;
if (!hadAddEventListener) {
delete anyGlobalThis.addEventListener;
}
if (!hadRemoveEventListener) {
delete anyGlobalThis.removeEventListener;
}
}
}
expect(transport.reconnectTimer).to.be.null;
expect(closeConnectionSpy).to.have.been.calledOnce;
});
});
});