UNPKG

voluptasmollitia

Version:
260 lines (243 loc) 9.45 kB
/** * @license * Copyright 2020 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 { SinonStub, stub, useFakeTimers, restore } from 'sinon'; import '../testing/setup'; import { fetchDynamicConfig, fetchDynamicConfigWithRetry, AppFields, LONG_RETRY_FACTOR } from './get-config'; import { DYNAMIC_CONFIG_URL } from './constants'; import { getFakeApp } from '../testing/get-fake-firebase-services'; import { DynamicConfig, MinimalDynamicConfig } from '@firebase/analytics-types'; import { AnalyticsError } from './errors'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeAppId = 'abcdefgh12345:23405'; const fakeAppParams = { appId: fakeAppId, apiKey: 'AAbbCCdd12345' }; const fakeUrl = DYNAMIC_CONFIG_URL.replace('{app-id}', fakeAppId); const successObject = { measurementId: fakeMeasurementId, appId: fakeAppId }; let fetchStub: SinonStub; function stubFetch(status: number, body: { [key: string]: any }): void { fetchStub = stub(window, 'fetch'); const mockResponse = new window.Response(JSON.stringify(body), { status }); fetchStub.returns(Promise.resolve(mockResponse)); } describe('Dynamic Config Fetch Functions', () => { afterEach(restore); describe('fetchDynamicConfig() - no retry', () => { it('successfully request and receives dynamic config JSON data', async () => { stubFetch(200, successObject); const config: DynamicConfig = await fetchDynamicConfig(fakeAppParams); expect(fetchStub.args[0][0]).to.equal(fakeUrl); expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( fakeAppParams.apiKey ); expect(config.appId).to.equal(fakeAppId); expect(config.measurementId).to.equal(fakeMeasurementId); }); it('throws error on failed response', async () => { stubFetch(500, { error: { /* no message */ } }); const app = getFakeApp(fakeAppParams); await expect( fetchDynamicConfig(app.options as AppFields) ).to.be.rejectedWith(AnalyticsError.CONFIG_FETCH_FAILED); }); it('throws error on failed response, includes server error message if provided', async () => { stubFetch(500, { error: { message: 'Oops' } }); const app = getFakeApp(fakeAppParams); await expect( fetchDynamicConfig(app.options as AppFields) ).to.be.rejectedWith( new RegExp(`Oops.+${AnalyticsError.CONFIG_FETCH_FAILED}`) ); }); }); describe('fetchDynamicConfigWithRetry()', () => { it('successfully request and receives dynamic config JSON data', async () => { stubFetch(200, successObject); const app = getFakeApp(fakeAppParams); const config: | DynamicConfig | MinimalDynamicConfig = await fetchDynamicConfigWithRetry(app); expect(fetchStub.args[0][0]).to.equal(fakeUrl); expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( fakeAppParams.apiKey ); expect(config.appId).to.equal(fakeAppId); expect(config.measurementId).to.equal(fakeMeasurementId); }); it('throws error on non-retriable failed response', async () => { stubFetch(404, { error: { /* no message */ } }); const app = getFakeApp(fakeAppParams); await expect(fetchDynamicConfigWithRetry(app)).to.be.rejectedWith( AnalyticsError.CONFIG_FETCH_FAILED ); }); it('warns on non-retriable failed response if local measurementId available', async () => { stubFetch(404, { error: { /* no message */ } }); const consoleStub = stub(console, 'warn'); const app = getFakeApp({ ...fakeAppParams, measurementId: fakeMeasurementId }); await fetchDynamicConfigWithRetry(app); expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); consoleStub.restore(); }); it('retries on retriable error until success', async () => { // Configures Date.now() to advance clock from zero in 20ms increments, enabling // tests to assert a known throttle end time and allow setTimeout to work. const clock = useFakeTimers({ shouldAdvanceTime: true }); // Ensures backoff is always zero, which simplifies reasoning about timer. const powSpy = stub(Math, 'pow').returns(0); const randomSpy = stub(Math, 'random').returns(0.5); const fakeRetryData = { throttleMetadata: {}, getThrottleMetadata: stub(), setThrottleMetadata: stub(), deleteThrottleMetadata: stub(), intervalMillis: 5 }; // Returns responses with each of 4 retriable statuses, then a success response. const retriableStatuses = [429, 500, 503, 504]; fetchStub = stub(window, 'fetch'); retriableStatuses.forEach((status, index) => { const failResponse = new window.Response(JSON.stringify({}), { status }); fetchStub.onCall(index).resolves(failResponse); }); const successResponse = new window.Response( JSON.stringify(successObject), { status: 200 } ); fetchStub.onCall(retriableStatuses.length).resolves(successResponse); const app = getFakeApp(fakeAppParams); const config: | DynamicConfig | MinimalDynamicConfig = await fetchDynamicConfigWithRetry( app, fakeRetryData ); // Verify retryData.setThrottleMetadata() was called on each retry. for (let i = 0; i < retriableStatuses.length; i++) { retriableStatuses[i]; expect(fakeRetryData.setThrottleMetadata.args[i][1]).to.deep.equal({ backoffCount: i + 1, throttleEndTimeMillis: (i + 1) * 20 }); } expect(fetchStub.args[0][0]).to.equal(fakeUrl); expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( fakeAppParams.apiKey ); expect(config.appId).to.equal(fakeAppId); expect(config.measurementId).to.equal(fakeMeasurementId); powSpy.restore(); randomSpy.restore(); clock.restore(); }); it('retries on retriable error until aborted by timeout', async () => { const fakeRetryData = { throttleMetadata: {}, getThrottleMetadata: stub(), setThrottleMetadata: stub(), deleteThrottleMetadata: stub(), intervalMillis: 10 }; // Always returns retriable server error. stubFetch(500, {}); const app = getFakeApp(fakeAppParams); // Set fetch timeout to 50 ms. const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); await expect(fetchPromise).to.be.rejectedWith( AnalyticsError.FETCH_THROTTLE ); // Should be enough time for at least 2 retries, including fuzzing. expect(fakeRetryData.setThrottleMetadata.callCount).to.be.greaterThan(1); }); it('retries on 503 error until aborted by timeout', async () => { const fakeRetryData = { throttleMetadata: {}, getThrottleMetadata: stub(), setThrottleMetadata: stub(), deleteThrottleMetadata: stub(), intervalMillis: 10 }; // Always returns retriable server error. stubFetch(503, {}); const app = getFakeApp(fakeAppParams); // Set fetch timeout to 50 ms. const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); await expect(fetchPromise).to.be.rejectedWith( AnalyticsError.FETCH_THROTTLE ); const retryTime1 = fakeRetryData.setThrottleMetadata.args[0][1].throttleEndTimeMillis; const retryTime2 = fakeRetryData.setThrottleMetadata.args[1][1].throttleEndTimeMillis; expect(fakeRetryData.setThrottleMetadata).to.be.called; // Interval between first and second retry should be greater than lowest fuzzable // value of LONG_RETRY_FACTOR. expect(retryTime2 - retryTime1).to.be.at.least( Math.floor(LONG_RETRY_FACTOR / 2) * fakeRetryData.intervalMillis ); }); it( 'retries on retriable error until aborted by timeout,' + ' then uses local measurementId if available', async () => { const fakeRetryData = { throttleMetadata: {}, getThrottleMetadata: stub(), setThrottleMetadata: stub(), deleteThrottleMetadata: stub(), intervalMillis: 10 }; // Always returns retriable server error. stubFetch(500, {}); const consoleStub = stub(console, 'warn'); const app = getFakeApp({ ...fakeAppParams, measurementId: fakeMeasurementId }); // Set fetch timeout to 50 ms. await fetchDynamicConfigWithRetry(app, fakeRetryData, 50); expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); consoleStub.restore(); } ); }); });