Skip to content

Commit f1c4bd6

Browse files
authored
Merge 90bb449 into 602ec18
2 parents 602ec18 + 90bb449 commit f1c4bd6

File tree

7 files changed

+230
-75
lines changed

7 files changed

+230
-75
lines changed

.changeset/lazy-elephants-suffer.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'firebase': minor
3+
'@firebase/rules-unit-testing': minor
4+
---
5+
6+
Add withFunctionTriggersDisabled function to facilitate test setup

packages/rules-unit-testing/firebase.json

Lines changed: 6 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 3 additions & 2 deletions
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

Lines changed: 114 additions & 64 deletions
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

Lines changed: 41 additions & 8 deletions
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)