Skip to content

Commit d9a54ee

Browse files
committed
Firestore: query.test.ts: improve the test that resumes a query with existence filter to actually validate the existence filter.
This builds upon the test added in #7134
1 parent 480d7d5 commit d9a54ee

File tree

7 files changed

+229
-4
lines changed

7 files changed

+229
-4
lines changed

integration/firestore/gulpfile.js

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function copyTests() {
4545
testBase + '/integration/util/events_accumulator.ts',
4646
testBase + '/integration/util/helpers.ts',
4747
testBase + '/integration/util/settings.ts',
48+
testBase + '/integration/util/testing_hooks_util.ts',
4849
testBase + '/util/equality_matcher.ts',
4950
testBase + '/util/promise.ts'
5051
],

packages/firestore/src/api.ts

+1
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,4 @@ export type { ByteString as _ByteString } from './util/byte_string';
211211
export { logWarn as _logWarn } from './util/log';
212212
export { EmptyAuthCredentialsProvider as _EmptyAuthCredentialsProvider } from './api/credentials';
213213
export { EmptyAppCheckTokenProvider as _EmptyAppCheckTokenProvider } from './api/credentials';
214+
export { TestingHooks as _TestingHooks } from './util/testing_hooks';

packages/firestore/src/remote/watch_change.ts

+5
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { logDebug } from '../util/log';
3434
import { primitiveComparator } from '../util/misc';
3535
import { SortedMap } from '../util/sorted_map';
3636
import { SortedSet } from '../util/sorted_set';
37+
import { TestingHooks } from '../util/testing_hooks';
3738

3839
import { ExistenceFilter } from './existence_filter';
3940
import { RemoteEvent, TargetChange } from './remote_event';
@@ -414,6 +415,10 @@ export class WatchChangeAggregator {
414415
// snapshot with `isFromCache:true`.
415416
this.resetTarget(targetId);
416417
this.pendingTargetResets = this.pendingTargetResets.add(targetId);
418+
TestingHooks.instance?.notifyOnExistenceFilterMismatch({
419+
localCacheCount: currentSize,
420+
existenceFilterCount: watchChange.existenceFilter.count
421+
});
417422
}
418423
}
419424
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Manages "testing hooks", hooks into the internals of the SDK to verify
20+
* internal state and events during integration tests. Do not use this class
21+
* except for testing purposes.
22+
*
23+
* There are two ways to retrieve the global singleton instance of this class:
24+
* 1. The `instance` property, which returns null if the global singleton
25+
* instance has not been created. Use this property if the caller should
26+
* "do nothing" if there are no testing hooks registered, such as when
27+
* delivering an event to notify registered callbacks.
28+
* 2. The `getOrCreateInstance()` method, which creates the global singleton
29+
* instance if it has not been created. Use this method if the instance is
30+
* needed to, for example, register a callback.
31+
*
32+
* @internal
33+
*/
34+
export class TestingHooks {
35+
private readonly onExistenceFilterMismatchCallbacks = new Map<
36+
Symbol,
37+
ExistenceFilterMismatchCallback
38+
>();
39+
40+
private constructor() {}
41+
42+
/**
43+
* Returns the singleton instance of this class, or null if it has not been
44+
* initialized.
45+
*/
46+
static get instance(): TestingHooks | null {
47+
return gTestingHooksSingletonInstance;
48+
}
49+
50+
/**
51+
* Returns the singleton instance of this class, creating it if is has never
52+
* been created before.
53+
*/
54+
static getOrCreateInstance(): TestingHooks {
55+
if (gTestingHooksSingletonInstance === null) {
56+
gTestingHooksSingletonInstance = new TestingHooks();
57+
}
58+
return gTestingHooksSingletonInstance;
59+
}
60+
61+
/**
62+
* Registers a callback to be notified when an existence filter mismatch
63+
* occurs in the Watch listen stream.
64+
*
65+
* The relative order in which callbacks are notified is unspecified; do not
66+
* rely on any particular ordering. If a given callback is registered multiple
67+
* times then it will be notified multiple times, once per registration.
68+
*
69+
* @param callback the callback to invoke upon existence filter mismatch.
70+
*
71+
* @return a function that, when called, unregisters the given callback; only
72+
* the first invocation of the returned function does anything; all subsequent
73+
* invocations do nothing.
74+
*/
75+
onExistenceFilterMismatch(
76+
callback: ExistenceFilterMismatchCallback
77+
): () => void {
78+
const key = Symbol();
79+
this.onExistenceFilterMismatchCallbacks.set(key, callback);
80+
return () => this.onExistenceFilterMismatchCallbacks.delete(key);
81+
}
82+
83+
/**
84+
* Invokes all currently-registered `onExistenceFilterMismatch` callbacks.
85+
* @param info Information about the existence filter mismatch.
86+
*/
87+
notifyOnExistenceFilterMismatch(info: ExistenceFilterMismatchInfo): void {
88+
this.onExistenceFilterMismatchCallbacks.forEach(callback => callback(info));
89+
}
90+
}
91+
92+
/**
93+
* Information about an existence filter mismatch, as specified to callbacks
94+
* registered with `TestingUtils.onExistenceFilterMismatch()`.
95+
*/
96+
export interface ExistenceFilterMismatchInfo {
97+
/** The number of documents that matched the query in the local cache. */
98+
localCacheCount: number;
99+
100+
/**
101+
* The number of documents that matched the query on the server, as specified
102+
* in the ExistenceFilter message's `count` field.
103+
*/
104+
existenceFilterCount: number;
105+
}
106+
107+
/**
108+
* The signature of callbacks registered with
109+
* `TestingUtils.onExistenceFilterMismatch()`.
110+
*/
111+
export type ExistenceFilterMismatchCallback = (
112+
info: ExistenceFilterMismatchInfo
113+
) => void;
114+
115+
/** The global singleton instance of `TestingHooks`. */
116+
let gTestingHooksSingletonInstance: TestingHooks | null = null;

packages/firestore/test/integration/api/query.test.ts

+38-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
Bytes,
2727
collection,
2828
collectionGroup,
29+
CollectionReference,
2930
deleteDoc,
3031
disableNetwork,
3132
doc,
@@ -65,7 +66,8 @@ import {
6566
withTestCollection,
6667
withTestDb
6768
} from '../util/helpers';
68-
import { USE_EMULATOR } from '../util/settings';
69+
import { USE_EMULATOR, TARGET_BACKEND } from '../util/settings';
70+
import { captureExistenceFilterMismatches } from '../util/testing_hooks_util';
6971

7072
apiDescribe('Queries', (persistence: boolean) => {
7173
addEqualityMatcher();
@@ -2092,7 +2094,10 @@ apiDescribe('Queries', (persistence: boolean) => {
20922094
await new Promise(resolve => setTimeout(resolve, 10000));
20932095

20942096
// Resume the query and save the resulting snapshot for verification.
2095-
const snapshot2 = await getDocs(coll);
2097+
// Use some internal testing hooks to "capture" the existence filter
2098+
// mismatches to verify them.
2099+
const [existenceFilterMismatches, snapshot2] =
2100+
await captureExistenceFilterMismatches(() => getDocs(coll));
20962101

20972102
// Verify that the snapshot from the resumed query contains the expected
20982103
// documents; that is, that it contains the 50 documents that were _not_
@@ -2114,6 +2119,37 @@ apiDescribe('Queries', (persistence: boolean) => {
21142119
expectedDocumentIds
21152120
);
21162121
}
2122+
2123+
// Skip the verification of the existence filter mismatch when persistence
2124+
// is disabled because without persistence there is no resume token
2125+
// specified in the subsequent call to getDocs(), and, therefore, Watch
2126+
// will _not_ send an existence filter.
2127+
// TODO(b/272754156) Re-write this test using a snapshot listener instead
2128+
// of calls to getDocs() and remove this check for disabled persistence.
2129+
if (!persistence) {
2130+
return;
2131+
}
2132+
2133+
// Skip the verification of the existence filter mismatch when testing
2134+
// against the Firestore emulator because the Firestore emulator fails to
2135+
// to send an existence filter at all.
2136+
// TODO(b/270731363): Enable the verification of the existence filter
2137+
// mismatch once the Firestore emulator is fixed to send an existence
2138+
// filter.
2139+
if (USE_EMULATOR) {
2140+
return;
2141+
}
2142+
2143+
// Verify that Watch sent an existence filter with the correct counts when
2144+
// the query was resumed.
2145+
expect(
2146+
existenceFilterMismatches,
2147+
'existenceFilterMismatches'
2148+
).to.have.length(1);
2149+
const { localCacheCount, existenceFilterCount } =
2150+
existenceFilterMismatches[0];
2151+
expect(localCacheCount, 'localCacheCount').to.equal(100);
2152+
expect(existenceFilterCount, 'existenceFilterCount').to.equal(50);
21172153
});
21182154
}).timeout('90s');
21192155
});

packages/firestore/test/integration/util/settings.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { PrivateSettings } from './firebase_export';
2525
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2626
declare const __karma__: any;
2727

28-
enum TargetBackend {
28+
export enum TargetBackend {
2929
EMULATOR = 'emulator',
3030
QA = 'qa',
3131
NIGHTLY = 'nightly',
@@ -35,7 +35,7 @@ enum TargetBackend {
3535
// eslint-disable-next-line @typescript-eslint/no-require-imports
3636
const PROJECT_CONFIG = require('../../../../../config/project.json');
3737

38-
const TARGET_BACKEND: TargetBackend = getTargetBackend();
38+
export const TARGET_BACKEND: TargetBackend = getTargetBackend();
3939

4040
export const USE_EMULATOR: boolean = TARGET_BACKEND === TargetBackend.EMULATOR;
4141

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { _TestingHooks as TestingHooks } from './firebase_export';
19+
20+
/**
21+
* Captures all existence filter mismatches in the Watch 'Listen' stream that
22+
* occur during the execution of the given code block.
23+
* @param callback The callback to invoke; during the invocation of this
24+
* callback all existence filter mismatches will be captured.
25+
* @return the captured existence filter mismatches.
26+
*/
27+
export async function captureExistenceFilterMismatches<T>(
28+
callback: () => Promise<T>
29+
): Promise<[ExistenceFilterMismatchInfo[], T]> {
30+
const results: ExistenceFilterMismatchInfo[] = [];
31+
const onExistenceFilterMismatchCallback = (
32+
info: ExistenceFilterMismatchInfo
33+
): void => {
34+
results.push(info);
35+
};
36+
37+
const unregister =
38+
TestingHooks.getOrCreateInstance().onExistenceFilterMismatch(
39+
onExistenceFilterMismatchCallback
40+
);
41+
42+
let callbackResult: T;
43+
try {
44+
callbackResult = await callback();
45+
} finally {
46+
unregister();
47+
}
48+
49+
return [results, callbackResult];
50+
}
51+
52+
/**
53+
* Information about an existence filter mismatch, capturing during an
54+
* invocation of `captureExistenceFilterMismatches()`.
55+
*
56+
* See the documentation of `TestingHooks.notifyOnExistenceFilterMismatch()`
57+
* for the meaning of these values.
58+
*
59+
* TODO: Delete this "interface" definition and instead use the one from
60+
* testing_hooks.ts. I tried to do this but couldn't figure out how to get it to
61+
* work in a way that survived bundling and minification.
62+
*/
63+
export interface ExistenceFilterMismatchInfo {
64+
localCacheCount: number;
65+
existenceFilterCount: number;
66+
}

0 commit comments

Comments
 (0)