Skip to content

Commit 4aee03e

Browse files
committed
Add more tests.
1 parent 8ec2787 commit 4aee03e

File tree

3 files changed

+167
-26
lines changed

3 files changed

+167
-26
lines changed

packages/functions/src/callable.test.ts

Lines changed: 166 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
AppCheckInternalComponentName
3838
} from '@firebase/app-check-interop-types';
3939
import { makeFakeApp, createTestService } from '../test/utils';
40-
import { httpsCallable } from './service';
40+
import { FunctionsService, httpsCallable } from './service';
4141
import { FUNCTIONS_TYPE } from './constants';
4242
import { FunctionsError } from './error';
4343

@@ -308,21 +308,26 @@ describe('Firebase Functions > Call', () => {
308308

309309
describe('Firebase Functions > Stream', () => {
310310
let app: FirebaseApp;
311+
let functions: FunctionsService;
312+
let mockFetch: sinon.SinonStub;
311313
const region = 'us-central1';
312314

313-
before(() => {
315+
beforeEach(() => {
314316
const useEmulator = !!process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN;
315317
const projectId = useEmulator
316318
? 'functions-integration-test'
317319
: TEST_PROJECT.projectId;
318320
const messagingSenderId = 'messaging-sender-id';
319321
app = makeFakeApp({ projectId, messagingSenderId });
322+
functions = createTestService(app, region);
323+
mockFetch = sinon.stub(functions, 'fetchImpl' as any);
320324
});
321325

322-
it('successfully streams data and resolves final result', async () => {
323-
const functions = createTestService(app, region);
324-
const mockFetch = sinon.stub(globalThis, 'fetch' as any);
326+
afterEach(() => {
327+
mockFetch.restore();
328+
})
325329

330+
it('successfully streams data and resolves final result', async () => {
326331
const mockResponse = new ReadableStream({
327332
start(controller) {
328333
controller.enqueue(new TextEncoder().encode('data: {"message":"Hello"}\n'));
@@ -349,14 +354,9 @@ describe('Firebase Functions > Stream', () => {
349354

350355
expect(messages).to.deep.equal(['Hello', 'World']);
351356
expect(await streamResult.data).to.equal('Final Result');
352-
353-
mockFetch.restore();
354357
});
355358

356359
it('handles network errors', async () => {
357-
const functions = createTestService(app, region);
358-
const mockFetch = sinon.stub(globalThis, 'fetch' as any);
359-
360360
mockFetch.rejects(new Error('Network error'));
361361

362362
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
@@ -371,17 +371,11 @@ describe('Firebase Functions > Stream', () => {
371371
errorThrown = true;
372372
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/internal`);
373373
}
374-
375374
expect(errorThrown).to.be.true;
376-
expect(streamResult.data).to.be.a('promise');
377-
378-
mockFetch.restore();
375+
expectError(streamResult.data, "internal", "Internal");
379376
});
380377

381378
it('handles server-side errors', async () => {
382-
const functions = createTestService(app, region);
383-
const mockFetch = sinon.stub(globalThis, 'fetch' as any);
384-
385379
const mockResponse = new ReadableStream({
386380
start(controller) {
387381
controller.enqueue(new TextEncoder().encode('data: {"error":{"status":"INVALID_ARGUMENT","message":"Invalid input"}}\n'));
@@ -396,7 +390,7 @@ describe('Firebase Functions > Stream', () => {
396390
statusText: 'OK',
397391
} as Response);
398392

399-
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
393+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'stream');
400394
const streamResult = await func.stream({});
401395

402396
let errorThrown = false;
@@ -412,8 +406,6 @@ describe('Firebase Functions > Stream', () => {
412406

413407
expect(errorThrown).to.be.true;
414408
expectError(streamResult.data, "invalid-argument", "Invalid input")
415-
416-
mockFetch.restore();
417409
});
418410

419411
it('includes authentication and app check tokens in request headers', async () => {
@@ -427,9 +419,23 @@ describe('Firebase Functions > Stream', () => {
427419
authProvider.setComponent(
428420
new Component('auth-internal', () => authMock, ComponentType.PRIVATE)
429421
);
422+
const appCheckMock: FirebaseAppCheckInternal = {
423+
getToken: async () => ({ token: 'app-check-token' })
424+
} as unknown as FirebaseAppCheckInternal;
425+
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
426+
'app-check-internal',
427+
new ComponentContainer('test')
428+
);
429+
appCheckProvider.setComponent(
430+
new Component(
431+
'app-check-internal',
432+
() => appCheckMock,
433+
ComponentType.PRIVATE
434+
)
435+
);
430436

431-
const functions = createTestService(app, region, authProvider);
432-
const mockFetch = sinon.stub(globalThis, 'fetch' as any);
437+
const functions = createTestService(app, region, authProvider, undefined, appCheckProvider);
438+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
433439

434440
const mockResponse = new ReadableStream({
435441
start(controller) {
@@ -445,15 +451,151 @@ describe('Firebase Functions > Stream', () => {
445451
statusText: 'OK',
446452
} as Response);
447453

448-
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
454+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'stream');
449455
await func.stream({});
450456

451457
expect(mockFetch.calledOnce).to.be.true;
452458
const [_, options] = mockFetch.firstCall.args;
453459
expect(options.headers['Authorization']).to.equal('Bearer auth-token');
454460
expect(options.headers['Content-Type']).to.equal('application/json');
455461
expect(options.headers['Accept']).to.equal('text/event-stream');
462+
});
456463

457-
mockFetch.restore();
464+
it('aborts during initial fetch', async () => {
465+
const controller = new AbortController();
466+
467+
// Create a fetch that rejects when aborted
468+
const fetchPromise = new Promise<Response>((_, reject) => {
469+
controller.signal.addEventListener('abort', () => {
470+
const error = new Error('The operation was aborted');
471+
error.name = 'AbortError';
472+
reject(error);
473+
});
474+
});
475+
mockFetch.returns(fetchPromise);
476+
477+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'streamTest');
478+
const streamPromise = func.stream({}, { signal: controller.signal });
479+
480+
controller.abort();
481+
482+
const streamResult = await streamPromise;
483+
484+
// Verify fetch was called with abort signal
485+
expect(mockFetch.calledOnce).to.be.true;
486+
const [_, options] = mockFetch.firstCall.args;
487+
expect(options.signal).to.equal(controller.signal);
488+
489+
// Verify stream iteration throws AbortError
490+
let errorThrown = false;
491+
try {
492+
for await (const _ of streamResult.stream) {
493+
// Should not execute
494+
}
495+
} catch (error) {
496+
errorThrown = true;
497+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/cancelled`);
498+
}
499+
expect(errorThrown).to.be.true;
500+
expectError(streamResult.data, "cancelled", "Request was cancelled")
501+
});
502+
503+
it('aborts during streaming', async () => {
504+
const controller = new AbortController();
505+
506+
const mockResponse = new ReadableStream({
507+
async start(controller) {
508+
controller.enqueue(new TextEncoder().encode('data: {"message":"First"}\n'));
509+
// Add delay to simulate network latency
510+
await new Promise(resolve => setTimeout(resolve, 50));
511+
controller.enqueue(new TextEncoder().encode('data: {"message":"Second"}\n'));
512+
await new Promise(resolve => setTimeout(resolve, 50));
513+
controller.enqueue(new TextEncoder().encode('data: {"result":"Final"}\n'));
514+
controller.close();
515+
}
516+
});
517+
518+
mockFetch.resolves({
519+
body: mockResponse,
520+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
521+
status: 200,
522+
statusText: 'OK',
523+
} as Response);
524+
525+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'streamTest');
526+
const streamResult = await func.stream({}, { signal: controller.signal });
527+
528+
const messages: string[] = [];
529+
try {
530+
for await (const message of streamResult.stream) {
531+
messages.push(message);
532+
if (messages.length === 1) {
533+
// Abort after receiving first message
534+
controller.abort();
535+
}
536+
}
537+
throw new Error('Stream should have been aborted');
538+
} catch (error) {
539+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/cancelled`);
540+
}
541+
expect(messages).to.deep.equal(['First']);
542+
expectError(streamResult.data, "cancelled", "Request was cancelled")
543+
});
544+
545+
it('fails immediately with pre-aborted signal', async () => {
546+
mockFetch.callsFake((url: string, options: RequestInit) => {
547+
if (options.signal?.aborted) {
548+
const error = new Error('The operation was aborted');
549+
error.name = 'AbortError';
550+
return Promise.reject(error);
551+
}
552+
return Promise.resolve(new Response());
553+
});
554+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'streamTest');
555+
const streamResult = await func.stream({}, { signal: AbortSignal.abort() });
556+
557+
let errorThrown = false;
558+
try {
559+
for await (const _ of streamResult.stream) {
560+
// Should not execute
561+
}
562+
} catch (error) {
563+
errorThrown = true;
564+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/cancelled`);
565+
}
566+
expect(errorThrown).to.be.true;
567+
expectError(streamResult.data, "cancelled", "Request was cancelled")
568+
});
569+
570+
it('properly handles AbortSignal.timeout()', async () => {
571+
const timeoutMs = 50;
572+
const signal = AbortSignal.timeout(timeoutMs);
573+
574+
mockFetch.callsFake(async (url: string, options: RequestInit) => {
575+
await new Promise((resolve, reject) => {
576+
options.signal?.addEventListener('abort', () => {
577+
const error = new Error('The operation was aborted');
578+
error.name = 'AbortError';
579+
reject(error);
580+
});
581+
setTimeout(resolve, timeoutMs * 3);
582+
});
583+
584+
// If we get here, timeout didn't occur
585+
return new Response();
586+
});
587+
588+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'streamTest');
589+
const streamResult = await func.stream({}, { signal });
590+
591+
try {
592+
for await (const message of streamResult.stream) {
593+
// Should not execute
594+
}
595+
throw new Error('Stream should have timed out');
596+
} catch (error) {
597+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/cancelled`);
598+
}
599+
expectError(streamResult.data, "cancelled", "Request was cancelled")
458600
});
459601
});

packages/functions/src/public-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface HttpsCallableStreamResult<ResponseData = unknown, StreamData =
4343
*/
4444
export type HttpsCallable<RequestData = unknown, ResponseData = unknown, StreamData = unknown> = {
4545
(data?: RequestData | null): Promise<HttpsCallableResult<ResponseData>>;
46-
stream: (data?: RequestData | null) => Promise<HttpsCallableStreamResult<ResponseData, StreamData>>;
46+
stream: (data?: RequestData | null, options?: HttpsCallableStreamOptions) => Promise<HttpsCallableStreamResult<ResponseData, StreamData>>;
4747
};
4848

4949
/**

packages/functions/src/service.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,6 @@ async function callAtURL(
285285
data: unknown,
286286
options: HttpsCallableOptions
287287
): Promise<HttpsCallableResult> {
288-
console.log(url);
289288
// Encode any special types, such as dates, in the input data.
290289
data = encode(data);
291290
const body = { data };

0 commit comments

Comments
 (0)