Skip to content

Commit 67b2fba

Browse files
committed
internal: Add integration test
1 parent d4fb76b commit 67b2fba

File tree

9 files changed

+250
-37
lines changed

9 files changed

+250
-37
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"@react-navigation/native": "^7.0.0",
6767
"@react-navigation/native-stack": "^7.0.0",
6868
"@testing-library/dom": "^10.4.0",
69+
"@testing-library/jest-dom": "^6.6.3",
6970
"@testing-library/react": "16.1.0",
7071
"@testing-library/react-hooks": "8.0.1",
7172
"@testing-library/react-native": "13.0.0",

packages/core/src/state/GCPolicy.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,30 @@ export class GCPolicy implements GCInterface {
5757

5858
// decrement
5959
return () => {
60-
const currentCount = this.endpointCount.get(key) ?? 0;
61-
if (currentCount <= 1) {
62-
this.endpointCount.delete(key);
63-
// queue for cleanup
64-
this.endpointsQ.add(key);
65-
} else {
66-
this.endpointCount.set(key, currentCount - 1);
60+
const currentCount = this.endpointCount.get(key)!;
61+
if (currentCount !== undefined) {
62+
if (currentCount <= 1) {
63+
this.endpointCount.delete(key);
64+
// queue for cleanup
65+
this.endpointsQ.add(key);
66+
} else {
67+
this.endpointCount.set(key, currentCount - 1);
68+
}
6769
}
6870
paths.forEach(path => {
6971
if (!this.entityCount.has(path.key)) {
7072
return;
7173
}
7274
const instanceCount = this.entityCount.get(path.key)!;
73-
if (instanceCount.get(path.pk)! <= 1) {
74-
instanceCount.delete(path.pk);
75-
// queue for cleanup
76-
this.entitiesQ.push(path);
77-
} else {
78-
instanceCount.set(path.pk, instanceCount.get(path.pk)! - 1);
75+
const entityCount = instanceCount.get(path.pk)!;
76+
if (entityCount !== undefined) {
77+
if (entityCount <= 1) {
78+
instanceCount.delete(path.pk);
79+
// queue for cleanup
80+
this.entitiesQ.push(path);
81+
} else {
82+
instanceCount.set(path.pk, entityCount - 1);
83+
}
7984
}
8085
});
8186
};

packages/core/src/state/__tests__/GCPolicy.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ describe('GCPolicy', () => {
6464
});
6565
});
6666

67+
it('should dispatch GC action once no ref counts and is expired with extra decrements', () => {
68+
const key = 'testEndpoint';
69+
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
70+
const state = {
71+
meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
72+
entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
73+
};
74+
(controller.getState as jest.Mock).mockReturnValue(state);
75+
76+
const countRef = gcPolicy.createCountRef({ key, paths });
77+
78+
const decrement = countRef();
79+
countRef(); // Increment again
80+
gcPolicy['runSweep']();
81+
expect(controller.dispatch).not.toHaveBeenCalled();
82+
decrement();
83+
gcPolicy['runSweep']();
84+
expect(controller.dispatch).not.toHaveBeenCalled();
85+
decrement(); // Decrement twice
86+
decrement(); // Decrement extra time
87+
88+
gcPolicy['runSweep']();
89+
expect(controller.dispatch).toHaveBeenCalledWith({
90+
type: GC,
91+
entities: [{ key: 'testEntity', pk: '1' }],
92+
endpoints: ['testEndpoint'],
93+
});
94+
});
95+
6796
it('should dispatch GC action once no ref counts and no expiry state', () => {
6897
const key = 'testEndpoint';
6998
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
AsyncBoundary,
3+
DataProvider,
4+
GCPolicy,
5+
useSuspense,
6+
} from '@data-client/react';
7+
import { MockResolver } from '@data-client/test';
8+
import { render, screen, act } from '@testing-library/react';
9+
import { ArticleResource } from '__tests__/new';
10+
import '@testing-library/jest-dom';
11+
import { useState } from 'react';
12+
13+
const mockGetList = jest.fn();
14+
const mockGet = jest.fn();
15+
const GC_INTERVAL = 5000;
16+
17+
const ListView = ({ onSelect }: { onSelect: (id: number) => void }) => {
18+
const articles = useSuspense(ArticleResource.getList);
19+
return (
20+
<div>
21+
{articles.map(article => (
22+
<div key={article.id} onClick={() => onSelect(article.id ?? 0)}>
23+
{article.title}
24+
</div>
25+
))}
26+
</div>
27+
);
28+
};
29+
30+
const DetailView = ({ id }: { id: number }) => {
31+
const article = useSuspense(ArticleResource.get, { id });
32+
const [toggle, setToggle] = useState(false);
33+
34+
return (
35+
<div>
36+
<h3>{article.title}</h3>
37+
<p>{article.content}</p>
38+
<button onClick={() => setToggle(!toggle)}>Toggle Re-render</button>
39+
{toggle && <div>Toggle state: {toggle.toString()}</div>}
40+
</div>
41+
);
42+
};
43+
44+
const TestComponent = () => {
45+
const [view, setView] = useState<'list' | 'detail'>('list');
46+
const [selectedId, setSelectedId] = useState<number | null>(null);
47+
48+
return (
49+
<DataProvider gcPolicy={new GCPolicy({ intervalMS: GC_INTERVAL })}>
50+
<MockResolver
51+
fixtures={[
52+
{
53+
endpoint: ArticleResource.getList,
54+
response: mockGetList,
55+
delay: 100,
56+
},
57+
{ endpoint: ArticleResource.get, response: mockGet, delay: 100 },
58+
]}
59+
>
60+
<div onClick={() => setView('list')}>Home</div>
61+
<AsyncBoundary fallback={<div>Loading...</div>}>
62+
{view === 'list' ?
63+
<ListView
64+
onSelect={id => {
65+
setSelectedId(id);
66+
setView('detail');
67+
}}
68+
/>
69+
: selectedId !== null && <DetailView id={selectedId} />}
70+
</AsyncBoundary>
71+
</MockResolver>
72+
</DataProvider>
73+
);
74+
};
75+
76+
test('switch between list and detail view', async () => {
77+
jest.useFakeTimers();
78+
mockGetList.mockResolvedValue([
79+
{ id: 1, title: 'Article 1', content: 'Content 1' },
80+
{ id: 2, title: 'Article 2', content: 'Content 2' },
81+
]);
82+
mockGet.mockResolvedValue({
83+
id: 1,
84+
title: 'Article 1',
85+
content: 'Content 1',
86+
});
87+
88+
render(<TestComponent />);
89+
90+
// Initial render, should show list view
91+
expect(await screen.findByText('Article 1')).toBeInTheDocument();
92+
93+
// Switch to detail view
94+
act(() => {
95+
screen.getByText('Article 1').click();
96+
});
97+
98+
// Detail view should render
99+
expect(await screen.findByText('Content 1')).toBeInTheDocument();
100+
101+
// Jest time pass to trigger sweep but not expired
102+
act(() => {
103+
jest.advanceTimersByTime(GC_INTERVAL);
104+
});
105+
106+
// Switch back to list view
107+
act(() => {
108+
screen.getByText('Home').click();
109+
});
110+
111+
// List view should instantly render
112+
expect(await screen.findByText('Article 1')).toBeInTheDocument();
113+
114+
// Switch back to detail view
115+
act(() => {
116+
screen.getByText('Article 1').click();
117+
});
118+
119+
// Jest time pass to expiry
120+
act(() => {
121+
jest.advanceTimersByTime(ArticleResource.getList.dataExpiryLength ?? 60000);
122+
});
123+
expect(await screen.findByText('Content 1')).toBeInTheDocument();
124+
125+
// Re-render detail view to make sure it still renders
126+
act(() => {
127+
screen.getByText('Toggle Re-render').click();
128+
});
129+
expect(await screen.findByText('Toggle state: true')).toBeInTheDocument();
130+
expect(await screen.findByText('Content 1')).toBeInTheDocument();
131+
132+
// Visit list view and see suspense fallback
133+
act(() => {
134+
screen.getByText('Home').click();
135+
});
136+
137+
expect(screen.getByText('Loading...')).toBeInTheDocument();
138+
jest.useRealTimers();
139+
});

packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ exports[`<BackupBoundary /> should warn users about SSR with DataProvider 1`] =
66
See https://dataclient.io/docs/guides/ssr.",
77
]
88
`;
9+
10+
exports[`<DataProvider /> should warn users about SSR with DataProvider 1`] = `
11+
[
12+
"DataProvider from @data-client/react does not update while doing SSR.
13+
See https://dataclient.io/docs/guides/ssr.",
14+
]
15+
`;

packages/react/src/components/__tests__/__snapshots__/provider.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ exports[`<DataProvider /> should change state 1`] = `
1818
"CoolerArticle": {
1919
"5": {
2020
"date": 50,
21-
"expiresAt": 55,
21+
"expiresAt": 60050,
2222
"fetchedAt": 50,
2323
},
2424
},
@@ -28,7 +28,7 @@ exports[`<DataProvider /> should change state 1`] = `
2828
"meta": {
2929
"GET http://test.com/article-cooler/5": {
3030
"date": 50,
31-
"expiresAt": 55,
31+
"expiresAt": 60050,
3232
"prevExpiresAt": undefined,
3333
},
3434
},

packages/react/src/components/__tests__/provider.node.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { renderToString } from 'react-dom/server';
44

55
import DataProvider from '../DataProvider';
66

7-
describe('<BackupBoundary />', () => {
7+
describe('<DataProvider />', () => {
88
let warnspy: jest.SpyInstance;
99
beforeEach(() => {
1010
warnspy = jest.spyOn(global.console, 'warn').mockImplementation(() => {});

packages/react/src/components/__tests__/provider.tsx

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
// eslint-env jest
22
import {
33
NetworkManager,
4-
actionTypes,
54
Manager,
65
Middleware,
76
Controller,
8-
SetResponseAction,
97
} from '@data-client/core';
108
import { act, render } from '@testing-library/react';
119
import { CoolerArticleResource } from '__tests__/new';
@@ -18,8 +16,6 @@ import { payload } from '../../test-fixtures';
1816
import DataProvider from '../DataProvider';
1917
import { getDefaultManagers } from '../getDefaultManagers';
2018

21-
const { SET_RESPONSE } = actionTypes;
22-
2319
describe('<DataProvider />', () => {
2420
let warnspy: jest.SpyInstance;
2521
let debugspy: jest.SpyInstance;
@@ -123,37 +119,33 @@ describe('<DataProvider />', () => {
123119
expect(curDisp).not.toBe(dispatch);
124120
expect(count).toBe(2);
125121
});
122+
126123
it('should change state', () => {
127-
let dispatch: any, state;
124+
jest.useFakeTimers({ now: 50 });
125+
let ctrl: Controller | undefined = undefined;
126+
let state;
128127
let count = 0;
129128
function ContextTester() {
130-
dispatch = useController().dispatch;
129+
ctrl = useController();
131130
state = useContext(StateContext);
132131
count++;
133132
return null;
134133
}
135134
const chil = <ContextTester />;
136135
const tree = <DataProvider>{chil}</DataProvider>;
137136
render(tree);
138-
expect(dispatch).toBeDefined();
137+
expect(ctrl).toBeDefined();
139138
expect(state).toBeDefined();
140-
const action: SetResponseAction = {
141-
type: SET_RESPONSE,
142-
response: { id: 5, title: 'hi', content: 'more things here' },
143-
endpoint: CoolerArticleResource.get,
144-
args: [{ id: 5 }],
145-
key: CoolerArticleResource.get.key({ id: 5 }),
146-
meta: {
147-
fetchedAt: 50,
148-
date: 50,
149-
expiresAt: 55,
150-
},
151-
};
152139
act(() => {
153-
dispatch(action);
140+
ctrl?.setResponse(
141+
CoolerArticleResource.get,
142+
{ id: 5 },
143+
{ id: 5, title: 'hi', content: 'more things here' },
144+
);
154145
});
155146
expect(count).toBe(2);
156147
expect(state).toMatchSnapshot();
148+
jest.useRealTimers();
157149
});
158150

159151
it('should ignore dispatches after unmount', async () => {

0 commit comments

Comments
 (0)