Skip to content

Commit 6ef39d4

Browse files
authored
Add withFunctionsTriggersDisabled method to rules-unit-testing (#3928)
1 parent 2ac31a1 commit 6ef39d4

File tree

7 files changed

+229
-75
lines changed

7 files changed

+229
-75
lines changed

.changeset/lazy-elephants-suffer.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/rules-unit-testing': minor
3+
---
4+
5+
Add withFunctionTriggersDisabled function which runs a user-provided setup function with emulated Cloud Functions triggers disabled. This can be used to import data into the Realtime Database or Cloud Firestore emulators without triggering locally emulated Cloud Functions. This method only works with Firebase CLI version 8.13.0 or higher.

packages/rules-unit-testing/firebase.json

+6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
{
2+
"functions": {
3+
"source": "."
4+
},
25
"emulators": {
36
"firestore": {
47
"port": 9003
58
},
69
"database": {
710
"port": 9002
811
},
12+
"functions": {
13+
"port": 9004
14+
},
915
"ui": {
1016
"enabled": false
1117
}

packages/rules-unit-testing/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ export {
3131
initializeAdminApp,
3232
initializeTestApp,
3333
loadDatabaseRules,
34-
loadFirestoreRules
34+
loadFirestoreRules,
35+
withFunctionTriggersDisabled
3536
} from './src/api';

packages/rules-unit-testing/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"build:deps": "lerna run --scope @firebase/rules-unit-testing --include-dependencies build",
1414
"dev": "rollup -c -w",
1515
"test:nyc": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --config ../../config/mocharc.node.js",
16-
"test": "firebase --debug emulators:exec 'yarn test:nyc'",
16+
"test": "firebase --project=foo --debug emulators:exec 'yarn test:nyc'",
1717
"test:ci": "node ../../scripts/run_tests_in_ci.js -s test",
1818
"prepare": "yarn build"
1919
},
@@ -28,7 +28,8 @@
2828
"@google-cloud/firestore": "4.4.0",
2929
"@types/request": "2.48.5",
3030
"firebase-admin": "9.2.0",
31-
"firebase-tools": "8.12.1",
31+
"firebase-tools": "8.13.0",
32+
"firebase-functions": "3.11.0",
3233
"rollup": "2.29.0",
3334
"rollup-plugin-typescript2": "0.27.3"
3435
},

packages/rules-unit-testing/src/api/index.ts

+114-64
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ const FIRESTORE_ADDRESS_ENV: string = 'FIRESTORE_EMULATOR_HOST';
3636
/** The default address for the local Firestore emulator. */
3737
const FIRESTORE_ADDRESS_DEFAULT: string = 'localhost:8080';
3838

39+
/** Environment variable to locate the Emulator Hub */
40+
const HUB_HOST_ENV: string = 'FIREBASE_EMULATOR_HUB';
41+
/** The default address for the Emulator hub */
42+
const HUB_HOST_DEFAULT: string = 'localhost:4400';
43+
3944
/** The actual address for the database emulator */
4045
let _databaseHost: string | undefined = undefined;
4146

@@ -307,7 +312,7 @@ export type LoadDatabaseRulesOptions = {
307312
databaseName: string;
308313
rules: string;
309314
};
310-
export function loadDatabaseRules(
315+
export async function loadDatabaseRules(
311316
options: LoadDatabaseRulesOptions
312317
): Promise<void> {
313318
if (!options.databaseName) {
@@ -318,33 +323,25 @@ export function loadDatabaseRules(
318323
throw Error('must provide rules to loadDatabaseRules');
319324
}
320325

321-
return new Promise((resolve, reject) => {
322-
request.put(
323-
{
324-
uri: `http://${getDatabaseHost()}/.settings/rules.json?ns=${
325-
options.databaseName
326-
}`,
327-
headers: { Authorization: 'Bearer owner' },
328-
body: options.rules
329-
},
330-
(err, resp, body) => {
331-
if (err) {
332-
reject(err);
333-
} else if (resp.statusCode !== 200) {
334-
reject(JSON.parse(body).error);
335-
} else {
336-
resolve();
337-
}
338-
}
339-
);
326+
const resp = await requestPromise(request.put, {
327+
method: 'PUT',
328+
uri: `http://${getDatabaseHost()}/.settings/rules.json?ns=${
329+
options.databaseName
330+
}`,
331+
headers: { Authorization: 'Bearer owner' },
332+
body: options.rules
340333
});
334+
335+
if (resp.statusCode !== 200) {
336+
throw new Error(JSON.parse(resp.body.error));
337+
}
341338
}
342339

343340
export type LoadFirestoreRulesOptions = {
344341
projectId: string;
345342
rules: string;
346343
};
347-
export function loadFirestoreRules(
344+
export async function loadFirestoreRules(
348345
options: LoadFirestoreRulesOptions
349346
): Promise<void> {
350347
if (!options.projectId) {
@@ -355,64 +352,98 @@ export function loadFirestoreRules(
355352
throw new Error('must provide rules to loadFirestoreRules');
356353
}
357354

358-
return new Promise((resolve, reject) => {
359-
request.put(
360-
{
361-
uri: `http://${getFirestoreHost()}/emulator/v1/projects/${
362-
options.projectId
363-
}:securityRules`,
364-
body: JSON.stringify({
365-
rules: {
366-
files: [{ content: options.rules }]
367-
}
368-
})
369-
},
370-
(err, resp, body) => {
371-
if (err) {
372-
reject(err);
373-
} else if (resp.statusCode !== 200) {
374-
console.log('body', body);
375-
reject(JSON.parse(body).error);
376-
} else {
377-
resolve();
378-
}
355+
const resp = await requestPromise(request.put, {
356+
method: 'PUT',
357+
uri: `http://${getFirestoreHost()}/emulator/v1/projects/${
358+
options.projectId
359+
}:securityRules`,
360+
body: JSON.stringify({
361+
rules: {
362+
files: [{ content: options.rules }]
379363
}
380-
);
364+
})
381365
});
366+
367+
if (resp.statusCode !== 200) {
368+
throw new Error(JSON.parse(resp.body.error));
369+
}
382370
}
383371

384372
export type ClearFirestoreDataOptions = {
385373
projectId: string;
386374
};
387-
export function clearFirestoreData(
375+
export async function clearFirestoreData(
388376
options: ClearFirestoreDataOptions
389377
): Promise<void> {
390378
if (!options.projectId) {
391379
throw new Error('projectId not specified');
392380
}
393381

394-
return new Promise((resolve, reject) => {
395-
request.delete(
396-
{
397-
uri: `http://${getFirestoreHost()}/emulator/v1/projects/${
398-
options.projectId
399-
}/databases/(default)/documents`,
400-
body: JSON.stringify({
401-
database: `projects/${options.projectId}/databases/(default)`
402-
})
403-
},
404-
(err, resp, body) => {
405-
if (err) {
406-
reject(err);
407-
} else if (resp.statusCode !== 200) {
408-
console.log('body', body);
409-
reject(JSON.parse(body).error);
410-
} else {
411-
resolve();
412-
}
413-
}
382+
const resp = await requestPromise(request.delete, {
383+
method: 'DELETE',
384+
uri: `http://${getFirestoreHost()}/emulator/v1/projects/${
385+
options.projectId
386+
}/databases/(default)/documents`,
387+
body: JSON.stringify({
388+
database: `projects/${options.projectId}/databases/(default)`
389+
})
390+
});
391+
392+
if (resp.statusCode !== 200) {
393+
throw new Error(JSON.parse(resp.body.error));
394+
}
395+
}
396+
397+
/**
398+
* Run a setup function with background Cloud Functions triggers disabled. This can be used to
399+
* import data into the Realtime Database or Cloud Firestore emulator without triggering locally
400+
* emulated Cloud Functions.
401+
*
402+
* This method only works with Firebase CLI version 8.13.0 or higher.
403+
*
404+
* @param fn an function which returns a promise.
405+
*/
406+
export async function withFunctionTriggersDisabled<TResult>(
407+
fn: () => TResult | Promise<TResult>
408+
): Promise<TResult> {
409+
let hubHost = process.env[HUB_HOST_ENV];
410+
if (!hubHost) {
411+
console.warn(
412+
`${HUB_HOST_ENV} is not set, assuming the Emulator hub is running at ${HUB_HOST_DEFAULT}`
414413
);
414+
hubHost = HUB_HOST_DEFAULT;
415+
}
416+
417+
// Disable background triggers
418+
const disableRes = await requestPromise(request.put, {
419+
method: 'PUT',
420+
uri: `http://${hubHost}/functions/disableBackgroundTriggers`
415421
});
422+
if (disableRes.statusCode !== 200) {
423+
throw new Error(
424+
`HTTP Error ${disableRes.statusCode} when disabling functions triggers, are you using firebase-tools 8.13.0 or higher?`
425+
);
426+
}
427+
428+
// Run the user's function
429+
let result: TResult | undefined = undefined;
430+
try {
431+
result = await fn();
432+
} finally {
433+
// Re-enable background triggers
434+
const enableRes = await requestPromise(request.put, {
435+
method: 'PUT',
436+
uri: `http://${hubHost}/functions/enableBackgroundTriggers`
437+
});
438+
if (enableRes.statusCode !== 200) {
439+
throw new Error(
440+
`HTTP Error ${enableRes.statusCode} when enabling functions triggers, are you using firebase-tools 8.13.0 or higher?`
441+
);
442+
}
443+
}
444+
445+
// Return the user's function result
446+
return result;
416447
}
417448

418449
export function assertFails(pr: Promise<any>): any {
@@ -441,3 +472,22 @@ export function assertFails(pr: Promise<any>): any {
441472
export function assertSucceeds(pr: Promise<any>): any {
442473
return pr;
443474
}
475+
476+
function requestPromise(
477+
method: typeof request.get,
478+
options: request.CoreOptions & request.UriOptions
479+
): Promise<{ statusCode: number; body: any }> {
480+
return new Promise((resolve, reject) => {
481+
const callback: request.RequestCallback = (err, resp, body) => {
482+
if (err) {
483+
reject(err);
484+
} else {
485+
resolve({ statusCode: resp.statusCode, body });
486+
}
487+
};
488+
489+
// Unfortunately request's default method is not very test-friendly so having
490+
// the caler pass in the method here makes this whole thing compatible with sinon
491+
method(options, callback);
492+
});
493+
}

packages/rules-unit-testing/test/database.test.ts

+41-8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import * as chai from 'chai';
1919
import * as chaiAsPromised from 'chai-as-promised';
20+
import * as request from 'request';
21+
import * as sinon from 'sinon';
2022
import * as firebase from '../src/api';
2123
import { base64 } from '@firebase/util';
2224
import { _FirebaseApp } from '@firebase/app-types/private';
@@ -28,6 +30,15 @@ before(() => {
2830
});
2931

3032
describe('Testing Module Tests', function () {
33+
let sandbox: sinon.SinonSandbox;
34+
beforeEach(function () {
35+
sandbox = sinon.createSandbox();
36+
});
37+
38+
afterEach(function () {
39+
sandbox && sandbox.restore();
40+
});
41+
3142
it('assertSucceeds() iff success', async function () {
3243
const success = Promise.resolve('success');
3344
const failure = Promise.reject('failure');
@@ -262,19 +273,19 @@ describe('Testing Module Tests', function () {
262273

263274
it('loadDatabaseRules() throws if no databaseName or rules', async function () {
264275
// eslint-disable-next-line @typescript-eslint/no-explicit-any
265-
await expect((firebase as any).loadDatabaseRules.bind(null, {})).to.throw(
266-
/databaseName not specified/
267-
);
276+
await expect(
277+
firebase.loadDatabaseRules({} as any)
278+
).to.eventually.be.rejectedWith(/databaseName not specified/);
268279
// eslint-disable-next-line @typescript-eslint/no-explicit-any
269280
await expect(
270-
(firebase as any).loadDatabaseRules.bind(null, {
281+
firebase.loadDatabaseRules({
271282
databaseName: 'foo'
272-
}) as Promise<void>
273-
).to.throw(/must provide rules/);
283+
} as any)
284+
).to.eventually.be.rejectedWith(/must provide rules/);
274285
await expect(
275286
// eslint-disable-next-line @typescript-eslint/no-explicit-any
276-
(firebase as any).loadDatabaseRules.bind(null, { rules: '{}' })
277-
).to.throw(/databaseName not specified/);
287+
firebase.loadDatabaseRules({ rules: '{}' } as any)
288+
).to.eventually.be.rejectedWith(/databaseName not specified/);
278289
});
279290

280291
it('loadDatabaseRules() succeeds on valid input', async function () {
@@ -318,4 +329,26 @@ describe('Testing Module Tests', function () {
318329
it('there is a way to get firestore timestamps', function () {
319330
expect(firebase.firestore.FieldValue.serverTimestamp()).not.to.be.null;
320331
});
332+
333+
it('disabling function triggers does not throw, returns value', async function () {
334+
const putSpy = sandbox.spy(request, 'put');
335+
336+
const res = await firebase.withFunctionTriggersDisabled(() => {
337+
return Promise.resolve(1234);
338+
});
339+
340+
expect(res).to.eq(1234);
341+
expect(putSpy.callCount).to.equal(2);
342+
});
343+
344+
it('disabling function triggers always re-enables, event when the function throws', async function () {
345+
const putSpy = sandbox.spy(request, 'put');
346+
347+
const res = firebase.withFunctionTriggersDisabled(() => {
348+
throw new Error('I throw!');
349+
});
350+
351+
await expect(res).to.eventually.be.rejectedWith('I throw!');
352+
expect(putSpy.callCount).to.equal(2);
353+
});
321354
});

0 commit comments

Comments
 (0)