Skip to content

Make initialize methods for each product idempotent #5272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 13, 2021
Merged
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions .changeset/fast-buses-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/component': patch
---

Store instance initialization options on the Provider.
3 changes: 3 additions & 0 deletions common/api-review/analytics-exp.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@ export interface Promotion {
name?: string;
}

// @public (undocumented)
export function registerAnalytics(): void;

// @public
export function setAnalyticsCollectionEnabled(analyticsInstance: Analytics, enabled: boolean): void;

Expand Down
4 changes: 4 additions & 0 deletions common/api-review/app-check-exp.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export class CustomProvider implements AppCheckProvider {
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
Expand Down Expand Up @@ -79,6 +81,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
Expand Down
135 changes: 135 additions & 0 deletions packages-exp/analytics-exp/src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2019 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 { SinonFakeTimers, stub, useFakeTimers } from 'sinon';
import '../testing/setup';
import { getFullApp } from '../testing/get-fake-firebase-services';
import { getAnalytics, initializeAnalytics } from './api';
import { FirebaseApp, deleteApp } from '@firebase/app-exp';
import { AnalyticsError } from './errors';
import { removeGtagScript } from '../testing/gtag-script-util';
import * as getConfig from './get-config';
const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' };

const fakeDynamicConfig = stub(
getConfig,
'fetchDynamicConfigWithRetry'
).resolves({
appId: 'FAKE_APP_ID',
measurementId: 'FAKE_MEASUREMENT_ID'
});

// Fake indexedDB.open() request
let fakeRequest = {
onsuccess: () => {},
result: {
close: () => {}
}
};
let idbOpenStub = stub();

// Stub indexedDB.open() because sinon's clock does not know
// how to wait for the real indexedDB callbacks to resolve.
function stubIdbOpen(): void {
(fakeRequest = {
onsuccess: () => {},
result: {
close: () => {}
}
}),
(idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any));
}

describe('FirebaseAnalytics API tests', () => {
let app: FirebaseApp;
let clock: SinonFakeTimers;

beforeEach(() => {
stubIdbOpen();
clock = useFakeTimers();
});

afterEach(async () => {
clock.restore();
idbOpenStub.restore();
removeGtagScript();
if (app) {
return deleteApp(app);
}
});

after(() => {
delete window['gtag'];
delete window['dataLayer'];
fakeDynamicConfig.restore();
});

it('initializeAnalytics() returns an Analytics instance', async () => {
app = getFullApp(fakeAppParams);
const analyticsInstance = initializeAnalytics(app);
expect(analyticsInstance.app).to.equal(app);
await clock.runAllAsync();
});
it('initializeAnalytics() with same (no) options returns same instance', async () => {
app = getFullApp(fakeAppParams);
const analyticsInstance = initializeAnalytics(app);
const newInstance = initializeAnalytics(app);
expect(analyticsInstance).to.equal(newInstance);
await clock.runAllAsync();
});
it('initializeAnalytics() with same options returns same instance', () => {
app = getFullApp(fakeAppParams);
const analyticsInstance = initializeAnalytics(app, {
config: { 'send_page_view': false }
});
const newInstance = initializeAnalytics(app, {
config: { 'send_page_view': false }
});
expect(analyticsInstance).to.equal(newInstance);
});
it('initializeAnalytics() with different options throws', () => {
app = getFullApp(fakeAppParams);
initializeAnalytics(app, {
config: { 'send_page_view': false }
});
expect(() =>
initializeAnalytics(app, {
config: { 'send_page_view': true }
})
).to.throw(AnalyticsError.ALREADY_INITIALIZED);
});
it('initializeAnalytics() with different options (one undefined) throws', () => {
app = getFullApp(fakeAppParams);
initializeAnalytics(app);
expect(() =>
initializeAnalytics(app, {
config: { 'send_page_view': true }
})
).to.throw(AnalyticsError.ALREADY_INITIALIZED);
});
it('getAnalytics() returns same instance created by previous getAnalytics()', () => {
app = getFullApp(fakeAppParams);
const analyticsInstance = getAnalytics(app);
expect(getAnalytics(app)).to.equal(analyticsInstance);
});
it('getAnalytics() returns same instance created by initializeAnalytics()', () => {
app = getFullApp(fakeAppParams);
const analyticsInstance = initializeAnalytics(app);
expect(getAnalytics(app)).to.equal(analyticsInstance);
});
});
15 changes: 13 additions & 2 deletions packages-exp/analytics-exp/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
validateIndexedDBOpenable,
areCookiesEnabled,
isBrowserExtension,
getModularInstance
getModularInstance,
deepEqual
} from '@firebase/util';
import { ANALYTICS_TYPE } from './constants';
import {
Expand Down Expand Up @@ -97,7 +98,17 @@ export function initializeAnalytics(
ANALYTICS_TYPE
);
if (analyticsProvider.isInitialized()) {
throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED);
const existingInstance = analyticsProvider.getImmediate();
if (
deepEqual(
options.config || {},
(analyticsProvider.getOptions() as AnalyticsSettings)?.config || {}
)
) {
return existingInstance;
} else {
throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED);
}
}
const analyticsInstance = analyticsProvider.initialize({ options });
return analyticsInstance;
Expand Down
6 changes: 4 additions & 2 deletions packages-exp/analytics-exp/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ const ERRORS: ErrorMap<AnalyticsError> = {
' already exists. ' +
'Only one Firebase Analytics instance can be created for each appId.',
[AnalyticsError.ALREADY_INITIALIZED]:
'Firebase Analytics has already been initialized. ' +
'initializeAnalytics() must only be called once. getAnalytics() can be used ' +
'Firebase Analytics has already been initialized with these options. ' +
'initializeAnalytics() cannot be called again with different options than those ' +
'it was initially called with. It can be called again with the same options to ' +
'return the existing instance, or getAnalytics() can be used ' +
'to get a reference to the already-intialized instance.',
[AnalyticsError.ALREADY_INITIALIZED_SETTINGS]:
'Firebase Analytics has already been initialized.' +
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/analytics-exp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ declare global {
}
}

function registerAnalytics(): void {
export function registerAnalytics(): void {
_registerComponent(
new Component(
ANALYTICS_TYPE,
Expand Down
46 changes: 35 additions & 11 deletions packages-exp/analytics-exp/testing/get-fake-firebase-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,22 @@
* limitations under the License.
*/

import { FirebaseApp } from '@firebase/app-exp';
import {
FirebaseApp,
initializeApp,
_registerComponent
} from '@firebase/app-exp';
import { Component, ComponentType } from '@firebase/component';
import { _FirebaseInstallationsInternal } from '@firebase/installations-exp';
import { registerAnalytics } from '../src';

const fakeConfig = {
projectId: 'projectId',
authDomain: 'authDomain',
messagingSenderId: 'messagingSenderId',
databaseURL: 'databaseUrl',
storageBucket: 'storageBucket'
};

export function getFakeApp(fakeAppParams?: {
appId?: string;
Expand All @@ -25,16 +39,7 @@ export function getFakeApp(fakeAppParams?: {
}): FirebaseApp {
return {
name: 'appName',
options: {
apiKey: fakeAppParams?.apiKey,
projectId: 'projectId',
authDomain: 'authDomain',
messagingSenderId: 'messagingSenderId',
databaseURL: 'databaseUrl',
storageBucket: 'storageBucket',
appId: fakeAppParams?.appId,
measurementId: fakeAppParams?.measurementId
},
options: { ...fakeConfig, ...fakeAppParams },
automaticDataCollectionEnabled: true
};
}
Expand All @@ -52,3 +57,22 @@ export function getFakeInstallations(
getToken: async () => 'authToken'
};
}

export function getFullApp(fakeAppParams?: {
appId?: string;
apiKey?: string;
measurementId?: string;
}): FirebaseApp {
registerAnalytics();
_registerComponent(
new Component(
'installations-exp-internal',
() => {
return getFakeInstallations();
},
ComponentType.PUBLIC
)
);
const app = initializeApp({ ...fakeConfig, ...fakeAppParams });
return app;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { getAnalytics, initializeAnalytics, logEvent } from '../../src/index';
import '../setup';
import { expect } from 'chai';
import { stub } from 'sinon';
import { AnalyticsError } from '../../src/errors';

let config: Record<string, string>;
try {
Expand Down Expand Up @@ -86,15 +85,5 @@ describe('FirebaseAnalytics Integration Smoke Tests', () => {
expect(eventCalls.length).to.equal(1);
expect(eventCalls[0].name).to.include('method=email');
});
it('getAnalytics() does not throw if called after initializeAnalytics().', async () => {
const analyticsInstance = getAnalytics(app);
expect(analyticsInstance.app).to.equal(app);
});
it('initializeAnalytics() throws if called more than once.', async () => {
expect(() => initializeAnalytics(app)).to.throw(
AnalyticsError.ALREADY_INITIALIZED
);
await deleteApp(app);
});
});
});
57 changes: 54 additions & 3 deletions packages-exp/app-check-exp/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ import * as client from './client';
import * as storage from './storage';
import * as internalApi from './internal-api';
import { deleteApp, FirebaseApp } from '@firebase/app-exp';
import { ReCaptchaV3Provider } from './providers';
import { CustomProvider, ReCaptchaV3Provider } from './providers';
import { AppCheckService } from './factory';
import { AppCheckToken } from './public-types';

describe('api', () => {
let app: FirebaseApp;
Expand All @@ -57,16 +58,66 @@ describe('api', () => {
});

describe('initializeAppCheck()', () => {
it('can only be called once', () => {
it('can only be called once (if given different provider classes)', () => {
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
expect(() =>
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
provider: new CustomProvider({
getToken: () => Promise.resolve({ token: 'mm' } as AppCheckToken)
})
})
).to.throw(/appCheck\/already-initialized/);
});
it('can only be called once (if given different ReCaptchaV3Providers)', () => {
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
expect(() =>
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY + 'X')
})
).to.throw(/appCheck\/already-initialized/);
});
it('can only be called once (if given different CustomProviders)', () => {
initializeAppCheck(app, {
provider: new CustomProvider({
getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken)
})
});
expect(() =>
initializeAppCheck(app, {
provider: new CustomProvider({
getToken: () => Promise.resolve({ token: 'gg' } as AppCheckToken)
})
})
).to.throw(/appCheck\/already-initialized/);
});
it('can be called multiple times (if given equivalent ReCaptchaV3Providers)', () => {
const appCheckInstance = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
expect(
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
})
).to.equal(appCheckInstance);
});
it('can be called multiple times (if given equivalent CustomProviders)', () => {
const appCheckInstance = initializeAppCheck(app, {
provider: new CustomProvider({
getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken)
})
});
expect(
initializeAppCheck(app, {
provider: new CustomProvider({
getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken)
})
})
).to.equal(appCheckInstance);
});

it('initialize reCAPTCHA when a ReCaptchaV3Provider is provided', () => {
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns(
Expand Down
Loading