@data-client/core
Version:
Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch
755 lines (714 loc) • 22.7 kB
text/typescript
import { Entity } from '@data-client/endpoint';
import { INVALID } from '@data-client/normalizr';
import { ArticleResource, Article, PaginatedArticle } from '__tests__/new';
import { Controller } from '../..';
import {
INVALIDATE,
FETCH,
RESET,
GC,
SET_RESPONSE,
SET,
} from '../../actionTypes';
import {
State,
ActionTypes,
FetchAction,
SetResponseAction,
ResetAction,
InvalidateAction,
GCAction,
SetAction,
} from '../../types';
import createReducer, { initialState } from '../reducer/createReducer';
describe('reducer', () => {
let reducer: (
state: State<unknown> | undefined,
action: ActionTypes,
) => State<unknown>;
beforeEach(() => {
reducer = createReducer(new Controller());
});
describe('singles', () => {
const id = 20;
const response = { id, title: 'hi', content: 'this is the content' };
const action: SetResponseAction = {
type: SET_RESPONSE,
response,
endpoint: ArticleResource.get,
args: [{ id }],
key: ArticleResource.get.url({ id }),
meta: {
date: 5000000000,
expiresAt: 5000500000,
fetchedAt: 5000000000,
},
};
const partialResultAction: SetResponseAction = {
...action,
response: { id, title: 'hello' },
};
const iniState = initialState;
let newState = initialState;
it('should update state correctly', () => {
newState = reducer(iniState, action);
expect(newState).toMatchSnapshot();
});
it('should overwrite existing entity', () => {
const getEntity = (state: any): Article =>
state.entities[Article.key][`${Article.pk(action.response)}`];
const prevEntity = getEntity(newState);
expect(prevEntity).toBeDefined();
const nextState = reducer(newState, action);
const nextEntity = getEntity(nextState);
expect(nextEntity).not.toBe(prevEntity);
expect(nextEntity).toBeDefined();
});
it('should merge partial entity with existing entity', () => {
const getEntity = (state: any): Article =>
state.entities[Article.key][`${Article.pk(action.response)}`];
const prevEntity = getEntity(newState);
expect(prevEntity).toBeDefined();
const nextState = reducer(newState, partialResultAction);
const nextEntity = getEntity(nextState);
expect(nextEntity).not.toBe(prevEntity);
expect(nextEntity).toBeDefined();
expect(nextEntity.title).not.toBe(prevEntity.title);
expect(nextEntity.title).toBe(partialResultAction.response.title);
expect(nextEntity.content).toBe(prevEntity.content);
expect(nextEntity.content).not.toBe(undefined);
expect(
nextState.entitiesMeta[Article.key][`${Article.pk(action.response)}`],
).toBeDefined();
expect(
nextState.entitiesMeta[Article.key][`${Article.pk(action.response)}`]
.date,
).toBe(action.meta.date);
});
it('should have the latest entity date', () => {
const localAction = {
...partialResultAction,
meta: {
...partialResultAction.meta,
expiresAt: partialResultAction.meta.expiresAt * 2,
date: partialResultAction.meta.date * 2,
},
};
const getMeta = (state: any): { expiresAt: number } =>
state.entitiesMeta[Article.key][`${Article.pk(action.response)}`];
const prevMeta = getMeta(newState);
expect(prevMeta).toBeDefined();
const nextState = reducer(newState, localAction);
const nextMeta = getMeta(nextState);
expect(nextMeta).toBeDefined();
expect(nextMeta.expiresAt).toBe(localAction.meta.expiresAt);
});
it('should use existing entity with older date', () => {
const localAction = {
...partialResultAction,
meta: {
...partialResultAction.meta,
date: partialResultAction.meta.date / 2,
expiresAt: partialResultAction.meta.expiresAt / 2,
fetchedAt: partialResultAction.meta.date / 2,
},
};
const getMeta = (state: any): { date: number } =>
state.entitiesMeta[Article.key][`${Article.pk(action.response)}`];
const getEntity = (state: any): Article =>
state.entities[Article.key][`${Article.pk(action.response)}`];
const prevEntity = getEntity(newState);
const prevMeta = getMeta(newState);
expect(prevMeta).toBeDefined();
const nextState = reducer(newState, localAction);
const nextMeta = getMeta(nextState);
const nextEntity = getEntity(nextState);
expect(prevEntity).toEqual(nextEntity);
expect(nextMeta).toBeDefined();
expect(nextMeta.date).toBe(action.meta.date);
});
it('should use entity.mergeMetaWithStore()', () => {
class ExpiresSoon extends Article {
static get key() {
return Article.key;
}
static mergeMetaWithStore(
existingMeta: {
expiresAt: number;
date: number;
fetchedAt: number;
},
incomingMeta: { expiresAt: number; date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return (
this.shouldReorder(existingMeta, incomingMeta, existing, incoming)
) ?
existingMeta
: {
...incomingMeta,
expiresAt:
incoming.content ?
incomingMeta.expiresAt
: existingMeta.expiresAt,
};
}
}
const spy = jest.spyOn(ExpiresSoon, 'mergeMetaWithStore');
const localAction = {
...partialResultAction,
endpoint: (partialResultAction.endpoint as any).extend({
schema: ExpiresSoon,
}),
meta: {
...partialResultAction.meta,
date: partialResultAction.meta.date * 2,
expiresAt: partialResultAction.meta.expiresAt * 2,
fetchedAt: partialResultAction.meta.date * 2,
},
};
const getMeta = (state: any): { date: number; expiresAt: number } =>
state.entitiesMeta[ExpiresSoon.key][
`${ExpiresSoon.pk(action.response)}`
];
const getEntity = (state: any): ExpiresSoon =>
state.entities[ExpiresSoon.key][`${ExpiresSoon.pk(action.response)}`];
const prevEntity = getEntity(newState);
const prevMeta = getMeta(newState);
expect(prevMeta).toBeDefined();
const nextState = reducer(newState, localAction);
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1);
const nextMeta = getMeta(nextState);
const nextEntity = getEntity(nextState);
expect(nextMeta).toBeDefined();
// our new expires was larger, but custom function returned 0, so we keep old expires
expect(nextMeta.expiresAt).toBe(prevMeta.expiresAt);
expect(nextEntity.title).toBe('hello');
});
});
it('set(function) should do nothing when entity does not exist', () => {
const id = 20;
const value = (previous: { counter: number }) => ({
counter: previous.counter + 1,
});
class Counter extends Entity {
id = 0;
counter = 0;
static key = 'Counter';
}
const action: SetAction = {
type: SET,
value,
schema: Counter,
args: [{ id }],
meta: {
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const newState = reducer(initialState, action);
expect(newState).toBe(initialState);
});
it('set(function) should increment when it is found', () => {
const id = 20;
const value = (previous: { id: number; counter: number }) => ({
id: previous.id,
counter: previous.counter + 1,
});
class Counter extends Entity {
id = 0;
counter = 0;
static key = 'Counter';
}
const action: SetAction = {
type: SET,
value,
schema: Counter,
args: [{ id }],
meta: {
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const state = {
...initialState,
entities: {
[Counter.key]: {
[id]: { id, counter: 5 },
},
},
entitiesMeta: {
[Counter.key]: {
[id]: { date: 0, fetchedAt: 0, expiresAt: 0 },
},
},
};
const newState = reducer(state, action);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(newState.entities[Counter.key]?.[id]?.counter).toBe(6);
});
it('set should add entity when it does not exist', () => {
const id = 20;
const value = { id, title: 'hi', content: 'this is the content' };
const action: SetAction = {
type: SET,
value,
schema: Article,
args: [{ id }],
meta: {
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const iniState = {
...initialState,
endpoints: { abc: '5', [ArticleResource.get.key(value)]: `${id}` },
};
const newState = reducer(iniState, action);
expect(newState.entities[Article.key]?.[id]).toEqual(value);
});
it('set should never change endpoints', () => {
const id = 20;
const value = { id, title: 'hi', content: 'this is the content' };
const action: SetAction = {
type: SET,
value,
schema: Article,
args: [{ id }],
meta: {
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const iniState = {
...initialState,
endpoints: { abc: '5', [ArticleResource.get.key(value)]: `${id}` },
};
const newState = reducer(iniState, action);
expect(newState.endpoints).toStrictEqual(iniState.endpoints);
});
it('mutate should never change endpoints', () => {
const id = 20;
const response = { id, title: 'hi', content: 'this is the content' };
const action: SetResponseAction = {
type: SET_RESPONSE,
response,
endpoint: ArticleResource.get,
args: [{ id }],
key: ArticleResource.get.key(response),
meta: {
date: 0,
fetchedAt: 0,
expiresAt: 1000000000000,
},
};
const iniState = {
...initialState,
endpoints: { abc: '5', [ArticleResource.get.key(response)]: `${id}` },
};
const newState = reducer(iniState, action);
expect(newState.endpoints).toStrictEqual(iniState.endpoints);
});
it('purge should delete entities', () => {
const id = 20;
const action: SetResponseAction = {
type: SET_RESPONSE,
response: { id },
endpoint: ArticleResource.delete,
args: [{ id }],
key: ArticleResource.delete.key({ id }),
meta: {
fetchedAt: 0,
date: 0,
expiresAt: 0,
},
};
const iniState: any = {
...initialState,
entities: {
[Article.key]: {
'10': Article.fromJS({ id: 10 }),
'20': Article.fromJS({ id: 20 }),
'25': Article.fromJS({ id: 25 }),
},
[PaginatedArticle.key]: {
hi: PaginatedArticle.fromJS({ id: 5 }),
},
'5': undefined,
},
endpoints: { abc: '20' },
};
const newState = reducer(iniState, action);
expect(newState.endpoints.abc).toBe(iniState.endpoints.abc);
const expectedEntities = { ...iniState.entities[Article.key] };
expectedEntities['20'] = INVALID;
expect(newState.entities[Article.key]).toEqual(expectedEntities);
});
/* this is probably not needed and will eventually be deprecated
describe('endpoint.update', () => {
describe('Update on get (pagination use case)', () => {
const endpoint = PaginatedArticleResource.getList;
const iniState: any = {
...initialState,
entities: {
[PaginatedArticle.key]: {
'10': PaginatedArticle.fromJS({ id: 10 }),
},
},
endpoints: {
[PaginatedArticleResource.getList.key({})]: { endpoints: ['10'] },
},
};
it('should insert a new page of resources into a list request', () => {
const action = createSetResponse(
{ results: [{ id: 11 }, { id: 12 }] },
{
...endpoint,
key: endpoint.key({ cursor: 2 }),
update: (nextpage: { results: string[] }) => ({
[PaginatedArticleResource.getList.key({})]: (
existing: { results: string[] } = { results: [] },
) => ({
...existing,
results: [...existing.results, ...nextpage.results],
}),
}),
dataExpiryLength: 600000,
},
);
const newState = reducer(iniState, action);
expect(
newState.endpoints[PaginatedArticleResource.getList.key({})],
).toStrictEqual({
results: ['10', '11', '12'],
});
});
it('should insert correctly into the beginning of the list request', () => {
const newState = reducer(
iniState,
createSetResponse(
{ results: [{ id: 11 }, { id: 12 }] },
{
...endpoint,
key: endpoint.key({ cursor: 2 }),
update: (nextpage: { results: string[] }) => ({
[PaginatedArticleResource.getList.key({})]: (
existing: { results: string[] } = { results: [] },
) => ({
...existing,
results: [...nextpage.results, ...existing.results],
}),
}),
dataExpiryLength: 600000,
},
),
);
expect(
newState.endpoints[PaginatedArticleResource.getList.key({})],
).toStrictEqual({
results: ['11', '12', '10'],
});
});
it('should account for args', () => {
const iniState: any = {
...initialState,
entities: {
[PaginatedArticle.key]: {
'10': PaginatedArticle.fromJS({ id: 10 }),
},
},
endpoints: {
[PaginatedArticleResource.getList.key({ admin: true })]: {
results: ['10'],
},
},
};
const newState = reducer(
iniState,
createSetResponse(
{ results: [{ id: 11 }, { id: 12 }] },
{
...endpoint,
key: endpoint.key({ cursor: 2, admin: true }),
args: [{ cursor: 2, admin: true }],
update: (nextpage: { results: string[] }, { admin }) => ({
[PaginatedArticleResource.getList.key({ admin })]: (
existing: { results: string[] } = { results: [] },
) => ({
...existing,
results: [...nextpage.results, ...existing.results],
}),
}),
dataExpiryLength: 600000,
},
),
);
expect(
newState.endpoints[
PaginatedArticleResource.getList.key({ admin: true })
],
).toStrictEqual({
results: ['11', '12', '10'],
});
});
});
});*/
it('invalidates resources correctly', () => {
const id = 20;
const action: InvalidateAction = {
type: INVALIDATE,
key: id.toString(),
};
const iniState: any = {
...initialState,
entities: {
[Article.key]: {
'10': Article.fromJS({ id: 10 }),
'20': Article.fromJS({ id: 20 }),
'25': Article.fromJS({ id: 25 }),
},
[PaginatedArticle.key]: {
hi: PaginatedArticle.fromJS({ id: 5 }),
},
'5': undefined,
},
endpoints: { abc: '20' },
meta: {
'20': {
expiresAt: 500,
},
'25': {
expiresAt: 1000,
},
},
};
const newState = reducer(iniState, action);
expect(newState.endpoints).toEqual(iniState.endpoints);
expect(newState.entities).toBe(iniState.entities);
const expectedMeta = { ...iniState.meta };
expectedMeta['20'] = { expiresAt: 0, invalidated: true };
expect(newState.meta).toEqual(expectedMeta);
});
it('should set error in meta for "set"', () => {
const id = 20;
const error = new Error('hi');
const action: SetResponseAction = {
type: SET_RESPONSE,
response: error,
endpoint: ArticleResource.get,
args: [{ id }],
key: ArticleResource.get.key({ id }),
meta: {
fetchedAt: 5000000000,
date: 5000000000,
expiresAt: 5000500000,
},
error: true,
};
const iniState = initialState;
const newState = reducer(iniState, action);
expect(newState).toMatchSnapshot();
});
it('should not modify state on error for "rpc"', () => {
const id = 20;
const error = new Error('hi');
const action: SetResponseAction = {
type: SET_RESPONSE,
response: error,
endpoint: ArticleResource.get,
args: [{ id }],
key: ArticleResource.get.key({ id }),
meta: {
fetchedAt: 0,
date: 0,
expiresAt: 10000000000000000000,
},
error: true,
};
const iniState = initialState;
const newState = reducer(iniState, action);
// ignore meta for this check
expect({ ...newState, meta: {} }).toEqual(iniState);
});
it('should not delete on error for "purge"', () => {
const id = 20;
const error = new Error('hi');
const action: SetResponseAction = {
type: SET_RESPONSE,
response: error,
endpoint: ArticleResource.delete,
args: [{ id }],
key: ArticleResource.delete.key({ id }),
meta: {
fetchedAt: 0,
date: 0,
expiresAt: 0,
},
error: true,
};
const iniState = {
...initialState,
entities: {
[Article.key]: {
[id]: Article.fromJS({}),
},
},
endpoints: {
[ArticleResource.get.url({ id })]: id,
},
};
const newState = reducer(iniState, action);
expect(newState.entities).toBe(iniState.entities);
});
it('rdc/fetch should not console.warn()', () => {
const warnspy = jest
.spyOn(global.console, 'warn')
.mockImplementation(() => {});
try {
const action: FetchAction = {
type: FETCH,
endpoint: ArticleResource.get,
args: [{ id: 5 }],
key: ArticleResource.get.url({ id: 5 }),
meta: {
reject: (v: any) => null,
resolve: (v: any) => null,
promise: new Promise((v: any) => null),
fetchedAt: 0,
},
};
const iniState = {
...initialState,
endpoints: { abc: '5' },
};
const newState = reducer(iniState, action);
expect(newState).toBe(iniState);
// moved warns to applyManager() vv
expect(warnspy.mock.calls.length).toBe(0);
} finally {
warnspy.mockRestore();
}
});
it('other types should do nothing', () => {
const action: any = {
type: 'whatever',
};
const iniState = {
...initialState,
endpoints: { abc: '5' },
};
const newState = reducer(iniState, action);
expect(newState).toBe(iniState);
});
describe('RESET', () => {
let warnspy: jest.Spied<any>;
beforeEach(() => {
warnspy = jest.spyOn(global.console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnspy.mockRestore();
});
it('reset should delete all entries', () => {
const action: ResetAction = {
type: RESET,
date: Date.now(),
};
const iniState: any = {
...initialState,
entities: {
[Article.key]: {
'10': Article.fromJS({ id: 10 }),
'20': Article.fromJS({ id: 20 }),
'25': Article.fromJS({ id: 25 }),
},
[PaginatedArticle.key]: {
hi: PaginatedArticle.fromJS({ id: 5 }),
},
'5': undefined,
},
endpoints: { abc: '20' },
};
const newState = reducer(iniState, action);
expect(newState.endpoints).toEqual({});
expect(newState.meta).toEqual({});
expect(newState.entities).toEqual({});
});
});
describe('GC action', () => {
let iniState: State<unknown>;
beforeEach(() => {
iniState = {
...initialState,
entities: {
[Article.key]: {
'10': Article.fromJS({ id: 10 }),
'20': Article.fromJS({ id: 20 }),
'25': Article.fromJS({ id: 25 }),
'250': Article.fromJS({ id: 250 }),
},
[PaginatedArticle.key]: {
hi: PaginatedArticle.fromJS({ id: 5 }),
},
'5': undefined,
},
entitiesMeta: {
[Article.key]: {
'10': { date: 0, expiresAt: 10000, fetchedAt: 0 },
'20': { date: 0, expiresAt: 10000, fetchedAt: 0 },
'25': { date: 0, expiresAt: 10000, fetchedAt: 0 },
'250': { date: 0, expiresAt: 10000, fetchedAt: 0 },
},
},
endpoints: { abc: '20' },
};
});
it('empty targets should do nothing', () => {
const action: GCAction = {
type: GC,
entities: [],
endpoints: [],
};
const newState = reducer(iniState, action);
expect(newState).toBe(iniState);
expect(Object.keys(newState.entities[Article.key] ?? {}).length).toBe(4);
expect(Object.keys(newState.endpoints).length).toBe(1);
});
it('empty deleting entities should work', () => {
const action: GCAction = {
type: GC,
entities: [
{ key: Article.key, pk: '10' },
{ key: Article.key, pk: '250' },
],
endpoints: ['abc'],
};
const newState = reducer(iniState, action);
expect(newState).toBe(iniState);
expect(Object.keys(newState.entities[Article.key] ?? {}).length).toBe(2);
expect(Object.keys(newState.entitiesMeta[Article.key] ?? {}).length).toBe(
2,
);
expect(Object.keys(newState.endpoints).length).toBe(0);
});
it('empty deleting nonexistant things should passthrough', () => {
const action: GCAction = {
type: GC,
entities: [
{ key: Article.key, pk: '100000000' },
{ key: 'sillythings', pk: '10' },
],
endpoints: [],
};
const newState = reducer(iniState, action);
expect(newState).toBe(iniState);
expect(Object.keys(newState.entities[Article.key] ?? {}).length).toBe(4);
expect(Object.keys(newState.endpoints).length).toBe(1);
});
});
});