Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
30 changes: 30 additions & 0 deletions packages/firestore/__tests__/firestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import FirebaseModule from '../../app/lib/internal/FirebaseModule';
import Query from '../lib/FirestoreQuery';
// @ts-ignore test
import FirestoreDocumentSnapshot from '../lib/FirestoreDocumentSnapshot';
import { parseSnapshotArgs } from '../lib/utils';
// @ts-ignore test
import * as nativeModule from '@react-native-firebase/app/dist/module/internal/nativeModuleAndroidIos';

Expand Down Expand Up @@ -498,6 +499,35 @@ describe('Firestore', function () {
});
});
});

describe('onSnapshot()', function () {
it("accepts { source: 'cache' } listener options", function () {
const parsed = parseSnapshotArgs([{ source: 'cache' }, () => {}]);

expect(parsed.snapshotListenOptions).toEqual({
includeMetadataChanges: false,
source: 'cache',
});
});

it("accepts { source: 'default', includeMetadataChanges: true } listener options", function () {
const parsed = parseSnapshotArgs([
{ source: 'default', includeMetadataChanges: true },
() => {},
]);

expect(parsed.snapshotListenOptions).toEqual({
includeMetadataChanges: true,
source: 'default',
});
});

it("throws for unsupported listener source value 'server'", function () {
expect(() =>
parseSnapshotArgs([{ source: 'server' as 'default' | 'cache' }, () => {}]),
).toThrow("'options' SnapshotOptions.source must be one of 'default' or 'cache'.");
});
});
});

describe('modular', function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ private void handleQueryOnSnapshot(
int listenerId,
ReadableMap listenerOptions) {
MetadataChanges metadataChanges;
SnapshotListenOptions.Builder snapshotListenOptionsBuilder = new SnapshotListenOptions.Builder();

if (listenerOptions != null
&& listenerOptions.hasKey("includeMetadataChanges")
Expand All @@ -342,6 +343,15 @@ private void handleQueryOnSnapshot(
} else {
metadataChanges = MetadataChanges.EXCLUDE;
}
snapshotListenOptionsBuilder.setMetadataChanges(metadataChanges);

if (listenerOptions != null
&& listenerOptions.hasKey("source")
&& "cache".equals(listenerOptions.getString("source"))) {
snapshotListenOptionsBuilder.setSource(ListenSource.CACHE);
} else {
snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT);
}

final EventListener<QuerySnapshot> listener =
(querySnapshot, exception) -> {
Expand All @@ -358,7 +368,7 @@ private void handleQueryOnSnapshot(
};

ListenerRegistration listenerRegistration =
firestoreQuery.query.addSnapshotListener(metadataChanges, listener);
firestoreQuery.query.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener);

collectionSnapshotListeners.put(listenerId, listenerRegistration);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,26 @@ public void documentOnSnapshot(
}
};

MetadataChanges metadataChanges;
SnapshotListenOptions.Builder snapshotListenOptionsBuilder = new SnapshotListenOptions.Builder();

if (listenerOptions != null
&& listenerOptions.hasKey("includeMetadataChanges")
&& listenerOptions.getBoolean("includeMetadataChanges")) {
metadataChanges = MetadataChanges.INCLUDE;
snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.INCLUDE);
} else {
metadataChanges = MetadataChanges.EXCLUDE;
snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.EXCLUDE);
}

if (listenerOptions != null
&& listenerOptions.hasKey("source")
&& "cache".equals(listenerOptions.getString("source"))) {
snapshotListenOptionsBuilder.setSource(ListenSource.CACHE);
} else {
snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT);
}

ListenerRegistration listenerRegistration =
documentReference.addSnapshotListener(metadataChanges, listener);
documentReference.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener);

documentSnapshotListeners.put(listenerId, listenerRegistration);
}
Expand Down
143 changes: 143 additions & 0 deletions packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,97 @@ describe('firestore().doc().onSnapshot()', function () {
}
});

it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
try {
firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({
source: 'server',
});
return Promise.reject(new Error('Did not throw an Error.'));
} catch (error) {
error.message.should.containEql(
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
);
return Promise.resolve();
}
});

it('accepts source-only SnapshotListenerOptions', async function () {
if (Platform.other) {
return;
}
const callback = sinon.spy();
const unsub = firebase.firestore().doc(`${COLLECTION}/source-only`).onSnapshot(
{
source: 'cache',
},
callback,
);

await Utils.spyToBeCalledOnceAsync(callback);
unsub();
});

it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () {
if (Platform.other) {
return;
}
const callback = sinon.spy();
const unsub = firebase.firestore().doc(`${COLLECTION}/source-with-metadata`).onSnapshot(
{
source: 'default',
includeMetadataChanges: true,
},
callback,
);

await Utils.spyToBeCalledOnceAsync(callback);
unsub();
});

it('uses cache source for document listeners', async function () {
if (Platform.other) {
return;
}

const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`);
await docRef.set({ enabled: true });
await docRef.get();

let unsub = () => {};
try {
await firebase.firestore().disableNetwork();
const callback = sinon.spy();
unsub = docRef.onSnapshot({ source: 'cache' }, callback);
await Utils.spyToBeCalledOnceAsync(callback);
callback.args[0][0].metadata.fromCache.should.equal(true);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the added test - thank you - I wonder if this truly probes it though - I think it would be false-positive in that ListenSource.default and ListenSource.cache would have the same behavior in this scenario wouldn't they (since default listens to server and cache) ? So this doesn't really differentiate if the implementation is correct

The only thing I can think of that would truly differentiate is to have a function outside of the test app update firestore (so it didn't go in the test app local cache during set) and then have two listeners with the two different sources, do the out-of-band server set and make sure server listener was called but cache was not. Then do an in-band set and make sure they were both called

Cloud function to run in emulator could be added in to this area https://github.com/invertase/react-native-firebase/blob/main/.github/workflows/scripts/functions/src/index.ts ) where you told it a document path to update and a key/value pair perhaps (or anything that worked) via a post from the test app, and post from the test app could look like the auth ones do https://github.com/invertase/react-native-firebase/blob/main/packages/auth/e2e/helpers.js

A bit heavy but it is the only idea I have to really differentiate between the two source options and prove they work as defined vs just appear to function without error

Thoughts?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the existing test didn't really prove anything!

Instead of going the callable function route I added an out-of-band helper function that uses direct API access to firestore, adds a little boilerplate but avoids the dependency on functions and could potentially be re-used in the future. Let me know if you'd prefer me to go the callable function route and I can do that.

} finally {
unsub();
await firebase.firestore().enableNetwork();
}
});

it('supports cache source with metadata changes', async function () {
if (Platform.other) {
return;
}

const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`);
await docRef.set({ enabled: true });
await docRef.get();

let unsub = () => {};
try {
await firebase.firestore().disableNetwork();
const callback = sinon.spy();
unsub = docRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback);
await Utils.spyToBeCalledOnceAsync(callback);
callback.args[0][0].metadata.fromCache.should.equal(true);
} finally {
unsub();
await firebase.firestore().enableNetwork();
}
});

it('throws if next callback is invalid', function () {
try {
firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({
Expand Down Expand Up @@ -616,6 +707,58 @@ describe('firestore().doc().onSnapshot()', function () {
}
});

it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
const { getFirestore, doc, onSnapshot } = firestoreModular;
try {
onSnapshot(doc(getFirestore(), `${NO_RULE_COLLECTION}/nope`), {
source: 'server',
});
return Promise.reject(new Error('Did not throw an Error.'));
} catch (error) {
error.message.should.containEql(
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
);
return Promise.resolve();
}
});

it('accepts source-only SnapshotListenerOptions', async function () {
if (Platform.other) {
return;
}
const { getFirestore, doc, onSnapshot } = firestoreModular;
const callback = sinon.spy();
const unsub = onSnapshot(
doc(getFirestore(), `${COLLECTION}/mod-source-only`),
{
source: 'cache',
},
callback,
);

await Utils.spyToBeCalledOnceAsync(callback);
unsub();
});

it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () {
if (Platform.other) {
return;
}
const { getFirestore, doc, onSnapshot } = firestoreModular;
const callback = sinon.spy();
const unsub = onSnapshot(
doc(getFirestore(), `${COLLECTION}/mod-source-with-metadata`),
{
source: 'default',
includeMetadataChanges: true,
},
callback,
);

await Utils.spyToBeCalledOnceAsync(callback);
unsub();
});

it('throws if next callback is invalid', function () {
const { getFirestore, doc, onSnapshot } = firestoreModular;
try {
Expand Down
75 changes: 75 additions & 0 deletions packages/firestore/e2e/Query/onSnapshot.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,66 @@ describe('firestore().collection().onSnapshot()', function () {
}
});

it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
try {
firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({
source: 'server',
});
return Promise.reject(new Error('Did not throw an Error.'));
} catch (error) {
error.message.should.containEql(
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
);
return Promise.resolve();
}
});

it('uses cache source for query listeners', async function () {
if (Platform.other) {
return;
}

const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source`;
const colRef = firebase.firestore().collection(collectionPath);
await colRef.doc('one').set({ enabled: true });
await colRef.get();

let unsub = () => {};
try {
await firebase.firestore().disableNetwork();
const callback = sinon.spy();
unsub = colRef.onSnapshot({ source: 'cache' }, callback);
await Utils.spyToBeCalledOnceAsync(callback);
callback.args[0][0].metadata.fromCache.should.equal(true);
} finally {
unsub();
await firebase.firestore().enableNetwork();
}
});

it('supports cache source with metadata changes', async function () {
if (Platform.other) {
return;
}

const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source-meta`;
const colRef = firebase.firestore().collection(collectionPath);
await colRef.doc('one').set({ enabled: true });
await colRef.get();

let unsub = () => {};
try {
await firebase.firestore().disableNetwork();
const callback = sinon.spy();
unsub = colRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback);
await Utils.spyToBeCalledOnceAsync(callback);
callback.args[0][0].metadata.fromCache.should.equal(true);
} finally {
unsub();
await firebase.firestore().enableNetwork();
}
});

it('throws if next callback is invalid', function () {
try {
firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({
Expand Down Expand Up @@ -637,6 +697,21 @@ describe('firestore().collection().onSnapshot()', function () {
}
});

it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
const { getFirestore, collection, onSnapshot } = firestoreModular;
try {
onSnapshot(collection(getFirestore(), NO_RULE_COLLECTION), {
source: 'server',
});
return Promise.reject(new Error('Did not throw an Error.'));
} catch (error) {
error.message.should.containEql(
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
);
return Promise.resolve();
}
});

it('throws if next callback is invalid', function () {
const { getFirestore, collection, onSnapshot } = firestoreModular;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#import "RNFBFirestoreSerialize.h"

static NSString *const KEY_INCLUDE_METADATA_CHANGES = @"includeMetadataChanges";
static NSString *const KEY_SOURCE = @"source";

@interface RNFBFirestoreCollectionModule : NSObject <RCTBridgeModule>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,13 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp
listenerId:(nonnull NSNumber *)listenerId
listenerOptions:(NSDictionary *)listenerOptions {
BOOL includeMetadataChanges = NO;
FIRListenSource source = FIRListenSourceDefault;
if (listenerOptions[KEY_INCLUDE_METADATA_CHANGES] != nil) {
includeMetadataChanges = [listenerOptions[KEY_INCLUDE_METADATA_CHANGES] boolValue];
}
if ([listenerOptions[KEY_SOURCE] isEqualToString:@"cache"]) {
source = FIRListenSourceCache;
}

__weak RNFBFirestoreCollectionModule *weakSelf = self;
id listenerBlock = ^(FIRQuerySnapshot *snapshot, NSError *error) {
Expand All @@ -362,9 +366,12 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp
}
};

FIRSnapshotListenOptions *snapshotListenOptions =
[[[[FIRSnapshotListenOptions alloc] init]
optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source];
id<FIRListenerRegistration> listener = [[firestoreQuery instance]
addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges
listener:listenerBlock];
addSnapshotListenerWithOptions:snapshotListenOptions
listener:listenerBlock];
collectionSnapshotListeners[listenerId] = listener;
}

Expand Down
Loading
Loading