UNPKG

@data-client/core

Version:

Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch

413 lines (385 loc) 11.3 kB
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(); }); });