@data-client/endpoint
Version:
Declarative Network Interface Definitions
1,332 lines (1,229 loc) • 37.8 kB
text/typescript
// eslint-env jest
import { normalize, denormalize } from '@data-client/normalizr';
import { INVALID } from '@data-client/normalizr';
import { Temporal } from '@js-temporal/polyfill';
import { fromJS, Record } from 'immutable';
import SimpleMemoCache from './denormalize';
import { schema, EntityMixin } from '../..';
let dateSpy: jest.SpyInstance;
beforeAll(() => {
dateSpy = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf());
});
afterAll(() => {
dateSpy.mockRestore();
});
const values = <T extends { [k: string]: any }>(obj: T) =>
Object.keys(obj).map(key => obj[key]);
class TacoData {
id = '';
name = '';
alias: string | undefined = undefined;
}
class Tacos extends EntityMixin(TacoData) {}
class ArticleData {
readonly id: string = '';
readonly title: string = '';
readonly author: string = '';
readonly content: string = '';
}
class ArticleEntity extends EntityMixin(ArticleData) {}
class OptionalData {
readonly id: string = '';
readonly article: ArticleEntity | null = null;
readonly requiredArticle = ArticleEntity.fromJS();
readonly nextPage: string = '';
}
class WithOptional extends EntityMixin(OptionalData, {
schema: {
article: ArticleEntity,
requiredArticle: ArticleEntity,
},
}) {}
class IDData {
id = '';
}
describe(`${schema.Entity.name} construction`, () => {
describe('pk', () => {
it('should use provided pk if part of class', () => {
class MyData {
username = '';
title = '';
pk() {
return this.username;
}
}
const MyEntity = EntityMixin(MyData);
expect(MyEntity.pk({ username: 'bob' })).toBe('bob');
const entity = MyEntity.fromJS({ username: 'bob' });
expect(entity.pk()).toBe('bob');
// @ts-expect-error
entity.lksdfl;
});
it('should use pk overide in Entity', () => {
class MyData {
username = '';
title = '';
pk() {
return this.username;
}
}
class MyEntity extends EntityMixin(MyData) {
pk() {
return this.title;
}
}
expect(MyEntity.pk({ title: 'hi' })).toBe('hi');
expect(MyEntity.fromJS({ title: 'hi' }).pk()).toBe('hi');
});
it('should use pk overide in Entity when base is set via options', () => {
class MyData {
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData, { pk: 'username' }) {
pk() {
return this.title;
}
}
expect(MyEntity.pk({ title: 'hi' })).toBe('hi');
expect(MyEntity.fromJS({ title: 'hi' }).pk()).toBe('hi');
});
it('should use pk field names', () => {
class MyData {
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData, { pk: 'username' }) {}
expect(MyEntity.pk({ username: 'bob' })).toBe('bob');
expect(MyEntity.fromJS({ username: 'bob' }).pk()).toBe('bob');
});
it('should use pk function option', () => {
class MyData {
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData, {
pk(v) {
//@ts-expect-error
v.sdlfkjsd;
return v.username;
},
}) {}
expect(MyEntity.pk({ username: 'bob' })).toBe('bob');
expect(MyEntity.fromJS({ username: 'bob' }).pk()).toBe('bob');
});
it('should fail with bad pk field name', () => {
class MyData {
username = '';
title = '';
}
// @ts-expect-error
class MyEntity extends EntityMixin(MyData, { pk: 'id' }) {}
// @ts-expect-error
expect(MyEntity.pk({ username: 'bob' })).toBeUndefined();
// @ts-expect-error
expect(MyEntity.fromJS({ username: 'bob' }).pk()).toBeUndefined();
});
it('should fail with no id and pk unspecified', () => {
class MyData {
username = '';
title = '';
}
// @ts-expect-error
class MyEntity extends EntityMixin(MyData) {}
// @ts-expect-error
expect(MyEntity.pk({ username: 'bob' })).toBeUndefined();
// @ts-expect-error
expect(MyEntity.fromJS({ username: 'bob' }).pk()).toBeUndefined();
expect(() =>
normalize(MyEntity, { username: 'bob' }),
).toThrowErrorMatchingSnapshot();
});
it('should use id field if no pk specified', () => {
class MyData {
id = '';
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData) {}
expect(MyEntity.pk({ id: '5' })).toBe('5');
expect(MyEntity.fromJS({ id: '5' }).pk()).toBe('5');
});
});
describe('key', () => {
it('should use class name with no key in options', () => {
class MyData {
id = '';
username = '';
title = '';
}
const MyEntity = EntityMixin(MyData);
expect(MyEntity.key).toBe('MyData');
});
it('should error with no discernable name', () => {
const MyEntity = EntityMixin(
class {
id = '';
username = '';
title = '';
},
);
expect(() => MyEntity.key).toThrowErrorMatchingInlineSnapshot(`
"Entity classes without a name must define \`static key\`
See: https://dataclient.io/rest/api/Entity#key"
`);
});
it('should use entity class name with no key in options', () => {
class MyData {
id = '';
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData) {}
expect(MyEntity.key).toBe('MyEntity');
});
it('should use key in options', () => {
class MyData {
id = '';
username = '';
title = '';
}
const MyEntity = EntityMixin(MyData, { key: 'MYKEY' });
expect(MyEntity.key).toBe('MYKEY');
class MyEntity2 extends EntityMixin(MyData, { key: 'MYKEY' }) {}
expect(MyEntity2.key).toBe('MYKEY');
});
it('should use static key in base class', () => {
class MyData {
id = '';
username = '';
title = '';
static key = 'MYKEY';
}
const MyEntity = EntityMixin(MyData);
expect(MyEntity.key).toBe('MYKEY');
});
it('should have options.key override base class', () => {
class MyData {
id = '';
username = '';
title = '';
static key = 'MYKEY';
}
const MyEntity = EntityMixin(MyData, { key: 'OVERRIDE' });
expect(MyEntity.key).toBe('OVERRIDE');
});
it('static key in Entity should override options', () => {
class MyData {
id = '';
username = '';
title = '';
}
class MyEntity extends EntityMixin(MyData, { key: 'OPTIONSKEY' }) {
static key = 'STATICKEY';
}
expect(MyEntity.key).toBe('STATICKEY');
});
});
describe('schema', () => {
it('options.schema should set schema', () => {
class MyData {
id = '';
username = '';
title = '';
createdAt = Temporal.Instant.fromEpochSeconds(0);
}
class MyEntity extends EntityMixin(MyData, {
schema: { createdAt: Temporal.Instant.from },
}) {}
expect(MyEntity.schema).toEqual({ createdAt: Temporal.Instant.from });
});
it('options.schema should override base schema', () => {
class MyData {
id = '';
username = '';
title = '';
createdAt = Temporal.Instant.fromEpochSeconds(0);
static schema = {
user: Temporal.Instant.from,
};
}
class MyEntity extends EntityMixin(MyData, {
schema: { createdAt: Temporal.Instant.from },
}) {}
expect(MyEntity.schema).toEqual({ createdAt: Temporal.Instant.from });
});
it('static schema in base should be used', () => {
class MyData {
id = '';
username = '';
title = '';
createdAt = Temporal.Instant.fromEpochSeconds(0);
static schema = {
createdAt: Temporal.Instant.from,
};
}
class MyEntity extends EntityMixin(MyData) {}
expect(MyEntity.schema).toEqual({ createdAt: Temporal.Instant.from });
});
it('static schema in Entity should override options', () => {
class MyData {
id = '';
username = '';
title = '';
createdAt = Temporal.Instant.fromEpochSeconds(0);
}
class MyEntity extends EntityMixin(MyData, {
schema: { createdAt: Temporal.Instant.from },
}) {
static schema = {
user: Temporal.Instant.from,
};
}
expect(MyEntity.schema).toEqual({ user: Temporal.Instant.from });
});
});
});
describe(`${schema.Entity.name} normalization`, () => {
let warnSpy: jest.SpyInstance;
afterEach(() => {
warnSpy.mockRestore();
});
beforeEach(() =>
(warnSpy = jest.spyOn(console, 'warn')).mockImplementation(() => {}),
);
test('normalizes an entity', () => {
class MyEntity extends EntityMixin(IDData) {}
expect(normalize(MyEntity, { id: '1' })).toMatchSnapshot();
});
test('normalizes already processed entities', () => {
class MyEntity extends EntityMixin(IDData) {}
class MyData {
id = '';
title = '';
nest = MyEntity.fromJS();
}
class Nested extends EntityMixin(MyData, {
schema: {
nest: MyEntity,
},
}) {}
expect(normalize(new schema.Array(MyEntity), ['1'])).toMatchSnapshot();
expect(
normalize(new schema.Object({ data: MyEntity }), { data: '1' }),
).toMatchSnapshot();
expect(
normalize(Nested, { title: 'hi', id: '5', nest: '10' }),
).toMatchSnapshot();
});
test('normalizes does not change value when shouldUpdate() returns false', () => {
class MyData {
id = '';
title = '';
}
class MyEntity extends EntityMixin(MyData) {
static shouldUpdate() {
return false;
}
}
const { entities, entityMeta } = normalize(MyEntity, {
id: '1',
title: 'hi',
});
const secondEntities = normalize(
MyEntity,
{ id: '1', title: 'second' },
[],
{ entities, entityMeta, indexes: {} },
).entities;
expect(entities.MyEntity['1']).toBeDefined();
expect(entities.MyEntity['1']).toBe(secondEntities.MyEntity['1']);
});
it('should throw a custom error if data does not include pk', () => {
class MyData {
name = '';
secondthing = '';
}
const MyEntity = EntityMixin(MyData, { pk: 'name' });
function normalizeBad() {
normalize(MyEntity, { secondthing: 'hi' });
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
// @ts-expect-error
EntityMixin(MyData, { pk: 'sdfasd' });
});
it('should not throw if schema key is missing from Entity', () => {
class MyData {
name = '';
secondthing = '';
}
// @ts-expect-error
const MyEntity = EntityMixin(MyData, {
pk: 'name',
schema: {
blarb: Temporal.Instant.from,
},
});
expect(
normalize(MyEntity, { name: 'bob', secondthing: 'hi' }),
).toMatchSnapshot();
});
it('should handle optional schema entries Entity', () => {
class MyData {
readonly name: string = '';
readonly secondthing: string = '';
readonly blarb: Date | undefined = undefined;
}
class MyEntity extends EntityMixin(MyData, {
pk: 'name',
schema: {
blarb: Temporal.Instant.from,
},
}) {}
expect(normalize(MyEntity, { name: 'bob', secondthing: 'hi' }))
.toMatchInlineSnapshot(`
{
"entities": {
"MyEntity": {
"bob": {
"name": "bob",
"secondthing": "hi",
},
},
},
"entityMeta": {
"MyEntity": {
"bob": {
"date": 1557831718135,
"expiresAt": Infinity,
"fetchedAt": 0,
},
},
},
"indexes": {},
"result": "bob",
}
`);
});
it('should throw a custom error if data loads with no matching props', () => {
class MyData {
name = '';
secondthing = '';
}
const MyEntity = EntityMixin(MyData, { pk: 'name' });
function normalizeBad() {
normalize(MyEntity, {});
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
it('should throw a custom error loads with array', () => {
class MyData {
name = '';
secondthing = '';
}
const MyEntity = EntityMixin(MyData, { pk: 'name' });
function normalizeBad() {
normalize(MyEntity, [
{ name: 'hi', secondthing: 'ho' },
{ name: 'hi', secondthing: 'ho' },
{ name: 'hi', secondthing: 'ho' },
]);
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
it('should error if no matching keys are found', () => {
class MyData {
readonly name: string = '';
}
// @ts-expect-error
class MyEntity extends EntityMixin(MyData, { pk: 'e' }) {}
expect(() =>
normalize(MyEntity, {
name: 0,
}),
).toThrowErrorMatchingSnapshot();
});
it('should allow many unexpected as long as none are missing', () => {
class MyData {
readonly name: string = '';
readonly a: string = '';
}
class MyEntity extends EntityMixin(MyData, { pk: 'name' }) {}
expect(
normalize(MyEntity, {
name: 'hi',
a: 'a',
b: 'b',
c: 'c',
d: 'e',
e: 0,
f: 0,
g: 0,
h: 0,
i: 0,
j: 0,
k: 0,
l: 0,
m: 0,
n: 0,
o: 0,
p: 0,
q: 0,
r: 0,
s: 0,
t: 0,
u: 0,
}),
).toMatchSnapshot();
expect(warnSpy.mock.calls.length).toBe(0);
});
it('should not expect getters returned', () => {
class MyData {
readonly name: string = '';
get other() {
return this.name + 5;
}
get another() {
return 'another';
}
get yetAnother() {
return 'another2';
}
}
class MyEntity extends EntityMixin(MyData, { pk: 'name' }) {}
function normalizeBad() {
normalize(MyEntity, { name: 'bob' });
}
expect(normalizeBad).not.toThrow();
expect(warnSpy.mock.calls.length).toBe(0);
});
it('should throw a custom error if data loads with string', () => {
class MyData {
readonly name: string = '';
readonly secondthing: string = '';
readonly thirdthing: number = 0;
pk() {
return this.name;
}
}
const MyEntity = EntityMixin(MyData);
function normalizeBad() {
normalize({ data: MyEntity }, 'hibho');
}
expect(normalizeBad).toThrowErrorMatchingSnapshot();
});
describe('key', () => {
test('key name must be a string', () => {
class MyData {
id = '';
}
// @ts-expect-error
class MyEntity extends EntityMixin(MyData) {
static get key() {
return 42;
}
}
});
});
describe('pk()', () => {
test('can use a custom pk() string', () => {
class User {
readonly idStr: string = '';
readonly name: string = '';
}
const UserEntity = EntityMixin(User, { pk: 'idStr' });
expect(
normalize(UserEntity, { idStr: '134351', name: 'Kathy' }),
).toMatchSnapshot();
});
test('can normalize entity IDs based on their object key', () => {
class User {
readonly name: string = '';
}
const UserEntity = EntityMixin(User, {
pk(value, parent, key) {
return key;
},
});
const inputSchema = new schema.Values(
{ users: UserEntity },
() => 'users',
);
expect(
normalize(inputSchema, {
'4': { name: 'taco' },
'56': { name: 'burrito' },
}),
).toMatchSnapshot();
});
test("can build the entity's ID from the parent object", () => {
class User {
readonly id: string = '';
readonly name: string = '';
}
const UserEntity = EntityMixin(User, {
pk(value, parent, key) {
return `${parent.name}-${key}-${value.id}`;
},
});
const inputSchema = new schema.Object({ user: UserEntity });
expect(
normalize(inputSchema, {
name: 'tacos',
user: { id: '4', name: 'Jimmy' },
}),
).toMatchSnapshot();
});
});
describe('mergeStrategy', () => {
test('defaults to plain merging', () => {
expect(
normalize(
[Tacos],
[
{ id: '1', name: 'foo' },
{ id: '1', name: 'bar', alias: 'bar' },
],
),
).toMatchSnapshot();
});
test('can use a custom merging strategy', () => {
class MergeTaco extends Tacos {
static merge(existing: any, incoming: any) {
const props = Object.assign({}, existing, incoming, {
name: (existing as MergeTaco).name,
});
return this.fromJS(props);
}
}
expect(
normalize(
[MergeTaco],
[
{ id: '1', name: 'foo' },
{ id: '1', name: 'bar', alias: 'bar' },
],
),
).toMatchSnapshot();
});
});
describe('process', () => {
test('can use a custom processing strategy', () => {
class ProcessTaco extends Tacos {
readonly slug: string = '';
static process(input: any, parent: any, key: string | undefined): any {
return {
...input,
slug: `thing-${(input as unknown as ProcessTaco).id}`,
};
}
}
const { entities, result } = normalize(ProcessTaco, {
id: '1',
name: 'foo',
});
const final = denormalize(ProcessTaco, result, entities);
expect(final).not.toEqual(expect.any(Symbol));
if (typeof final === 'symbol') return;
expect(final?.slug).toEqual('thing-1');
expect(final).toMatchSnapshot();
});
test('can use information from the parent in the process strategy', () => {
class Child {
id = '';
readonly content: string = '';
readonly parentId: string = '';
readonly parentKey: string = '';
}
class ChildEntity extends EntityMixin(Child) {
static process(input: any, parent: any, key: string | undefined): any {
return {
...input,
parentId: parent?.id,
parentKey: key,
};
}
}
class Parent {
id = '';
readonly content: string = '';
readonly child: ChildEntity = ChildEntity.fromJS({});
}
const ParentEntity = EntityMixin(Parent, {
schema: { child: ChildEntity },
});
const { entities, result } = normalize(ParentEntity, {
id: '1',
content: 'parent',
child: { id: '4', content: 'child' },
});
const final = denormalize(ParentEntity, result, entities);
expect(final).not.toEqual(expect.any(Symbol));
if (typeof final === 'symbol') return;
expect(final?.child?.parentId).toEqual('1');
expect(final).toMatchSnapshot();
});
describe('schema denormalization', () => {
class AttachmentsEntity extends EntityMixin(
class {
id = '';
},
) {}
expect(AttachmentsEntity.key).toBe('AttachmentsEntity');
class Entries {
id = '';
readonly type: string = '';
data = { attachment: undefined };
}
class EntriesEntity extends EntityMixin(Entries) {
static schema = {
data: { attachment: AttachmentsEntity },
};
static process(input: any, parent: any, key: string | undefined): any {
return {
...values(input)[0],
type: Object.keys(input)[0],
};
}
}
class EntriesEntity2 extends EntityMixin(Entries, {
schema: {
data: { attachment: AttachmentsEntity },
},
process(input, parent, key) {
return {
...values(input)[0],
type: Object.keys(input)[0],
};
},
}) {}
it.each([EntriesEntity, EntriesEntity2])(
'is run before and passed to the schema denormalization %s',
EntriesEntity => {
const { entities, result } = normalize(EntriesEntity, {
message: { id: '123', data: { attachment: { id: '456' } } },
});
const final = denormalize(EntriesEntity, result, entities);
expect(final).not.toEqual(expect.any(Symbol));
if (typeof final === 'symbol') return;
expect(final?.type).toEqual('message');
expect(final).toMatchSnapshot();
},
);
});
});
});
describe(`${schema.Entity.name} denormalization`, () => {
test('denormalizes an entity', () => {
const entities = {
Tacos: {
'1': { id: '1', name: 'foo' },
},
};
expect(denormalize(Tacos, '1', entities)).toMatchSnapshot();
expect(denormalize(Tacos, '1', fromJS(entities))).toMatchSnapshot();
});
class Food extends EntityMixin(
class {
id = '';
},
) {}
class MenuData {
id = '';
readonly food: Food = Food.fromJS();
}
class Menu extends EntityMixin(MenuData, { schema: { food: Food } }) {}
test('denormalizes deep entities', () => {
const entities = {
Menu: {
'1': { id: '1', food: '1' },
'2': { id: '2' },
},
Food: {
'1': { id: '1' },
},
};
const de1 = denormalize(Menu, '1', entities);
expect(de1).toMatchSnapshot();
expect(denormalize(Menu, '1', fromJS(entities))).toEqual(de1);
const de2 = denormalize(Menu, '2', entities);
expect(de2).toMatchSnapshot();
expect(denormalize(Menu, '2', fromJS(entities))).toEqual(de2);
});
test('denormalizes deep entities while maintaining referential equality', () => {
const entities = {
Menu: {
'1': { id: '1', food: '1' },
'2': { id: '2' },
},
Food: {
'1': { id: '1' },
},
};
const memo = new SimpleMemoCache();
const first = memo.denormalize(Menu, '1', entities);
const second = memo.denormalize(Menu, '1', entities);
expect(first).not.toEqual(expect.any(Symbol));
if (typeof first === 'symbol') return;
expect(second).not.toEqual(expect.any(Symbol));
if (typeof second === 'symbol') return;
expect(first).toBe(second);
expect(first?.food).toBe(second?.food);
});
test('denormalizes to undefined when validate() returns string', () => {
class MyTacos extends Tacos {
static validate(entity) {
if (!Object.hasOwn(entity, 'name')) return 'no name';
}
}
const entities = {
MyTacos: {
'1': { id: '1' },
},
};
expect(denormalize(MyTacos, '1', entities)).toEqual(expect.any(Symbol));
expect(denormalize(MyTacos, '1', fromJS(entities))).toEqual(
expect.any(Symbol),
);
});
test('denormalizes to undefined for missing data', () => {
const entities = {
Menu: {
'1': { id: '1', food: '2' },
},
Food: {
'1': { id: '1' },
},
};
expect(denormalize(Menu, '1', entities)).toMatchSnapshot();
expect(denormalize(Menu, '1', fromJS(entities))).toMatchSnapshot();
expect(denormalize(Menu, '2', entities)).toMatchSnapshot();
expect(denormalize(Menu, '2', fromJS(entities))).toMatchSnapshot();
});
it('should handle optional schema entries Entity', () => {
class MyData {
readonly name: string = '';
readonly secondthing: string = '';
readonly blarb: Date | undefined = undefined;
}
class MyEntity extends EntityMixin(MyData, {
pk: 'name',
schema: { blarb: Temporal.Instant.from },
}) {}
expect(
denormalize(MyEntity, 'bob', {
MyEntity: { bob: { name: 'bob', secondthing: 'hi' } },
}),
).toMatchInlineSnapshot(`
MyEntity {
"blarb": undefined,
"name": "bob",
"secondthing": "hi",
}
`);
});
it('should handle null schema entries Entity', () => {
class MyData {
readonly name: string = '';
readonly secondthing: string = '';
readonly blarb: Date | null = null;
}
class MyEntity extends EntityMixin(MyData, {
pk: 'name',
schema: { blarb: Temporal.Instant.from },
}) {}
expect(
denormalize(MyEntity, 'bob', {
MyEntity: { bob: { name: 'bob', secondthing: 'hi', blarb: null } },
}),
).toMatchInlineSnapshot(`
MyEntity {
"blarb": null,
"name": "bob",
"secondthing": "hi",
}
`);
});
test('denormalizes to undefined for deleted data', () => {
const entities = {
Menu: {
'1': { id: '1', food: '2' },
'2': INVALID,
},
Food: {
'1': { id: '1' },
'2': INVALID,
},
};
expect(denormalize(Menu, '1', entities)).toMatchSnapshot();
expect(denormalize(Menu, '1', fromJS(entities))).toMatchSnapshot();
expect(denormalize(Menu, '2', entities)).toMatchSnapshot();
expect(denormalize(Menu, '2', fromJS(entities))).toMatchSnapshot();
});
test('denormalizes deep entities with records', () => {
const Food = Record<{ id: null | string }>({ id: null });
const MenuR = Record<{ id: null | string; food: null | string }>({
id: null,
food: null,
});
const entities = {
Menu: {
'1': new MenuR({ id: '1', food: '1' }),
'2': new MenuR({ id: '2' }),
},
Food: {
'1': new Food({ id: '1' }),
},
};
expect(denormalize(Menu, '1', entities)).toMatchSnapshot();
expect(denormalize(Menu, '1', fromJS(entities))).toMatchSnapshot();
expect(denormalize(Menu, '2', entities)).toMatchSnapshot();
expect(denormalize(Menu, '2', fromJS(entities))).toMatchSnapshot();
});
test('can denormalize already partially denormalized data', () => {
const entities = {
Menu: {
'1': { id: '1', food: { id: '1' } },
},
Food: {
// TODO: BREAKING CHANGE: Update this to use main entity and only return nested as 'fallback' in case main entity is not set
'1': { id: '1', extra: 'hi' },
},
};
expect(denormalize(Menu, '1', entities)).toMatchSnapshot();
expect(denormalize(Menu, '1', fromJS(entities))).toMatchSnapshot();
});
describe('nesting', () => {
class UserData {
id = '';
readonly role = '';
readonly reports: Report[] = [];
}
class User extends EntityMixin(UserData) {}
class ReportData {
id = '';
readonly title: string = '';
readonly draftedBy: User = User.fromJS();
readonly publishedBy: User = User.fromJS();
}
class Report extends EntityMixin(ReportData, {
schema: {
draftedBy: User,
publishedBy: User,
},
}) {}
User.schema = {
reports: new schema.Array(Report),
};
class CommentData {
id = '';
readonly body: string = '';
readonly author: User = User.fromJS();
}
class Comment extends EntityMixin(CommentData, {
schema: { author: User },
}) {}
test('denormalizes recursive dependencies', () => {
const entities = {
Report: {
'123': {
id: '123',
title: 'Weekly report',
draftedBy: '456',
publishedBy: '456',
},
},
User: {
'456': {
id: '456',
role: 'manager',
reports: ['123'],
},
},
};
expect(denormalize(Report, '123', entities)).toMatchSnapshot();
expect(denormalize(Report, '123', fromJS(entities))).toMatchSnapshot();
expect(denormalize(User, '456', entities)).toMatchSnapshot();
expect(denormalize(User, '456', fromJS(entities))).toMatchSnapshot();
});
test('denormalizes recursive entities with referential equality', () => {
const entities = {
Report: {
'123': {
id: '123',
title: 'Weekly report',
draftedBy: '456',
publishedBy: '456',
},
},
Comment: {
'999': {
id: '999',
body: 'Good morning',
author: '456',
},
},
User: {
'456': {
id: '456',
role: 'manager',
reports: ['123'],
},
'457': {
id: '457',
role: 'servant',
reports: ['123'],
},
},
};
const memo = new SimpleMemoCache();
const denormalizedReport = memo.denormalize(Report, '123', entities);
expect(denormalizedReport).not.toEqual(expect.any(Symbol));
if (typeof denormalizedReport === 'symbol') return;
expect(denormalizedReport).toBeDefined();
// This is just for TypeScript, the above line actually determines this
if (!denormalizedReport) throw new Error('expected to be defined');
expect(denormalizedReport).toBe(denormalizedReport.draftedBy?.reports[0]);
expect(denormalizedReport.publishedBy).toBe(denormalizedReport.draftedBy);
expect(denormalizedReport.draftedBy?.reports[0].draftedBy).toBe(
denormalizedReport.draftedBy,
);
const denormalizedReport2 = memo.denormalize(Report, '123', entities);
expect(denormalizedReport2).not.toEqual(expect.any(Symbol));
if (typeof denormalizedReport2 === 'symbol') return;
expect(denormalizedReport2).toStrictEqual(denormalizedReport);
expect(denormalizedReport2).toBe(denormalizedReport);
// NOTE: Given how immutable data works, referential equality can't be
// maintained with nested denormalization.
});
test('denormalizes maintain referential equality when appropriate', () => {
const entities = {
Report: {
'123': {
id: '123',
title: 'Weekly report',
draftedBy: '456',
publishedBy: '456',
},
},
Comment: {
'999': {
id: '999',
body: 'Good morning',
author: '456',
},
},
User: {
'456': {
id: '456',
role: 'manager',
reports: ['123'],
},
'457': {
id: '457',
role: 'servant',
reports: ['123'],
},
},
};
const memo = new SimpleMemoCache();
const input = { report: '123', comment: '999' };
const sch = new schema.Object({
report: Report,
comment: Comment,
});
const denormalizedReport = memo.denormalize(sch, input, entities);
expect(denormalizedReport).not.toEqual(expect.any(Symbol));
if (typeof denormalizedReport === 'symbol') return;
expect(denormalizedReport.report).toBeDefined();
expect(denormalizedReport.comment).toBeDefined();
// This is just for TypeScript, the above line actually determines this
if (!denormalizedReport.report || !denormalizedReport.comment)
throw new Error('expected to be defined');
expect(denormalizedReport.report.publishedBy).toBe(
denormalizedReport.comment.author,
);
const denormalizedReport2 = memo.denormalize(sch, input, entities);
expect(denormalizedReport2).not.toEqual(expect.any(Symbol));
if (typeof denormalizedReport2 === 'symbol') return;
expect(denormalizedReport2).toStrictEqual(denormalizedReport);
expect(denormalizedReport2).toBe(denormalizedReport);
// should update all uses of user
const nextEntities = {
...entities,
User: {
...entities.User,
'456': {
...entities.User[456],
role: 'supervisor',
},
},
};
const denormalizedReport3 = memo.denormalize(sch, input, nextEntities);
expect(denormalizedReport3).not.toEqual(expect.any(Symbol));
if (typeof denormalizedReport3 === 'symbol') return;
expect(denormalizedReport3.comment?.author?.role).toBe('supervisor');
expect(denormalizedReport3.report?.draftedBy?.role).toBe('supervisor');
// NOTE: Given how immutable data works, referential equality can't be
// maintained with nested denormalization.
});
describe('optional entities', () => {
it('should be marked as found even when optional is not there', () => {
const denormalized = denormalize(WithOptional, 'abc', {
[WithOptional.key]: {
abc: {
id: 'abc',
// this is typed because we're actually sending wrong data to it
requiredArticle: '5' as any,
nextPage: 'blob',
},
},
[ArticleEntity.key]: {
['5']: { id: '5' },
},
});
const response = denormalized;
expect(response).toBeDefined();
expect(response).toBeInstanceOf(WithOptional);
expect(response).toEqual({
id: 'abc',
article: null,
requiredArticle: ArticleEntity.fromJS({ id: '5' }),
nextPage: 'blob',
});
});
it('should be marked as found when nested entity is missing', () => {
const denormalized = denormalize(WithOptional, 'abc', {
[WithOptional.key]: {
abc: WithOptional.fromJS({
id: 'abc',
// this is typed because we're actually sending wrong data to it
article: '5' as any,
nextPage: 'blob',
}),
},
[ArticleEntity.key]: {
['5']: ArticleEntity.fromJS({ id: '5' }),
},
});
expect(denormalized).not.toEqual(expect.any(Symbol));
if (typeof denormalized === 'symbol') return;
const response = denormalized;
expect(response).toBeDefined();
expect(response).toBeInstanceOf(WithOptional);
expect(response).toEqual({
id: 'abc',
article: ArticleEntity.fromJS({ id: '5' }),
requiredArticle: ArticleEntity.fromJS(),
nextPage: 'blob',
});
});
it('should be marked as deleted when required entity is deleted symbol', () => {
const denormalized = denormalize(WithOptional, 'abc', {
[WithOptional.key]: {
abc: {
id: 'abc',
// this is typed because we're actually sending wrong data to it
requiredArticle: '5' as any,
nextPage: 'blob',
},
},
[ArticleEntity.key]: {
['5']: INVALID,
},
});
expect(denormalized).toEqual(expect.any(Symbol));
});
it('should be non-required deleted members should not result in deleted indicator', () => {
const denormalized = denormalize(WithOptional, 'abc', {
[WithOptional.key]: {
abc: WithOptional.fromJS({
id: 'abc',
// this is typed because we're actually sending wrong data to it
article: '5' as any,
requiredArticle: '6' as any,
nextPage: 'blob',
}),
},
[ArticleEntity.key]: {
['5']: INVALID,
['6']: ArticleEntity.fromJS({ id: '6' }),
},
});
expect(denormalized).not.toEqual(expect.any(Symbol));
if (typeof denormalized === 'symbol') return;
const response = denormalized;
expect(response).toBeDefined();
expect(response).toBeInstanceOf(WithOptional);
expect(response).toEqual({
id: 'abc',
article: undefined,
requiredArticle: ArticleEntity.fromJS({ id: '6' }),
nextPage: 'blob',
});
});
it('should be both deleted and not found when both are true in different parts of schema', () => {
const denormalized = denormalize(
new schema.Object({ data: WithOptional, other: ArticleEntity }),
{ data: 'abc' },
{
[WithOptional.key]: {
abc: WithOptional.fromJS({
id: 'abc',
// this is typed because we're actually sending wrong data to it
article: '6' as any,
requiredArticle: '5' as any,
nextPage: 'blob',
}),
},
[ArticleEntity.key]: {
['5']: INVALID,
['6']: ArticleEntity.fromJS({ id: '6' }),
},
},
);
expect(denormalized).toEqual(expect.any(Symbol));
});
});
});
});
describe('Entity.defaults', () => {
it('should work with inheritance', () => {
abstract class DefaultsEntity extends EntityMixin(
class {
id = '';
},
) {
static getMyDefaults() {
return this.defaults;
}
}
class ID extends DefaultsEntity {
id = '';
pk() {
return this.id;
}
}
class UserEntity extends ID {
username = '';
createdAt = Temporal.Instant.fromEpochSeconds(0);
static schema = {
createdAt: Temporal.Instant.from,
};
}
expect(ID.getMyDefaults()).toMatchInlineSnapshot(`
ID {
"id": "",
}
`);
expect(UserEntity.getMyDefaults()).toMatchInlineSnapshot(`
UserEntity {
"createdAt": "1970-01-01T00:00:00Z",
"id": "",
"username": "",
}
`);
});
});