@data-client/normalizr
Version:
Normalizes and denormalizes JSON according to schema for Redux and Flux applications
693 lines (589 loc) • 19.5 kB
text/typescript
// eslint-env jest
import { Entity, schema } from '@data-client/endpoint';
import { fromJS } from 'immutable';
import { INVALID } from '..';
import {
normalize as normalizeImm,
denormalize,
type ImmutableStoreData,
} from '../imm';
// Helper to create ImmutableJS state from plain objects
function createImmutableState(state: {
entities?: { [k: string]: { [k: string]: any } };
indexes?: { [k: string]: { [k: string]: { [field: string]: string } } };
entitiesMeta?: {
[k: string]: {
[k: string]: { date: number; expiresAt: number; fetchedAt: number };
};
};
}): ImmutableStoreData {
return {
entities: fromJS(state.entities || {}),
indexes: fromJS(state.indexes || {}),
entitiesMeta: fromJS(state.entitiesMeta || {}),
};
}
let dateSpy: jest.SpyInstance;
beforeAll(() => {
dateSpy = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf());
});
afterAll(() => {
dateSpy.mockRestore();
});
describe('normalizeImm - ImmutableJS normalization', () => {
// Basic entity for testing
class User extends Entity {
id = '';
name = '';
pk() {
return this.id;
}
}
class Article extends Entity {
id = '';
title = '';
author = User.fromJS();
pk() {
return this.id;
}
static schema = {
author: User,
};
}
describe('basic normalization', () => {
test('normalizes a single entity into ImmutableJS state', () => {
const initialState = createImmutableState({});
const result = normalizeImm(
User,
{ id: '1', name: 'John' },
[],
initialState,
);
expect(result.result).toBe('1');
expect(result.entities.getIn(['User', '1'])).toEqual({
id: '1',
name: 'John',
});
});
test('normalizes with empty initial state (default)', () => {
const result = normalizeImm(User, { id: '1', name: 'John' });
expect(result.result).toBe('1');
expect(result.entities.getIn(['User', '1'])).toEqual({
id: '1',
name: 'John',
});
});
test('returns input when schema is undefined', () => {
const initialState = createImmutableState({});
const input = { foo: 'bar' };
const result = normalizeImm(undefined, input, [], initialState);
expect(result.result).toBe(input);
expect(result.entities).toBe(initialState.entities);
});
test('returns input when schema is null', () => {
const initialState = createImmutableState({});
const input = { foo: 'bar' };
const result = normalizeImm(null, input, [], initialState);
expect(result.result).toBe(input);
});
});
describe('nested entities', () => {
test('normalizes nested entities', () => {
const initialState = createImmutableState({});
const result = normalizeImm(
Article,
{
id: '1',
title: 'Hello World',
author: { id: '10', name: 'Jane' },
},
[],
initialState,
);
expect(result.result).toBe('1');
expect(result.entities.getIn(['Article', '1'])).toEqual({
id: '1',
title: 'Hello World',
author: '10',
});
expect(result.entities.getIn(['User', '10'])).toEqual({
id: '10',
name: 'Jane',
});
});
test('normalizes deeply nested entities', () => {
class Comment extends Entity {
id = '';
text = '';
author = User.fromJS();
pk() {
return this.id;
}
static schema = {
author: User,
};
}
class Post extends Entity {
id = '';
title = '';
author = User.fromJS();
comments: Comment[] = [];
pk() {
return this.id;
}
static schema = {
author: User,
comments: [Comment],
};
}
const result = normalizeImm(Post, {
id: 'post1',
title: 'My Post',
author: { id: 'user1', name: 'Alice' },
comments: [
{ id: 'c1', text: 'Great!', author: { id: 'user2', name: 'Bob' } },
{ id: 'c2', text: 'Thanks!', author: { id: 'user1', name: 'Alice' } },
],
});
expect(result.result).toBe('post1');
expect(result.entities.getIn(['Post', 'post1', 'comments'])).toEqual([
'c1',
'c2',
]);
expect(result.entities.getIn(['Comment', 'c1', 'author'])).toBe('user2');
expect(result.entities.getIn(['User', 'user1', 'name'])).toBe('Alice');
expect(result.entities.getIn(['User', 'user2', 'name'])).toBe('Bob');
});
});
describe('entity updates and merging', () => {
test('merges with existing entities in state', () => {
const initialState = createImmutableState({
entities: {
User: {
'1': { id: '1', name: 'Old Name' },
},
},
entitiesMeta: {
User: {
'1': { date: 1000, expiresAt: 2000, fetchedAt: 1000 },
},
},
});
const result = normalizeImm(
User,
{ id: '1', name: 'New Name' },
[],
initialState,
{ date: 2000, expiresAt: 3000, fetchedAt: 2000 },
);
const user = result.entities.getIn(['User', '1']);
// Verify the new data is present
expect(user.name).toBe('New Name');
expect(user.id).toBe('1');
});
test('handles multiple entities of same type', () => {
const result = normalizeImm(
[User],
[
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' },
],
);
expect(result.result).toEqual(['1', '2', '3']);
expect(result.entities.getIn(['User', '1', 'name'])).toBe('Alice');
expect(result.entities.getIn(['User', '2', 'name'])).toBe('Bob');
expect(result.entities.getIn(['User', '3', 'name'])).toBe('Charlie');
});
});
describe('arrays and collections', () => {
test('normalizes array of entities', () => {
const result = normalizeImm(
[User],
[
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
],
);
expect(result.result).toEqual(['1', '2']);
expect(result.entities.getIn(['User', '1'])).toEqual({
id: '1',
name: 'User 1',
});
expect(result.entities.getIn(['User', '2'])).toEqual({
id: '2',
name: 'User 2',
});
});
test('normalizes empty array', () => {
const result = normalizeImm([User], []);
expect(result.result).toEqual([]);
});
});
describe('entity meta', () => {
test('stores entity metadata correctly', () => {
const meta = { date: 1000, expiresAt: 2000, fetchedAt: 1000 };
const result = normalizeImm(
User,
{ id: '1', name: 'Test' },
[],
createImmutableState({}),
meta,
);
expect(result.entitiesMeta.getIn(['User', '1'])).toEqual(meta);
});
});
describe('indexes', () => {
class IndexedUser extends Entity {
id = '';
username = '';
pk() {
return this.id;
}
static indexes = ['username'] as const;
}
test('creates indexes for indexed entities', () => {
const result = normalizeImm(IndexedUser, {
id: '1',
username: 'johndoe',
});
expect(result.indexes.getIn(['IndexedUser', 'username', 'johndoe'])).toBe(
'1',
);
});
test('updates indexes when entity changes', () => {
const initialState = createImmutableState({
entities: {
IndexedUser: {
'1': { id: '1', username: 'oldname' },
},
},
indexes: {
IndexedUser: {
username: { oldname: '1' },
},
},
entitiesMeta: {
IndexedUser: {
'1': { date: 1000, expiresAt: 2000, fetchedAt: 1000 },
},
},
});
const result = normalizeImm(
IndexedUser,
{ id: '1', username: 'newname' },
[],
initialState,
{ date: 2000, expiresAt: 3000, fetchedAt: 2000 },
);
expect(result.indexes.getIn(['IndexedUser', 'username', 'newname'])).toBe(
'1',
);
});
});
describe('invalidation', () => {
test('invalidates entity correctly', () => {
const InvalidateUser = new schema.Invalidate(User);
const initialState = createImmutableState({
entities: {
User: {
'1': { id: '1', name: 'Test' },
},
},
entitiesMeta: {
User: {
'1': { date: 1000, expiresAt: 2000, fetchedAt: 1000 },
},
},
});
// Must pass an object to trigger invalidation (strings are treated as already processed)
const result = normalizeImm(
InvalidateUser,
{ id: '1', name: 'Test' },
[],
initialState,
);
expect(result.entities.getIn(['User', '1'])).toBe(INVALID);
});
});
describe('error handling', () => {
test('throws error for null input', () => {
expect(() => {
normalizeImm(User, null);
}).toThrow();
});
test('throws error for unexpected input type', () => {
expect(() => {
normalizeImm(User, 'not an object');
}).toThrow();
});
});
describe('round-trip: normalize and denormalize', () => {
test('denormalize returns original data structure', () => {
// Use proper ImmutableJS initial state
const initialState = createImmutableState({});
const originalData = {
id: '1',
title: 'Test Article',
author: { id: '10', name: 'Author' },
};
const normalized = normalizeImm(Article, originalData, [], initialState);
// The entities are now proper ImmutableJS Maps
const denormalized = denormalize(
Article,
'1',
normalized.entities as any,
);
expect(denormalized).toBeInstanceOf(Article);
expect((denormalized as Article).id).toBe('1');
expect((denormalized as Article).title).toBe('Test Article');
expect((denormalized as Article).author).toBeInstanceOf(User);
expect((denormalized as Article).author.id).toBe('10');
expect((denormalized as Article).author.name).toBe('Author');
});
test('handles array normalization round-trip', () => {
const initialState = createImmutableState({});
const originalData = [
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
];
const normalized = normalizeImm([User], originalData, [], initialState);
const denormalized = denormalize(
[User],
normalized.result,
normalized.entities as any,
);
expect(Array.isArray(denormalized)).toBe(true);
expect(denormalized as User[]).toHaveLength(2);
expect((denormalized as User[])[0]).toBeInstanceOf(User);
expect((denormalized as User[])[0].id).toBe('1');
expect((denormalized as User[])[1].id).toBe('2');
});
});
describe('schema.Object', () => {
test('normalizes plain objects with schemas', () => {
const result = normalizeImm(
{ user: User },
{ user: { id: '1', name: 'Test' } },
);
expect(result.result).toEqual({ user: '1' });
expect(result.entities.getIn(['User', '1', 'name'])).toBe('Test');
});
});
describe('schema.Values', () => {
test('normalizes values schema', () => {
const ValuesSchema = new schema.Values(User);
const result = normalizeImm(ValuesSchema, {
first: { id: '1', name: 'First' },
second: { id: '2', name: 'Second' },
});
expect(result.result).toEqual({ first: '1', second: '2' });
expect(result.entities.getIn(['User', '1', 'name'])).toBe('First');
expect(result.entities.getIn(['User', '2', 'name'])).toBe('Second');
});
});
describe('integration with EntityMixin', () => {
class FoodData {
id = '';
name = '';
}
class Food extends schema.EntityMixin(FoodData) {}
class MenuData {
id = '';
name = '';
food = Food.fromJS();
}
class Menu extends schema.EntityMixin(MenuData, {
schema: { food: Food },
}) {}
test('normalizes EntityMixin entities', () => {
const result = normalizeImm(Menu, {
id: '1',
name: 'Lunch Menu',
food: { id: '10', name: 'Pizza' },
});
expect(result.result).toBe('1');
expect(result.entities.getIn(['Menu', '1', 'name'])).toBe('Lunch Menu');
expect(result.entities.getIn(['Menu', '1', 'food'])).toBe('10');
expect(result.entities.getIn(['Food', '10', 'name'])).toBe('Pizza');
});
});
describe('error handling - additional coverage', () => {
test('throws descriptive error for parseable JSON string input', () => {
expect(() => {
normalizeImm(User, '{"id": "1", "name": "Test"}');
}).toThrow(/Parsing this input string as JSON worked/);
});
});
describe('denormalize.imm coverage', () => {
test('returns undefined input as-is', () => {
const result = denormalize(User, undefined, fromJS({ User: {} }));
expect(result).toBeUndefined();
});
});
describe('index edge cases', () => {
class IndexedUser extends Entity {
id = '';
username = '';
pk() {
return this.id;
}
static indexes = ['username'] as const;
}
test('handles index update when existing entity has old index value', () => {
// Set up initial state with an indexed entity
const initialState = createImmutableState({
entities: {
IndexedUser: {
'1': { id: '1', username: 'oldusername' },
},
},
indexes: {
IndexedUser: {
username: { oldusername: '1' },
},
},
entitiesMeta: {
IndexedUser: {
'1': { date: 1000, expiresAt: 2000, fetchedAt: 1000 },
},
},
});
// Update the entity with a new username
const result = normalizeImm(
IndexedUser,
{ id: '1', username: 'newusername' },
[],
initialState,
{ date: 2000, expiresAt: 3000, fetchedAt: 2000 },
);
// Old index should be invalidated, new index should be set
expect(
result.indexes.getIn(['IndexedUser', 'username', 'newusername']),
).toBe('1');
expect(
result.indexes.getIn(['IndexedUser', 'username', 'oldusername']),
).toBe(INVALID);
expect(result.entities.getIn(['IndexedUser', '1', 'username'])).toBe(
'newusername',
);
});
test('handles entity without index value defined', () => {
// Entity with index field not set
const result = normalizeImm(IndexedUser, { id: '1' });
expect(result.result).toBe('1');
expect(result.entities.getIn(['IndexedUser', '1', 'id'])).toBe('1');
});
test('handles existing entity with undefined index value', () => {
// Set up initial state where entity exists but index field is undefined
const initialState = createImmutableState({
entities: {
IndexedUser: {
'1': { id: '1' }, // username is undefined
},
},
indexes: {
IndexedUser: {
username: {},
},
},
entitiesMeta: {
IndexedUser: {
'1': { date: 1000, expiresAt: 2000, fetchedAt: 1000 },
},
},
});
// Update with a new username
const result = normalizeImm(
IndexedUser,
{ id: '1', username: 'newuser' },
[],
initialState,
{ date: 2000, expiresAt: 3000, fetchedAt: 2000 },
);
// Should set the new index without trying to remove undefined old value
expect(result.indexes.getIn(['IndexedUser', 'username', 'newuser'])).toBe(
'1',
);
});
test('handles multiple indexed entities in sequence', () => {
const result = normalizeImm(
[IndexedUser],
[
{ id: '1', username: 'alice' },
{ id: '2', username: 'bob' },
],
);
expect(result.indexes.getIn(['IndexedUser', 'username', 'alice'])).toBe(
'1',
);
expect(result.indexes.getIn(['IndexedUser', 'username', 'bob'])).toBe(
'2',
);
});
});
describe('default empty store behavior', () => {
test('works with no initial state provided', () => {
// This tests the emptyImmutableLike fallback
const result = normalizeImm(User, { id: '1', name: 'Test' });
expect(result.result).toBe('1');
expect(result.entities.getIn(['User', '1', 'name'])).toBe('Test');
});
test('default store getIn returns undefined for non-existent paths', () => {
const result = normalizeImm(User, { id: '1', name: 'Test' });
// Verify the default store behavior for non-existent paths
expect(result.entities.getIn(['NonExistent', 'key'])).toBeUndefined();
expect(result.entities.getIn(['User', '999'])).toBeUndefined();
});
test('default store get returns nested structure for existing keys', () => {
const result = normalizeImm(User, { id: '1', name: 'Test' });
// Call get() on the result to cover the get() method
const userEntities = result.entities.get('User');
expect(userEntities).toBeDefined();
// The returned object should also have get/getIn methods
if (userEntities && typeof userEntities.getIn === 'function') {
expect(userEntities.getIn(['1', 'name'])).toBe('Test');
}
});
test('default store get returns primitive values directly', () => {
const result = normalizeImm(User, { id: '1', name: 'Test' });
// Access nested values via get chain
const userEntities = result.entities.get('User');
if (userEntities && typeof userEntities.get === 'function') {
const user = userEntities.get('1');
// At this level, user is the entity object itself
expect(user).toBeDefined();
// Access a primitive property on the user object
if (user && typeof user.get === 'function') {
const name = user.get('name');
// This should return a primitive string, covering line 143
expect(name).toBe('Test');
}
}
});
test('default store get returns undefined for non-existent key', () => {
const result = normalizeImm(User, { id: '1', name: 'Test' });
// Call get on a non-existent key in nested structure
const userEntities = result.entities.get('User');
if (userEntities && typeof userEntities.get === 'function') {
const nonExistent = userEntities.get('nonexistent');
// Returns undefined for missing key (primitive path)
expect(nonExistent).toBeUndefined();
}
});
test('default store handles nested setIn operations', () => {
// Multiple entities trigger multiple setIn calls
const result = normalizeImm(
[User],
[
{ id: '1', name: 'First' },
{ id: '2', name: 'Second' },
],
);
expect(result.entities.getIn(['User', '1', 'name'])).toBe('First');
expect(result.entities.getIn(['User', '2', 'name'])).toBe('Second');
});
});
});