@data-client/endpoint
Version:
Declarative Network Interface Definitions
693 lines (648 loc) • 18.2 kB
text/typescript
// eslint-env jest
import { initialState } from '@data-client/core';
import { normalize, denormalize, MemoCache } from '@data-client/normalizr';
import { ArticleResource, IDEntity } from '__tests__/new';
import { Record } from 'immutable';
import SimpleMemoCache from './denormalize';
import { PolymorphicInterface } from '../..';
import { schema } from '../..';
import PolymorphicSchema from '../Polymorphic';
let dateSpy: jest.SpyInstance;
beforeAll(() => {
dateSpy = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf());
});
afterAll(() => {
dateSpy.mockRestore();
});
class Todo extends IDEntity {
userId = 0;
title = '';
completed = false;
static key = 'Todo';
}
class User extends IDEntity {
name = '';
username = '';
email = '';
todos: Todo[] = [];
static key = 'User';
static schema = {
todos: new schema.Collection(new schema.Array(Todo), {
nestKey: (parent, key) => ({
userId: parent.id,
}),
}),
};
}
const userTodos = new schema.Collection(new schema.Array(Todo), {
argsKey: ({ userId }: { userId: string }) => ({
userId,
}),
});
test('key works with custom schema', () => {
class CustomArray extends PolymorphicSchema {
declare schema: any;
normalize(
input: any,
parent: any,
key: any,
args: any[],
visit: any,
addEntity: any,
getEntity: any,
checkLoop: any,
): any {
return input.map((value, index) =>
this.normalizeValue(value, parent, key, args, visit),
);
}
denormalize(
input: any,
args: any[],
unvisit: (schema: any, input: any) => any,
) {
return input.map ?
input.map((entityOrId: any) =>
this.denormalizeValue(entityOrId, unvisit),
)
: input;
}
queryKey(
args: unknown,
queryKey: unknown,
getEntity: unknown,
getIndex: unknown,
): any {
return undefined;
}
_normalizeNullable(): any {}
_denormalizeNullable(): any {}
}
const collection = new schema.Collection(new CustomArray(Todo));
expect(collection.key).toBe('(Todo)');
});
describe(`${schema.Collection.name} normalization`, () => {
let warnSpy: jest.SpyInstance;
afterEach(() => {
warnSpy.mockRestore();
});
beforeEach(() =>
(warnSpy = jest.spyOn(console, 'warn')).mockImplementation(() => {}),
);
test('should throw a custom error if data loads with string unexpected value', () => {
function normalizeBad() {
normalize(userTodos, 'abc');
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
test('should throw a custom error if data loads with string unexpected value', () => {
function normalizeBad() {
normalize(userTodos.push, null);
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
test('should throw a custom error if data loads with args of unexpected value', () => {
function normalizeBad() {
userTodos.normalize(
[],
undefined as any,
'',
[],
() => undefined,
() => undefined,
() => undefined,
() => false,
);
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
test('normalizes nested collections', () => {
const state = normalize(User, {
id: '1',
username: 'bob',
todos: [{ id: '5', title: 'finish collections' }],
});
expect(state).toMatchSnapshot();
//const a: string | undefined = state.result;
// @ts-expect-error
const b: Record<any, any> | undefined = state.result;
});
test('normalizes top level collections', () => {
const state = normalize(
userTodos,
[{ id: '5', title: 'finish collections' }],
[{ userId: '1' }],
);
expect(state).toMatchSnapshot();
//const a: string[] | undefined = state.result;
// @ts-expect-error
const b: Record<any, any> | undefined = state.result;
});
test('normalizes top level collections (no args)', () => {
const state = normalize(
new schema.Collection(new schema.Array(Todo)),
[{ id: '5', title: 'finish collections' }],
[{ userId: '1' }],
);
expect(state).toMatchSnapshot();
//const a: string[] | undefined = state.result;
// @ts-expect-error
const b: Record<any, any> | undefined = state.result;
});
test('normalizes already processed entities', () => {
const state = normalize(User, {
id: '1',
username: 'bob',
todos: ['5', '6'],
});
expect(state).toMatchSnapshot();
});
describe('polymorphism', () => {
class User extends IDEntity {
type = 'users';
}
class Group extends IDEntity {
type = 'groups';
}
const collection = new schema.Collection(
new schema.Array(
{
users: User,
groups: Group,
},
'type',
),
);
const collectionUnion = new schema.Collection([
new schema.Union(
{
users: User,
groups: Group,
},
'type',
),
]);
test('generates polymorphic key', () => {
expect(collection.key).toBe('[User;Group]');
expect(collectionUnion.key).toBe('[User;Group]');
});
test('works with polymorphic members', () => {
const { entities, result } = normalize(
collection,
[
{ id: '1', type: 'users' },
{ id: '2', type: 'groups' },
],
[{ fakeFilter: false }],
);
expect(result).toMatchSnapshot();
expect(entities).toMatchSnapshot();
});
test('works with Union members', () => {
const { entities, result } = normalize(
collectionUnion,
[
{ id: '1', type: 'users' },
{ id: '2', type: 'groups' },
],
[{ fakeFilter: false }],
);
expect(result).toMatchSnapshot();
expect(entities).toMatchSnapshot();
});
});
test('normalizes push onto the end', () => {
const init = {
entities: {
[User.schema.todos.key]: {
'{"userId":"1"}': ['5'],
},
Todo: {
'5': {
id: '5',
title: 'finish collections',
},
},
User: {
'1': {
id: '1',
todos: '{"userId":"1"}',
username: 'bob',
},
},
},
entityMeta: {
[User.schema.todos.key]: {
'{"userId":"1"}': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
Todo: {
'5': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
User: {
'1': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
},
indexes: {},
};
const state = normalize(
User.schema.todos.push,
[{ id: '10', title: 'create new items' }],
[{ userId: '1' }],
init,
);
expect(state).toMatchSnapshot();
});
describe('push should add only to collections matching filterArgumentKeys', () => {
const initializingSchema = new schema.Collection([Todo]);
let state = {
...initialState,
...normalize(
initializingSchema,
[{ id: '10', title: 'create new items' }],
[{ userId: '1' }],
initialState,
),
};
state = {
...state,
...normalize(
initializingSchema,
[{ id: '10', title: 'create new items' }],
[{ userId: '1', ignoredMe: '5' }],
state,
),
};
state = {
...state,
...normalize(
initializingSchema,
[{ id: '20', title: 'second user' }],
[{ userId: '2' }],
state,
),
};
state = {
...state,
...normalize(
initializingSchema,
[
{ id: '10', title: 'create new items' },
{ id: '20', title: 'the ignored one' },
],
[{}],
state,
),
};
function validate(sch: schema.Collection<(typeof Todo)[]>) {
expect(
(
denormalize(
sch,
JSON.stringify({ userId: '1' }),
state.entities,
) as any
)?.length,
).toBe(1);
const testState = {
...state,
...normalize(
sch.push,
[{ id: '30', title: 'pushed to the end' }],
[{ userId: '1' }],
state,
),
};
function getResponse(...args: any) {
return denormalize(
sch,
sch.pk(undefined, undefined, '', args),
testState.entities,
) as any;
}
const userOne = getResponse({ userId: '1' });
if (!userOne || typeof userOne === 'symbol')
throw new Error('should have a value');
expect(userOne.length).toBe(2);
expect(userOne[1].title).toBe('pushed to the end');
expect(getResponse({}).length).toBe(3);
expect(getResponse({ ignoredMe: '5', userId: '1' })?.length).toBe(2);
expect(getResponse({ userId: '2' })?.length).toBe(1);
}
it('should work with function form', () => {
const sch = new schema.Collection([Todo], {
nonFilterArgumentKeys(key) {
return key.startsWith('ignored');
},
});
validate(sch);
});
it('should work with RegExp form', () => {
const sch = new schema.Collection([Todo], {
nonFilterArgumentKeys: /ignored/,
});
validate(sch);
});
it('should work with RegExp form', () => {
const sch = new schema.Collection([Todo], {
nonFilterArgumentKeys: ['ignoredMe'],
});
validate(sch);
});
it('should work with full createCollectionFilter form', () => {
const sch = new schema.Collection([Todo], {
createCollectionFilter:
(...args) =>
collectionKey =>
Object.entries(collectionKey).every(
([key, value]) =>
key.startsWith('ignored') ||
// strings are canonical form. See pk() above for value transformation
`${args[0]?.[key]}` === value ||
`${args[1]?.[key]}` === value,
),
});
validate(sch);
});
it('should work with function override of nonFilterArgumentKeys', () => {
class MyCollection<
S extends any[] | PolymorphicInterface = any,
Parent extends any[] =
| []
| [urlParams: { [k: string]: any }]
| [urlParams: { [k: string]: any }, body: { [k: string]: any }],
> extends schema.Collection<S, Parent> {
nonFilterArgumentKeys(key: string) {
return key.startsWith('ignored');
}
}
const sch = new MyCollection([Todo]);
validate(sch);
});
it('should work with function override of createCollectionFilter', () => {
class MyCollection<
S extends any[] | PolymorphicInterface = any,
Parent extends any[] =
| []
| [urlParams: { [k: string]: any }]
| [urlParams: { [k: string]: any }, body: { [k: string]: any }],
> extends schema.Collection<S, Parent> {
createCollectionFilter(...args: Parent) {
return (collectionKey: { [k: string]: string }) =>
Object.entries(collectionKey).every(
([key, value]) =>
key.startsWith('ignored') ||
// strings are canonical form. See pk() above for value transformation
`${args[0][key]}` === value ||
`${args[1]?.[key]}` === value,
);
}
}
const sch = new MyCollection([Todo]);
validate(sch);
});
});
});
describe(`${schema.Collection.name} denormalization`, () => {
const normalizeNested = {
entities: {
[userTodos.key]: {
'{"userId":"1"}': ['5'],
},
Todo: {
'5': {
id: '5',
title: 'finish collections',
},
},
User: {
'1': {
id: '1',
todos: '{"userId":"1"}',
username: 'bob',
},
},
},
entityMeta: {
[userTodos.key]: {
'{"userId":"1"}': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
Todo: {
'5': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
User: {
'1': {
date: 1557831718135,
expiresAt: Infinity,
fetchedAt: 0,
},
},
},
indexes: {},
result: '1',
};
test('denormalizes nested collections', () => {
expect(
denormalize(User, normalizeNested.result, normalizeNested.entities),
).toMatchSnapshot();
});
test('denormalizes top level collections', () => {
expect(
denormalize(userTodos, '{"userId":"1"}', normalizeNested.entities),
).toMatchSnapshot();
});
describe('caching', () => {
const memo = new SimpleMemoCache();
test('denormalizes nested and top level share referential equality', () => {
const todos = memo.denormalize(
userTodos,
'{"userId":"1"}',
normalizeNested.entities,
[{ userId: '1' }],
);
const user = memo.denormalize(
User,
normalizeNested.result,
normalizeNested.entities,
);
expect(user).toBeDefined();
expect(user).not.toEqual(expect.any(Symbol));
if (typeof user === 'symbol' || !user) return;
expect(todos).toBe(user.todos);
});
test('push updates cache', () => {
const pushedState = normalize(
User.schema.todos.push,
[{ id: '10', title: 'create new items' }],
[{ userId: '1' }],
normalizeNested,
);
const todos = memo.denormalize(
userTodos,
'{"userId":"1"}',
pushedState.entities,
[{ userId: '1' }],
);
const user = memo.denormalize(
User,
normalizeNested.result,
pushedState.entities,
[{ id: '1' }],
);
expect(user).toBeDefined();
expect(user).not.toEqual(expect.any(Symbol));
if (typeof user === 'symbol' || !user) return;
expect(user.todos.length).toBe(2);
expect(todos).toBe(user.todos);
});
test('unshift places at start', () => {
const unshiftState = normalize(
User.schema.todos.unshift,
[{ id: '2', title: 'from the start' }],
[{ userId: '1' }],
normalizeNested,
);
const todos = memo.denormalize(
userTodos,
'{"userId":"1"}',
unshiftState.entities,
[{ userId: '1' }],
);
const user = memo.denormalize(
User,
normalizeNested.result,
unshiftState.entities,
[{ id: '1' }],
);
expect(user).toBeDefined();
expect(user).not.toEqual(expect.any(Symbol));
if (typeof user === 'symbol' || !user) return;
expect(user.todos.length).toBe(2);
expect(todos).toBe(user.todos);
expect(user.todos[0].title).toBe('from the start');
});
test('denormalizes unshift', () => {
const todos = memo.denormalize(
userTodos.unshift,
[{ id: '2', title: 'from the start' }],
{},
[{ id: '1' }],
);
expect(todos).toBeDefined();
expect(todos).not.toEqual(expect.any(Symbol));
if (typeof todos === 'symbol' || !todos) return;
//expect(todos[0].title).toBe('from the start'); TODO: once we get types based on parameters sent
expect(todos).toMatchInlineSnapshot(`
[
Todo {
"completed": false,
"id": "2",
"title": "from the start",
"userId": 0,
},
]
`);
});
test('denormalizes unshift (single item)', () => {
const todos = memo.denormalize(
userTodos.unshift,
{ id: '2', title: 'from the start' },
{},
[{ id: '1' }],
);
expect(todos).toBeDefined();
expect(todos).not.toEqual(expect.any(Symbol));
if (typeof todos === 'symbol' || !todos) return;
expect(todos.title).toBe('from the start');
expect(todos).toMatchInlineSnapshot(`
Todo {
"completed": false,
"id": "2",
"title": "from the start",
"userId": 0,
}
`);
});
});
it('should buildQueryKey with matching args', () => {
const memo = new MemoCache();
const queryKey = memo.buildQueryKey(
userTodos,
[{ userId: '1' }],
normalizeNested.entities,
{},
);
expect(queryKey).toBeDefined();
// now ensure our queryKey is usable
const results = denormalize(userTodos, queryKey, normalizeNested.entities);
expect(results).toBeDefined();
expect(results).toMatchInlineSnapshot(`
[
Todo {
"completed": false,
"id": "5",
"title": "finish collections",
"userId": 0,
},
]
`);
});
it('should buildQueryKey undefined when not in cache', () => {
const memo = new MemoCache();
const queryKey = memo.buildQueryKey(
userTodos,
[{ userId: '100' }],
normalizeNested.entities,
{},
);
expect(queryKey).toBeUndefined();
});
it('should buildQueryKey undefined with nested Collection', () => {
const memo = new MemoCache();
const queryKey = memo.buildQueryKey(
User.schema.todos,
[{ userId: '1' }],
normalizeNested.entities,
{},
);
expect(queryKey).toBeUndefined();
});
it('pk should serialize differently with nested args', () => {
const filtersA = {
search: {
type: 'Coupon',
},
};
const filtersB = {
search: {
type: 'Cashback',
},
};
expect(
ArticleResource.getList.schema.pk([], undefined, '', [filtersA]),
).not.toEqual(
ArticleResource.getList.schema.pk([], undefined, '', [filtersB]),
);
});
});