Skip to content

Commit b61056f

Browse files
committed
initial unit tests
1 parent 8615a49 commit b61056f

File tree

3 files changed

+487
-63
lines changed

3 files changed

+487
-63
lines changed

packages/data-connect/src/core/Cache.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,7 @@ export class BackingDataObject {
8484
private serverValues: Map<string, Value>;
8585

8686
/** A set of listeners (StubDataObjects) that need to be updated when values change. */
87-
private listeners: Set<StubDataObject>;
88-
89-
/**
90-
* Adds a StubDataObject to the set of listeners for this BackingDataObject.
91-
* @param listener The StubDataObject to add.
92-
*/
93-
addListener(listener: StubDataObject): void {
94-
this.listeners.add(listener);
95-
}
96-
97-
/**
98-
* Removes a StubDataObject from the set of listeners.
99-
* @param listener The StubDataObject to remove.
100-
*/
101-
removeListener(listener: StubDataObject): void {
102-
this.listeners.delete(listener);
103-
}
87+
readonly listeners: Set<StubDataObject>;
10488

10589
constructor(typedKey: string, serverValues: Map<string, Value>) {
10690
this.typedKey = typedKey;
@@ -241,7 +225,7 @@ export class Cache {
241225
// TODO: don't cache non-cacheable fields!
242226
const serverValues = new Map<string, Value>(Object.entries(data));
243227
const newBdo = new BackingDataObject(bdoCacheKey, serverValues);
244-
newBdo.addListener(stubDataObject);
228+
newBdo.listeners.add(stubDataObject);
245229
this.bdoCache.set(bdoCacheKey, newBdo);
246230
}
247231

@@ -260,6 +244,6 @@ export class Cache {
260244
for (const [key, value] of Object.entries(data)) {
261245
backingDataObject.updateFromServer(value, key);
262246
}
263-
backingDataObject.addListener(stubDataObject);
247+
backingDataObject.listeners.add(stubDataObject);
264248
}
265249
}
Lines changed: 228 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable unused-imports/no-unused-imports-ts */
2+
/* eslint-disable @typescript-eslint/no-unused-vars */
13
/**
24
* @license
35
* Copyright 2024 Google LLC
@@ -15,66 +17,248 @@
1517
* limitations under the License.
1618
*/
1719

18-
import { FirebaseAuthTokenData } from '@firebase/auth-interop-types';
20+
import { deleteApp, FirebaseApp, initializeApp } from '@firebase/app';
1921
import { expect } from 'chai';
2022
import * as chai from 'chai';
2123
import chaiAsPromised from 'chai-as-promised';
2224
import * as sinon from 'sinon';
2325

24-
import { DataConnectOptions } from '../../src';
2526
import {
26-
AuthTokenListener,
27-
AuthTokenProvider
28-
} from '../../src/core/FirebaseAuthProvider';
29-
import { initializeFetch } from '../../src/network/fetch';
30-
import { RESTTransport } from '../../src/network/transport/rest';
27+
DataConnect,
28+
DataConnectOptions,
29+
DataSource,
30+
executeQuery,
31+
getDataConnect,
32+
mutationRef,
33+
queryRef,
34+
QueryResult,
35+
SerializedRef,
36+
SOURCE_SERVER
37+
} from '../../src';
38+
import { BackingDataObject, Cache, StubDataObject } from '../../src/core/Cache';
39+
import { Code, DataConnectError } from '../../src/core/error';
3140
chai.use(chaiAsPromised);
41+
42+
// TODO: convert to actually test cache stuffs...
43+
// Helper to create a mock QueryResult object for tests
44+
function createMockQueryResult<Data extends object, Variables>(
45+
queryName: string,
46+
variables: Variables,
47+
data: Data,
48+
dataConnectOptions: DataConnectOptions,
49+
dataconnect: DataConnect,
50+
source: DataSource = SOURCE_SERVER
51+
): QueryResult<Data, Variables> {
52+
const fetchTime = 'NOW';
53+
54+
return {
55+
ref: {
56+
name: queryName,
57+
variables,
58+
refType: 'query',
59+
dataConnect: dataconnect
60+
},
61+
data,
62+
source,
63+
fetchTime,
64+
toJSON(): SerializedRef<Data, Variables> {
65+
return {
66+
data,
67+
source,
68+
fetchTime,
69+
refInfo: {
70+
name: queryName,
71+
variables,
72+
connectorConfig: dataConnectOptions
73+
}
74+
};
75+
}
76+
};
77+
}
78+
3279
const options: DataConnectOptions = {
3380
connector: 'c',
3481
location: 'l',
3582
projectId: 'p',
3683
service: 's'
3784
};
38-
const INITIAL_TOKEN = 'initial token';
39-
class FakeAuthProvider implements AuthTokenProvider {
40-
private token: string | null = INITIAL_TOKEN;
41-
addTokenChangeListener(listener: AuthTokenListener): void {}
42-
getToken(forceRefresh: boolean): Promise<FirebaseAuthTokenData | null> {
43-
if (!forceRefresh) {
44-
return Promise.resolve({ accessToken: this.token! });
45-
}
46-
return Promise.resolve({ accessToken: 'testToken' });
47-
}
48-
setToken(_token: string | null): void {
49-
this.token = _token;
50-
}
51-
}
5285

53-
function getFakeFetchImpl(data: unknown, status: number): sinon.SinonStub {
54-
return sinon.stub().returns(
55-
Promise.resolve({
56-
json: () => {
57-
return Promise.resolve({ data, errors: [] });
58-
},
59-
status
60-
} as Response)
61-
);
86+
// Sample entity data for testing
87+
interface Movie extends StubDataObject {
88+
__typename: string;
89+
__id: string;
90+
id: string;
91+
title: string;
92+
releaseYear: number;
6293
}
6394

64-
describe('Cache', () => {
65-
let fakeFetchImpl: sinon.SinonStub;
66-
afterEach(() => {
67-
fakeFetchImpl.resetHistory();
95+
const movie1: Movie = {
96+
__typename: 'Movie',
97+
__id: '1',
98+
id: '1',
99+
title: 'Inception',
100+
releaseYear: 2010
101+
};
102+
103+
const movie2: Movie = {
104+
__typename: 'Movie',
105+
__id: '2',
106+
id: '2',
107+
title: 'The Matrix',
108+
releaseYear: 1999
109+
};
110+
111+
describe('Normalized Cache Tests', () => {
112+
let dc: DataConnect;
113+
let app: FirebaseApp;
114+
let cache: Cache;
115+
const APPID = 'MYAPPID';
116+
const APPNAME = 'MYAPPNAME';
117+
118+
beforeEach(() => {
119+
app = initializeApp({ projectId: 'p', appId: APPID }, APPNAME);
120+
dc = getDataConnect(app, {
121+
connector: 'c',
122+
location: 'l',
123+
service: 's'
124+
});
125+
cache = new Cache();
68126
});
69-
it('PLAYGROUND', async () => {
70-
const data = {
71-
movies: []
72-
};
73-
fakeFetchImpl = getFakeFetchImpl(data, 200);
74-
initializeFetch(fakeFetchImpl);
75-
const authProvider = new FakeAuthProvider();
76-
const rt = new RESTTransport(options, undefined, undefined, authProvider);
77-
const fetchResult = await rt.invokeQuery('test', null);
78-
expect(fetchResult.data).to.equal({ data, errors: [] });
127+
afterEach(async () => {
128+
await dc._delete();
129+
await deleteApp(app);
130+
});
131+
132+
describe('Key Generation', () => {
133+
it('should create a consistent result tree cache key', () => {
134+
const key1 = Cache.makeResultTreeCacheKey('listMovies', { limit: 10 });
135+
const key2 = Cache.makeResultTreeCacheKey('listMovies', { limit: 10 });
136+
const key3 = Cache.makeResultTreeCacheKey('listMovies', { limit: 20 });
137+
expect(key1).to.equal(key2);
138+
expect(key1).to.not.equal(key3);
139+
expect(key1).to.equal('listMovies|{"limit":10}');
140+
});
141+
142+
it('should create a consistent BDO cache key', () => {
143+
const key1 = Cache.makeBdoCacheKey('Movie', '1');
144+
const key2 = Cache.makeBdoCacheKey('Movie', '1');
145+
const key3 = Cache.makeBdoCacheKey('Actor', '1');
146+
expect(key1).to.equal(key2);
147+
expect(key1).to.not.equal(key3);
148+
expect(key1).to.equal('Movie|"1"');
149+
});
150+
});
151+
152+
describe('updateCache', () => {
153+
it('should create new BDOs for a list of new entities', () => {
154+
// This test validates the `createBdo` path for multiple entities.
155+
const queryResult = createMockQueryResult(
156+
'listMovies',
157+
{ limit: 2 },
158+
{ movies: [movie1, movie2] },
159+
options,
160+
dc
161+
);
162+
cache.updateCache(queryResult);
163+
164+
// 1. Check Result Tree Cache for the list of stubs
165+
const resultTreeKey = Cache.makeResultTreeCacheKey('listMovies', {
166+
limit: 2
167+
});
168+
const resultTree = cache.resultTreeCache.get(resultTreeKey)!;
169+
const stubList = resultTree.movies;
170+
expect(stubList).to.be.an('array').with.lengthOf(2);
171+
expect(stubList[0].title).to.equal('Inception');
172+
expect(stubList[1].title).to.equal('The Matrix');
173+
174+
// 2. Check that two new BDOs were created in the BDO Cache
175+
expect(cache.bdoCache.size).to.equal(2);
176+
const bdo1 = cache.bdoCache.get(Cache.makeBdoCacheKey('Movie', '1'))!;
177+
const bdo2 = cache.bdoCache.get(Cache.makeBdoCacheKey('Movie', '2'))!;
178+
expect(bdo1).to.exist.and.be.an.instanceof(BackingDataObject);
179+
expect(bdo2).to.exist.and.be.an.instanceof(BackingDataObject);
180+
181+
// 3. White-box test: Check that each BDO has the correct stub as a listener.
182+
const listeners1 = bdo1.listeners;
183+
const listeners2 = bdo2.listeners;
184+
expect(listeners1.has(stubList[0])).to.be.true;
185+
expect(listeners2.has(stubList[1])).to.be.true;
186+
});
187+
188+
it('should update an existing BDO and propagate changes to all listeners', () => {
189+
// This test validates the `updateBdo` path and the reactivity mechanism.
190+
// Step 1: Cache a list of movies, implicitly calling `createBdo`.
191+
const listQueryResult = createMockQueryResult(
192+
'listMovies',
193+
{},
194+
{
195+
movies: [movie1]
196+
},
197+
options,
198+
dc
199+
);
200+
cache.updateCache(listQueryResult);
201+
202+
// Get the original stub from the list to check it later
203+
const resultTreeKey = Cache.makeResultTreeCacheKey('listMovies', {});
204+
const originalStub = cache.resultTreeCache.get(resultTreeKey)!.movies[0];
205+
expect(originalStub.title).to.equal('Inception');
206+
expect(cache.bdoCache.size).to.equal(1);
207+
208+
// Step 2: A new query result comes in with updated data for the same movie.
209+
// This should trigger the `updateBdo` logic path.
210+
const updatedMovie1 = {
211+
...movie1,
212+
title: "Inception (Director's Cut)"
213+
};
214+
const singleQueryResult = createMockQueryResult(
215+
'getMovie',
216+
{ id: '1' },
217+
{ movie: updatedMovie1 },
218+
options,
219+
dc
220+
);
221+
cache.updateCache(singleQueryResult);
222+
223+
// Assertions
224+
// 1. No new BDO was created; the existing one was found and updated.
225+
expect(cache.bdoCache.size).to.equal(1);
226+
227+
// 2. The new stub from the getMovie query has the new title.
228+
const newStub = cache.resultTreeCache.get(
229+
Cache.makeResultTreeCacheKey('getMovie', { id: '1' })
230+
)!.movie as StubDataObject;
231+
expect(newStub.title).to.equal("Inception (Director's Cut)");
232+
233+
// 3. CRITICAL: The original stub in the list was also updated via the listener mechanism.
234+
// This confirms that `updateFromServer` correctly notified all listeners.
235+
expect(originalStub.title).to.equal("Inception (Director's Cut)");
236+
237+
// 4. White-box test: The BDO now has two listeners (the original list stub and the new single-item stub).
238+
const bdo = cache.bdoCache.get(Cache.makeBdoCacheKey('Movie', '1'))!;
239+
const listeners = bdo.listeners;
240+
expect(listeners.size).to.equal(2);
241+
expect(listeners.has(originalStub)).to.be.true;
242+
expect(listeners.has(newStub)).to.be.true;
243+
});
244+
245+
it('should handle empty lists in query results gracefully', () => {
246+
const queryResult = createMockQueryResult(
247+
'searchMovies',
248+
{ title: 'NonExistent' },
249+
{ movies: [] },
250+
options,
251+
dc
252+
);
253+
cache.updateCache(queryResult);
254+
255+
const resultTree = cache.resultTreeCache.get(
256+
Cache.makeResultTreeCacheKey('searchMovies', { title: 'NonExistent' })
257+
);
258+
expect(resultTree).to.exist;
259+
const stubList = resultTree!.movies as StubDataObject[];
260+
expect(stubList).to.be.an('array').with.lengthOf(0);
261+
expect(cache.bdoCache.size).to.equal(0);
262+
});
79263
});
80264
});

0 commit comments

Comments
 (0)