Skip to content

Commit

Permalink
internal: Add integration test
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Jan 5, 2025
1 parent d4fb76b commit 67b2fba
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 37 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "16.1.0",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/react-native": "13.0.0",
Expand Down
31 changes: 18 additions & 13 deletions packages/core/src/state/GCPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,30 @@ export class GCPolicy implements GCInterface {

// decrement
return () => {
const currentCount = this.endpointCount.get(key) ?? 0;
if (currentCount <= 1) {
this.endpointCount.delete(key);
// queue for cleanup
this.endpointsQ.add(key);
} else {
this.endpointCount.set(key, currentCount - 1);
const currentCount = this.endpointCount.get(key)!;
if (currentCount !== undefined) {
if (currentCount <= 1) {
this.endpointCount.delete(key);
// queue for cleanup
this.endpointsQ.add(key);
} else {
this.endpointCount.set(key, currentCount - 1);
}
}
paths.forEach(path => {
if (!this.entityCount.has(path.key)) {
return;
}
const instanceCount = this.entityCount.get(path.key)!;
if (instanceCount.get(path.pk)! <= 1) {
instanceCount.delete(path.pk);
// queue for cleanup
this.entitiesQ.push(path);
} else {
instanceCount.set(path.pk, instanceCount.get(path.pk)! - 1);
const entityCount = instanceCount.get(path.pk)!;
if (entityCount !== undefined) {
if (entityCount <= 1) {
instanceCount.delete(path.pk);
// queue for cleanup
this.entitiesQ.push(path);
} else {
instanceCount.set(path.pk, entityCount - 1);
}
}
});
};
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/state/__tests__/GCPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ describe('GCPolicy', () => {
});
});

it('should dispatch GC action once no ref counts and is expired with extra decrements', () => {
const key = 'testEndpoint';
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
const state = {
meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
};
(controller.getState as jest.Mock).mockReturnValue(state);

const countRef = gcPolicy.createCountRef({ key, paths });

const decrement = countRef();
countRef(); // Increment again
gcPolicy['runSweep']();
expect(controller.dispatch).not.toHaveBeenCalled();
decrement();
gcPolicy['runSweep']();
expect(controller.dispatch).not.toHaveBeenCalled();
decrement(); // Decrement twice
decrement(); // Decrement extra time

gcPolicy['runSweep']();
expect(controller.dispatch).toHaveBeenCalledWith({
type: GC,
entities: [{ key: 'testEntity', pk: '1' }],
endpoints: ['testEndpoint'],
});
});

it('should dispatch GC action once no ref counts and no expiry state', () => {
const key = 'testEndpoint';
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
Expand Down
139 changes: 139 additions & 0 deletions packages/react/src/__tests__/integration-garbage-collection.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
AsyncBoundary,
DataProvider,
GCPolicy,
useSuspense,
} from '@data-client/react';
import { MockResolver } from '@data-client/test';
import { render, screen, act } from '@testing-library/react';
import { ArticleResource } from '__tests__/new';
import '@testing-library/jest-dom';
import { useState } from 'react';

const mockGetList = jest.fn();
const mockGet = jest.fn();
const GC_INTERVAL = 5000;

const ListView = ({ onSelect }: { onSelect: (id: number) => void }) => {
const articles = useSuspense(ArticleResource.getList);
return (
<div>
{articles.map(article => (
<div key={article.id} onClick={() => onSelect(article.id ?? 0)}>
{article.title}
</div>
))}
</div>
);
};

const DetailView = ({ id }: { id: number }) => {
const article = useSuspense(ArticleResource.get, { id });
const [toggle, setToggle] = useState(false);

return (
<div>
<h3>{article.title}</h3>
<p>{article.content}</p>
<button onClick={() => setToggle(!toggle)}>Toggle Re-render</button>
{toggle && <div>Toggle state: {toggle.toString()}</div>}
</div>
);
};

const TestComponent = () => {
const [view, setView] = useState<'list' | 'detail'>('list');
const [selectedId, setSelectedId] = useState<number | null>(null);

return (
<DataProvider gcPolicy={new GCPolicy({ intervalMS: GC_INTERVAL })}>
<MockResolver
fixtures={[
{
endpoint: ArticleResource.getList,
response: mockGetList,
delay: 100,
},
{ endpoint: ArticleResource.get, response: mockGet, delay: 100 },
]}
>
<div onClick={() => setView('list')}>Home</div>
<AsyncBoundary fallback={<div>Loading...</div>}>
{view === 'list' ?
<ListView
onSelect={id => {
setSelectedId(id);
setView('detail');
}}
/>
: selectedId !== null && <DetailView id={selectedId} />}
</AsyncBoundary>
</MockResolver>
</DataProvider>
);
};

test('switch between list and detail view', async () => {
jest.useFakeTimers();
mockGetList.mockResolvedValue([
{ id: 1, title: 'Article 1', content: 'Content 1' },
{ id: 2, title: 'Article 2', content: 'Content 2' },
]);
mockGet.mockResolvedValue({
id: 1,
title: 'Article 1',
content: 'Content 1',
});

render(<TestComponent />);

// Initial render, should show list view
expect(await screen.findByText('Article 1')).toBeInTheDocument();

// Switch to detail view
act(() => {
screen.getByText('Article 1').click();
});

// Detail view should render
expect(await screen.findByText('Content 1')).toBeInTheDocument();

// Jest time pass to trigger sweep but not expired
act(() => {
jest.advanceTimersByTime(GC_INTERVAL);
});

// Switch back to list view
act(() => {
screen.getByText('Home').click();
});

// List view should instantly render
expect(await screen.findByText('Article 1')).toBeInTheDocument();

// Switch back to detail view
act(() => {
screen.getByText('Article 1').click();
});

// Jest time pass to expiry
act(() => {
jest.advanceTimersByTime(ArticleResource.getList.dataExpiryLength ?? 60000);
});
expect(await screen.findByText('Content 1')).toBeInTheDocument();

// Re-render detail view to make sure it still renders
act(() => {
screen.getByText('Toggle Re-render').click();
});
expect(await screen.findByText('Toggle state: true')).toBeInTheDocument();
expect(await screen.findByText('Content 1')).toBeInTheDocument();

// Visit list view and see suspense fallback
act(() => {
screen.getByText('Home').click();
});

expect(screen.getByText('Loading...')).toBeInTheDocument();
jest.useRealTimers();
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ exports[`<BackupBoundary /> should warn users about SSR with DataProvider 1`] =
See https://dataclient.io/docs/guides/ssr.",
]
`;

exports[`<DataProvider /> should warn users about SSR with DataProvider 1`] = `
[
"DataProvider from @data-client/react does not update while doing SSR.
See https://dataclient.io/docs/guides/ssr.",
]
`;
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ exports[`<DataProvider /> should change state 1`] = `
"CoolerArticle": {
"5": {
"date": 50,
"expiresAt": 55,
"expiresAt": 60050,
"fetchedAt": 50,
},
},
Expand All @@ -28,7 +28,7 @@ exports[`<DataProvider /> should change state 1`] = `
"meta": {
"GET http://test.com/article-cooler/5": {
"date": 50,
"expiresAt": 55,
"expiresAt": 60050,
"prevExpiresAt": undefined,
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/__tests__/provider.node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { renderToString } from 'react-dom/server';

import DataProvider from '../DataProvider';

describe('<BackupBoundary />', () => {
describe('<DataProvider />', () => {
let warnspy: jest.SpyInstance;
beforeEach(() => {
warnspy = jest.spyOn(global.console, 'warn').mockImplementation(() => {});
Expand Down
32 changes: 12 additions & 20 deletions packages/react/src/components/__tests__/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// eslint-env jest
import {
NetworkManager,
actionTypes,
Manager,
Middleware,
Controller,
SetResponseAction,
} from '@data-client/core';
import { act, render } from '@testing-library/react';
import { CoolerArticleResource } from '__tests__/new';
Expand All @@ -18,8 +16,6 @@ import { payload } from '../../test-fixtures';
import DataProvider from '../DataProvider';
import { getDefaultManagers } from '../getDefaultManagers';

const { SET_RESPONSE } = actionTypes;

describe('<DataProvider />', () => {
let warnspy: jest.SpyInstance;
let debugspy: jest.SpyInstance;
Expand Down Expand Up @@ -123,37 +119,33 @@ describe('<DataProvider />', () => {
expect(curDisp).not.toBe(dispatch);
expect(count).toBe(2);
});

it('should change state', () => {
let dispatch: any, state;
jest.useFakeTimers({ now: 50 });
let ctrl: Controller | undefined = undefined;
let state;
let count = 0;
function ContextTester() {
dispatch = useController().dispatch;
ctrl = useController();
state = useContext(StateContext);
count++;
return null;
}
const chil = <ContextTester />;
const tree = <DataProvider>{chil}</DataProvider>;
render(tree);
expect(dispatch).toBeDefined();
expect(ctrl).toBeDefined();
expect(state).toBeDefined();
const action: SetResponseAction = {
type: SET_RESPONSE,
response: { id: 5, title: 'hi', content: 'more things here' },
endpoint: CoolerArticleResource.get,
args: [{ id: 5 }],
key: CoolerArticleResource.get.key({ id: 5 }),
meta: {
fetchedAt: 50,
date: 50,
expiresAt: 55,
},
};
act(() => {
dispatch(action);
ctrl?.setResponse(
CoolerArticleResource.get,
{ id: 5 },
{ id: 5, title: 'hi', content: 'more things here' },
);
});
expect(count).toBe(2);
expect(state).toMatchSnapshot();
jest.useRealTimers();
});

it('should ignore dispatches after unmount', async () => {
Expand Down
Loading

0 comments on commit 67b2fba

Please sign in to comment.