Skip to content
Open
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
37 changes: 37 additions & 0 deletions spec/v2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { expect } from 'chai';

import { wrapV2 } from '../src/v2';
import type { WrappedV2ScheduledFunction } from '../src/v2';

import {
CloudFunction,
Expand All @@ -35,6 +36,7 @@ import {
eventarc,
https,
firestore,
scheduler,
} from 'firebase-functions/v2';
import { defineString } from 'firebase-functions/params';
import { makeDataSnapshot } from '../src/providers/database';
Expand Down Expand Up @@ -1422,6 +1424,41 @@ describe('v2', () => {
});
});

describe('scheduler', () => {
describe('onSchedule()', () => {
it('should return correct data', async () => {
const scheduledFunction = scheduler.onSchedule(
'every 5 minutes',
(_e) => {}
);

scheduledFunction.__endpoint = {
platform: 'gcfv2',
labels: {},
scheduleTrigger: {
schedule: 'every 5 minutes',
},
concurrency: 20,
minInstances: 3,
region: ['us-west1', 'us-central1'],
};

let wrappedFunction: WrappedV2ScheduledFunction;

expect(
() => (wrappedFunction = wrapV2(scheduledFunction))
).not.to.throw();

const result = await wrappedFunction({
scheduleTime: '2024-01-01T00:00:00Z',
jobName: 'test-job',
});

expect(result).to.be.undefined;
});
});
});

describe('callable functions', () => {
it('should return correct data', async () => {
const cloudFunction = https.onCall(() => 'hello');
Expand Down
22 changes: 19 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,14 @@ export function wrap<T, V extends CloudEvent<unknown>>(
| WrappedFunction<T>
| WrappedV2Function<V>
| WrappedV2CallableFunction<T> {
if (!cloudFunction) {
throw new Error('Cannot wrap: undefined cloud function');
}

if (isV2CloudFunction<V>(cloudFunction)) {
return wrapV2<V>(cloudFunction as CloudFunctionV2<V>);
return wrapV2<V>(cloudFunction);
}

return wrapV1<T>(
cloudFunction as HttpsFunctionOrCloudFunctionV1<T, typeof cloudFunction>
);
Expand All @@ -93,12 +98,23 @@ export function wrap<T, V extends CloudEvent<unknown>>(
* <ul>
* <li> V1 CloudFunction is sometimes a binary function
* <li> V2 CloudFunction is always a unary function
* <li> V2 ScheduleFunction is a binary function (HttpsFunctionV2)
*
* <li> V1 CloudFunction.run is always a binary function
* <li> V2 CloudFunction.run is always a unary function
* <li> V2 ScheduleFunction.run is always a unary function
* </ul>
*
* @return True iff the CloudFunction is a V2 function.
*/
function isV2CloudFunction<T extends CloudEvent<unknown>>(
cloudFunction: any
cloudFunction: CloudFunctionV1<T> | CloudFunctionV2<T> | HttpsFunctionV2
): cloudFunction is CloudFunctionV2<T> {
return cloudFunction.length === 1 && cloudFunction?.run?.length === 1;
if (!cloudFunction) {
return false;
}

type CloudFunctionType = CloudFunctionV1<T> | CloudFunctionV2<T>;

return (cloudFunction as CloudFunctionType).run.length === 1;
}
89 changes: 76 additions & 13 deletions src/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import { CloudFunction, CloudEvent } from 'firebase-functions/v2';
import { CallableFunction, CallableRequest } from 'firebase-functions/v2/https';
import type { CloudFunction, CloudEvent } from 'firebase-functions/v2';
import type { CallableFunction, CallableRequest } from 'firebase-functions/v2/https';
import type {
ScheduledEvent,
ScheduleFunction,
} from 'firebase-functions/v2/scheduler';

import { generateCombinedCloudEvent } from './cloudevent/generate';
import { DeepPartial } from './cloudevent/types';
import * as express from 'express';

type V2WrappableFunctions =
| CloudFunction<any>
| CallableFunction<any, any>
| ScheduleFunction;

/** A function that can be called with test data and optional override values for {@link CloudEvent}
* It will subsequently invoke the cloud function it wraps with the provided {@link CloudEvent}
Expand All @@ -38,14 +46,24 @@ export type WrappedV2CallableFunction<T> = (
data: CallableRequest
) => T | Promise<T>;

function isCallableV2Function<T extends CloudEvent<unknown>>(
cf: CloudFunction<T> | CallableFunction<any, any>
export type WrappedV2ScheduledFunction = (
data: ScheduledEvent
) => void | Promise<void>;

function isCallableV2Function(
cf: V2WrappableFunctions
): cf is CallableFunction<any, any> {
return !!cf?.__endpoint?.callableTrigger;
return !!cf.__endpoint?.callableTrigger;
}

function isScheduledV2Function(
cf: V2WrappableFunctions
): cf is ScheduleFunction {
return !!cf.__endpoint?.scheduleTrigger;
}

function assertIsCloudFunction<T extends CloudEvent<unknown>>(
cf: CloudFunction<T> | CallableFunction<any, any>
cf: V2WrappableFunctions
): asserts cf is CloudFunction<T> {
if (!('run' in cf) || !cf.run) {
throw new Error(
Expand All @@ -54,6 +72,14 @@ function assertIsCloudFunction<T extends CloudEvent<unknown>>(
}
}

function assertIsCloudFunctionV2<T extends CloudEvent<unknown>>(
cf: CloudFunction<T> | CallableFunction<any, any>
): asserts cf is CloudFunction<T> {
if (cf?.__endpoint?.platform !== 'gcfv2') {
throw new Error('This function can only wrap V2 CloudFunctions.');
}
}
Comment on lines +75 to +81
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertIsCloudFunctionV2 function should accept ScheduleFunction in its parameter type since it's called with a V2WrappableFunctions union type (line 117) which includes ScheduleFunction. Without this, TypeScript will raise a type error when ScheduleFunction is passed to assertIsCloudFunctionV2.

Copilot uses AI. Check for mistakes.

/**
* Takes a v2 cloud function to be tested, and returns a {@link WrappedV2Function}
* which can be called in test code.
Expand All @@ -70,19 +96,34 @@ export function wrapV2(
cloudFunction: CallableFunction<any, any>
): WrappedV2CallableFunction<any>;

export function wrapV2(
cloudFunction: ScheduleFunction
): WrappedV2ScheduledFunction;

export function wrapV2<T extends CloudEvent<unknown>>(
cloudFunction: CloudFunction<T> | CallableFunction<any, any>
): WrappedV2Function<T> | WrappedV2CallableFunction<any> {
cloudFunction:
| CloudFunction<T>
| CallableFunction<any, any>
| ScheduleFunction
):
| WrappedV2Function<T>
| WrappedV2CallableFunction<any>
| WrappedV2ScheduledFunction {
if (!cloudFunction) {
throw new Error('Cannot wrap: undefined cloud function');
}

assertIsCloudFunction(cloudFunction);
assertIsCloudFunctionV2(cloudFunction);

if (isCallableV2Function(cloudFunction)) {
return (req: CallableRequest) => {
return cloudFunction.run(req);
};
}

assertIsCloudFunction(cloudFunction);

if (cloudFunction?.__endpoint?.platform !== 'gcfv2') {
throw new Error('This function can only wrap V2 CloudFunctions.');
if (isScheduledV2Function(cloudFunction)) {
return createScheduledWrapper(cloudFunction);
}

return (cloudEventPartial?: DeepPartial<T>) => {
Expand All @@ -93,3 +134,25 @@ export function wrapV2<T extends CloudEvent<unknown>>(
return cloudFunction.run(cloudEvent);
};
}

function createScheduledWrapper(
cloudFunction: ScheduleFunction
): WrappedV2ScheduledFunction {
return (options: ScheduledEvent) => {
_checkOptionValidity(['jobName', 'scheduleTime'], options);
return cloudFunction.run(options);
};
}

function _checkOptionValidity(
validFields: string[],
options: Record<string, any>
) {
Object.keys(options).forEach((key) => {
if (validFields.indexOf(key) === -1) {
throw new Error(
`Options object ${JSON.stringify(options)} has invalid key "${key}"`
);
}
});
}