Skip to content

Commit 927c1af

Browse files
authored
Add a last sent date to heartbeat storage (#6039)
1 parent 88841d4 commit 927c1af

File tree

5 files changed

+198
-124
lines changed

5 files changed

+198
-124
lines changed

.changeset/sweet-pumas-dance.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/app': patch
3+
---
4+
5+
Fix heartbeat controller to ensure not sending more than one a day.

packages/app/src/heartbeatService.test.ts

+138-49
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { FirebaseApp } from './public-types';
3232
import * as firebaseUtil from '@firebase/util';
3333
import { SinonStub, stub, useFakeTimers } from 'sinon';
3434
import * as indexedDb from './indexeddb';
35-
import { base64Encode, isIndexedDBAvailable } from '@firebase/util';
3635

3736
declare module '@firebase/component' {
3837
interface NameServiceMapping {
@@ -99,38 +98,37 @@ describe('HeartbeatServiceImpl', () => {
9998
*/
10099
it(`triggerHeartbeat() stores a heartbeat`, async () => {
101100
await heartbeatService.triggerHeartbeat();
102-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
103-
const heartbeat1 = heartbeatService._heartbeatsCache?.[0];
104-
expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1);
101+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
102+
const heartbeat1 = heartbeatService._heartbeatsCache?.heartbeats[0];
103+
expect(heartbeat1?.agent).to.equal(USER_AGENT_STRING_1);
105104
expect(heartbeat1?.date).to.equal('1970-01-01');
106-
expect(writeStub).to.be.calledWith([heartbeat1]);
105+
expect(writeStub).to.be.calledWith({ heartbeats: [heartbeat1] });
107106
});
108107
it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => {
109-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
108+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
110109
await heartbeatService.triggerHeartbeat();
111-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
110+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
112111
});
113112
it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => {
114-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
113+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
115114
clock.tick(24 * 60 * 60 * 1000);
116115
await heartbeatService.triggerHeartbeat();
117-
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
118-
expect(heartbeatService._heartbeatsCache?.[1].date).to.equal(
116+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2);
117+
expect(heartbeatService._heartbeatsCache?.heartbeats[1].date).to.equal(
119118
'1970-01-02'
120119
);
121120
});
122121
it(`triggerHeartbeat() stores another entry for a different user agent`, async () => {
123122
userAgentString = USER_AGENT_STRING_2;
124-
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
123+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2);
125124
clock.tick(2 * 24 * 60 * 60 * 1000);
126125
await heartbeatService.triggerHeartbeat();
127-
expect(heartbeatService._heartbeatsCache?.length).to.equal(3);
128-
expect(heartbeatService._heartbeatsCache?.[2].date).to.equal(
126+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(3);
127+
expect(heartbeatService._heartbeatsCache?.heartbeats[2].date).to.equal(
129128
'1970-01-03'
130129
);
131130
});
132131
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
133-
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
134132
const heartbeatHeaders = firebaseUtil.base64Decode(
135133
await heartbeatService.getHeartbeatsHeader()
136134
);
@@ -140,10 +138,13 @@ describe('HeartbeatServiceImpl', () => {
140138
expect(heartbeatHeaders).to.include('1970-01-02');
141139
expect(heartbeatHeaders).to.include('1970-01-03');
142140
expect(heartbeatHeaders).to.include(`"version":2`);
143-
expect(heartbeatService._heartbeatsCache).to.equal(null);
141+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty;
142+
expect(writeStub).to.be.calledWith({
143+
lastSentHeartbeatDate: '1970-01-01',
144+
heartbeats: []
145+
});
144146
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
145147
expect(emptyHeaders).to.equal('');
146-
expect(deleteStub).to.be.called;
147148
});
148149
});
149150
describe('If IndexedDB has entries', () => {
@@ -154,11 +155,11 @@ describe('HeartbeatServiceImpl', () => {
154155
const mockIndexedDBHeartbeats = [
155156
// Chosen so one will exceed 30 day limit and one will not.
156157
{
157-
userAgent: 'old-user-agent',
158+
agent: 'old-user-agent',
158159
date: '1969-12-01'
159160
},
160161
{
161-
userAgent: 'old-user-agent',
162+
agent: 'old-user-agent',
162163
date: '1969-12-31'
163164
}
164165
];
@@ -197,61 +198,149 @@ describe('HeartbeatServiceImpl', () => {
197198
*/
198199
it(`new heartbeat service reads from indexedDB cache`, async () => {
199200
const promiseResult = await heartbeatService._heartbeatsCachePromise;
200-
if (isIndexedDBAvailable()) {
201-
expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats);
202-
expect(heartbeatService._heartbeatsCache).to.deep.equal(
203-
mockIndexedDBHeartbeats
204-
);
201+
if (firebaseUtil.isIndexedDBAvailable()) {
202+
expect(promiseResult).to.deep.equal({
203+
heartbeats: mockIndexedDBHeartbeats
204+
});
205+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
206+
heartbeats: mockIndexedDBHeartbeats
207+
});
205208
} else {
206209
// In Node or other no-indexed-db environments it will fail the
207210
// `canUseIndexedDb` check and return an empty array.
208-
expect(promiseResult).to.deep.equal([]);
209-
expect(heartbeatService._heartbeatsCache).to.deep.equal([]);
211+
expect(promiseResult).to.deep.equal({
212+
heartbeats: []
213+
});
214+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
215+
heartbeats: []
216+
});
210217
}
211218
});
212219
it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => {
213220
userAgentString = USER_AGENT_STRING_2;
214221
clock.tick(3 * 24 * 60 * 60 * 1000);
215222
await heartbeatService.triggerHeartbeat();
216-
if (isIndexedDBAvailable()) {
217-
expect(writeStub).to.be.calledWith([
218-
// The first entry exceeds the 30 day retention limit.
219-
mockIndexedDBHeartbeats[1],
220-
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
221-
]);
223+
if (firebaseUtil.isIndexedDBAvailable()) {
224+
expect(writeStub).to.be.calledWith({
225+
heartbeats: [
226+
// The first entry exceeds the 30 day retention limit.
227+
mockIndexedDBHeartbeats[1],
228+
{ agent: USER_AGENT_STRING_2, date: '1970-01-04' }
229+
]
230+
});
222231
} else {
223-
expect(writeStub).to.be.calledWith([
224-
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
225-
]);
232+
expect(writeStub).to.be.calledWith({
233+
heartbeats: [{ agent: USER_AGENT_STRING_2, date: '1970-01-04' }]
234+
});
226235
}
227236
});
228237
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
229-
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
230238
const heartbeatHeaders = firebaseUtil.base64Decode(
231239
await heartbeatService.getHeartbeatsHeader()
232240
);
233-
if (isIndexedDBAvailable()) {
241+
if (firebaseUtil.isIndexedDBAvailable()) {
234242
expect(heartbeatHeaders).to.include('old-user-agent');
235243
expect(heartbeatHeaders).to.include('1969-12-31');
236244
}
237245
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2);
238246
expect(heartbeatHeaders).to.include('1970-01-04');
239247
expect(heartbeatHeaders).to.include(`"version":2`);
240-
expect(heartbeatService._heartbeatsCache).to.equal(null);
248+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty;
249+
expect(writeStub).to.be.calledWith({
250+
lastSentHeartbeatDate: '1970-01-01',
251+
heartbeats: []
252+
});
241253
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
242254
expect(emptyHeaders).to.equal('');
243-
expect(deleteStub).to.be.called;
255+
});
256+
});
257+
258+
describe('If IndexedDB records that a header was sent today', () => {
259+
let heartbeatService: HeartbeatServiceImpl;
260+
let writeStub: SinonStub;
261+
const userAgentString = USER_AGENT_STRING_1;
262+
const mockIndexedDBHeartbeats = [
263+
// Chosen so one will exceed 30 day limit and one will not.
264+
{
265+
agent: 'old-user-agent',
266+
date: '1969-12-01'
267+
},
268+
{
269+
agent: 'old-user-agent',
270+
date: '1969-12-31'
271+
}
272+
];
273+
before(() => {
274+
const container = new ComponentContainer('heartbeatTestContainer');
275+
container.addComponent(
276+
new Component(
277+
'app',
278+
() =>
279+
({
280+
options: { appId: 'an-app-id' },
281+
name: 'an-app-name'
282+
} as FirebaseApp),
283+
ComponentType.VERSION
284+
)
285+
);
286+
container.addComponent(
287+
new Component(
288+
'platform-logger',
289+
() => ({ getPlatformInfoString: () => userAgentString }),
290+
ComponentType.VERSION
291+
)
292+
);
293+
stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({
294+
lastSentHeartbeatDate: '1970-01-01',
295+
heartbeats: [...mockIndexedDBHeartbeats]
296+
});
297+
heartbeatService = new HeartbeatServiceImpl(container);
298+
});
299+
beforeEach(() => {
300+
useFakeTimers();
301+
writeStub = stub(heartbeatService._storage, 'overwrite');
302+
});
303+
it(`new heartbeat service reads from indexedDB cache`, async () => {
304+
const promiseResult = await heartbeatService._heartbeatsCachePromise;
305+
if (firebaseUtil.isIndexedDBAvailable()) {
306+
expect(promiseResult).to.deep.equal({
307+
lastSentHeartbeatDate: '1970-01-01',
308+
heartbeats: mockIndexedDBHeartbeats
309+
});
310+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
311+
lastSentHeartbeatDate: '1970-01-01',
312+
heartbeats: mockIndexedDBHeartbeats
313+
});
314+
} else {
315+
// In Node or other no-indexed-db environments it will fail the
316+
// `canUseIndexedDb` check and return an empty array.
317+
expect(promiseResult).to.deep.equal({
318+
heartbeats: []
319+
});
320+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
321+
heartbeats: []
322+
});
323+
}
324+
});
325+
it(`triggerHeartbeat() will skip storing new data`, async () => {
326+
await heartbeatService.triggerHeartbeat();
327+
expect(writeStub).to.not.be.called;
328+
if (firebaseUtil.isIndexedDBAvailable()) {
329+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.deep.equal(
330+
mockIndexedDBHeartbeats
331+
);
332+
}
244333
});
245334
});
246335

247336
describe('countBytes()', () => {
248337
it('counts how many bytes there will be in a stringified, encoded header', () => {
249338
const heartbeats = [
250-
{ userAgent: generateUserAgentString(1), dates: generateDates(1) },
251-
{ userAgent: generateUserAgentString(3), dates: generateDates(2) }
339+
{ agent: generateUserAgentString(1), dates: generateDates(1) },
340+
{ agent: generateUserAgentString(3), dates: generateDates(2) }
252341
];
253342
let size: number = 0;
254-
const headerString = base64Encode(
343+
const headerString = firebaseUtil.base64urlEncodeWithoutPadding(
255344
JSON.stringify({ version: 2, heartbeats })
256345
);
257346
// Use independent methods to validate our byte count method matches.
@@ -272,7 +361,7 @@ describe('HeartbeatServiceImpl', () => {
272361
describe('_extractHeartbeatsForHeader()', () => {
273362
it('returns empty heartbeatsToKeep if it cannot get under maxSize', () => {
274363
const heartbeats = [
275-
{ userAgent: generateUserAgentString(1), date: '2022-01-01' }
364+
{ agent: generateUserAgentString(1), date: '2022-01-01' }
276365
];
277366
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
278367
heartbeats,
@@ -283,11 +372,11 @@ describe('HeartbeatServiceImpl', () => {
283372
});
284373
it('splits heartbeats array', () => {
285374
const heartbeats = [
286-
{ userAgent: generateUserAgentString(20), date: '2022-01-01' },
287-
{ userAgent: generateUserAgentString(4), date: '2022-01-02' }
375+
{ agent: generateUserAgentString(20), date: '2022-01-01' },
376+
{ agent: generateUserAgentString(4), date: '2022-01-02' }
288377
];
289378
const sizeWithHeartbeat0Only = countBytes([
290-
{ userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] }
379+
{ agent: heartbeats[0].agent, dates: [heartbeats[0].date] }
291380
]);
292381
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
293382
heartbeats,
@@ -299,12 +388,12 @@ describe('HeartbeatServiceImpl', () => {
299388
it('splits the first heartbeat if needed', () => {
300389
const uaString = generateUserAgentString(20);
301390
const heartbeats = [
302-
{ userAgent: uaString, date: '2022-01-01' },
303-
{ userAgent: uaString, date: '2022-01-02' },
304-
{ userAgent: uaString, date: '2022-01-03' }
391+
{ agent: uaString, date: '2022-01-01' },
392+
{ agent: uaString, date: '2022-01-02' },
393+
{ agent: uaString, date: '2022-01-03' }
305394
];
306395
const sizeWithHeartbeat0Only = countBytes([
307-
{ userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] }
396+
{ agent: heartbeats[0].agent, dates: [heartbeats[0].date] }
308397
]);
309398
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
310399
heartbeats,

0 commit comments

Comments
 (0)