@data-client/core
Version:
Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch
413 lines (385 loc) • 11.3 kB
text/typescript
import { Endpoint, Entity, Scalar, schema } from '@data-client/endpoint';
import { ExpiryStatus } from '../..';
import { initialState } from '../../state/reducer/createReducer';
import Contoller from '../Controller';
describe('Controller.getResponse()', () => {
it('denormalizes schema with extra members but not set', () => {
const controller = new Contoller();
class Tacos extends Entity {
type = '';
id = '';
}
const ep = new Endpoint(() => Promise.resolve(), {
key() {
return 'mytest';
},
schema: {
data: [Tacos],
extra: '',
page: {
first: null,
second: undefined,
third: 0,
complex: { complex: true, next: false },
},
},
});
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const state = {
...initialState,
entities,
endpoints: {
[ep.key()]: {
data: ['1', '2'],
},
},
};
const { data, expiryStatus } = controller.getResponse(ep, state);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
expect(data).toMatchSnapshot();
});
it('denormalizes distinct schemas for null arg', () => {
const controller = new Contoller();
class Tacos extends Entity {
type = '';
id = '';
pk() {
return this.id;
}
}
const ep = new Endpoint(() => Promise.resolve(), {
key() {
return 'mytest';
},
schema: {
data: [Tacos],
extra: '',
page: {
first: null,
second: undefined,
third: 0,
complex: { complex: true, next: false },
},
},
});
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const state = {
...initialState,
entities,
endpoints: {
[ep.key()]: {
data: ['1', '2'],
},
},
};
const { data, expiryStatus } = controller.getResponse(ep, null, state);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
// null args means don't fill anything in
expect(data.data).toBeUndefined();
expect(data).toMatchInlineSnapshot(`
{
"data": undefined,
"extra": "",
"page": {
"complex": {
"complex": true,
"next": false,
},
"first": null,
"second": undefined,
"third": 0,
},
}
`);
expect(controller.getResponse(ep, null, state).data).toStrictEqual(data);
const ep2 = ep.extend({ schema: { data: Tacos, nextPage: { five: '5' } } });
const data2 = controller.getResponse(ep2, null, state).data;
expect(data2.data).toBeUndefined();
expect(data2).toMatchInlineSnapshot(`
{
"data": undefined,
"nextPage": {
"five": "5",
},
}
`);
});
it('does not over-denormalize a schema map containing string values', () => {
// Regression: `String.prototype.normalize` made string leaves look like schemas.
const controller = new Contoller();
const ep = new Endpoint(() => Promise.resolve(), {
key() {
return 'string-only-schema';
},
schema: { label: 'hello', count: 0, nested: { value: 'world' } },
});
const cached = { label: 'hi', count: 5, nested: { value: 'there' } };
const state = {
...initialState,
endpoints: { [ep.key()]: cached },
};
const { data, expiryStatus } = controller.getResponse(ep, state);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
// Reference equality — denormalize must be skipped for entity-free schemas.
expect(data).toBe(cached);
});
it('infers schema with extra members but not set', () => {
const controller = new Contoller();
class Tacos extends Entity {
type = '';
id = '';
}
const ep = new Endpoint(({ id }: { id: string }) => Promise.resolve(), {
key({ id }) {
return `mytest ${id}`;
},
schema: {
data: Tacos,
extra: '',
page: {
first: null,
second: '',
third: 0,
complex: { complex: true, next: false },
},
},
});
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const state = {
...initialState,
entities,
entitiesMeta: {
Tacos: {
1: { date: 1000000, expiresAt: 1100000, fetchedAt: 1000000 },
2: { date: 2000000, expiresAt: 2100000, fetchedAt: 2000000 },
},
},
};
const { data, expiryStatus, expiresAt } = controller.getResponse(
ep,
{ id: '1' },
state,
);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
expect(data).toMatchSnapshot();
expect(expiresAt).toBe(1100000);
// test caching
const second = controller.getResponse(ep, { id: '1' }, state);
expect(second.data.data).toBe(data.data);
expect(second.expiryStatus).toBe(expiryStatus);
expect(second.expiresAt).toBe(expiresAt);
});
});
describe('Controller.getResponse() with Scalar', () => {
// Regression: `requiresDenormalize` (formerly `schemaHasEntity`) previously
// walked `Object.values(scalar)` and recursed into the `lensSelector`
// function, causing infinite recursion (RangeError). The table-resident
// Scalar schema (no `pk`) was also not recognized, so `Values(Scalar)`
// returned false and Controller skipped denormalization — returning raw
// cpk strings instead of the joined cell data. See
// packages/core/src/controller/Controller.ts `requiresDenormalize`.
class Company extends Entity {
id = '';
price = 0;
pct_equity = 0;
shares = 0;
static key = 'Company';
}
const PortfolioScalar = new Scalar({
lens: (args: readonly any[]) => args[0]?.portfolio,
key: 'portfolio',
entity: Company,
});
Company.schema = {
pct_equity: PortfolioScalar,
shares: PortfolioScalar,
} as any;
// Hard cap so a regression that re-introduces infinite recursion fails
// immediately rather than appearing as a generic Jest timeout.
const FAST_TIMEOUT = 2000;
it(
'denormalizes Values(Scalar) cells without infinite recursion',
() => {
const controller = new Contoller();
const ep = new Endpoint(
({ portfolio }: { portfolio: string }) => Promise.resolve(),
{
key: ({ portfolio }) => `getColumns ${portfolio}`,
schema: new schema.Values(PortfolioScalar),
},
);
const state = {
...initialState,
entities: {
'Scalar(portfolio)': {
'Company|1|A': { pct_equity: 0.5, shares: 100 },
'Company|2|A': { pct_equity: 0.2, shares: 40 },
},
},
endpoints: {
[ep.key({ portfolio: 'A' })]: {
'1': 'Company|1|A',
'2': 'Company|2|A',
},
},
};
let result: ReturnType<typeof controller.getResponse>;
expect(() => {
result = controller.getResponse(ep, { portfolio: 'A' }, state);
}).not.toThrow();
expect(result!.expiryStatus).toBe(ExpiryStatus.Valid);
// Critical: cells are joined, not raw cpk strings — proves
// `requiresDenormalize` returned true so denormalize ran.
expect(result!.data).toEqual({
'1': { pct_equity: 0.5, shares: 100 },
'2': { pct_equity: 0.2, shares: 40 },
});
},
FAST_TIMEOUT,
);
it(
'denormalizes Entity with Scalar field schema without infinite recursion',
() => {
const controller = new Contoller();
const ep = new Endpoint(
({ portfolio }: { portfolio: string }) => Promise.resolve(),
{
key: ({ portfolio }) => `getCompanies ${portfolio}`,
schema: [Company],
},
);
const state = {
...initialState,
entities: {
Company: {
'1': {
id: '1',
price: 100,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
},
},
'Scalar(portfolio)': {
'Company|1|A': { pct_equity: 0.5, shares: 100 },
},
},
endpoints: {
[ep.key({ portfolio: 'A' })]: ['1'],
},
};
let result: ReturnType<typeof controller.getResponse>;
expect(() => {
result = controller.getResponse(ep, { portfolio: 'A' }, state);
}).not.toThrow();
expect(result!.expiryStatus).toBe(ExpiryStatus.Valid);
const company = (result!.data as any[])[0];
expect(company.id).toBe('1');
expect(company.price).toBe(100);
expect(company.pct_equity).toBe(0.5);
expect(company.shares).toBe(100);
},
FAST_TIMEOUT,
);
});
describe('Controller.getResponse() with schema-less endpoints', () => {
it.each([
['empty string', ''],
['zero', 0],
['false', false],
['null', null],
])('treats cached %s as valid data', (_name, cachedValue) => {
const controller = new Contoller();
const endpoint = new Endpoint(() => Promise.resolve(), {
key: () => 'falsy-endpoint',
});
const key = endpoint.key();
const fetchedAt = Date.now();
const state = {
...initialState,
endpoints: {
[key]: cachedValue,
},
meta: {
[key]: {
date: fetchedAt,
fetchedAt,
expiresAt: fetchedAt + 1000,
},
},
};
const { data, expiryStatus } = controller.getResponse(endpoint, state);
expect(data).toBe(cachedValue);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
});
it('treats undefined cache entries as invalid', () => {
const controller = new Contoller();
const endpoint = new Endpoint(() => Promise.resolve(), {
key: () => 'missing-endpoint',
});
const { data, expiryStatus } = controller.getResponse(
endpoint,
initialState,
);
expect(data).toBeUndefined();
expect(expiryStatus).toBe(ExpiryStatus.Invalid);
});
});
describe('Snapshot.getResponseMeta()', () => {
it('denormalizes schema with extra members but not set', () => {
const controller = new Contoller();
class Tacos extends Entity {
type = '';
id = '';
}
const ep = new Endpoint(() => Promise.resolve(), {
key() {
return 'mytest';
},
schema: {
data: [Tacos],
extra: '',
page: {
first: null,
second: undefined,
third: 0,
complex: { complex: true, next: false },
},
},
});
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const state = {
...initialState,
entities,
endpoints: {
[ep.key()]: {
data: ['1', '2'],
},
},
};
const { data, expiryStatus } = controller
.snapshot(state)
.getResponseMeta(ep);
expect(expiryStatus).toBe(ExpiryStatus.Valid);
expect(data).toMatchSnapshot();
});
});