diff --git a/packages/client/src/crud/CRUD.test.tsx b/packages/client/src/crud/CRUD.test.tsx new file mode 100644 index 0000000..dfe0794 --- /dev/null +++ b/packages/client/src/crud/CRUD.test.tsx @@ -0,0 +1,156 @@ +import React from 'react' +import {AnyAction} from 'redux' +import {CRUDActions, CRUDReducer} from './' +import {HTTPClientMock, TestUtils, getError} from '../test-utils' + +describe('CRUD', () => { + + interface ITwo { + id: number + name: string + } + + interface ITwoCreateBody { + name: string + } + + interface ITwoListParams { + oneId: number + } + + interface ITwoSpecificParams { + oneId: number + twoId: number + } + + interface ITestAPI { + '/one/:oneId/two/:twoId': { + get: { + params: ITwoSpecificParams + body: ITwoCreateBody + response: ITwo + } + put: { + params: ITwoSpecificParams + body: ITwoCreateBody + response: ITwo + } + delete: { + params: ITwoSpecificParams + response: {id: number} + } + } + '/one/:oneId/two': { + get: { + params: ITwoListParams + response: ITwo[] + } + post: { + params: ITwoListParams + body: ITwoCreateBody + response: ITwo + } + } + } + + const http = new HTTPClientMock() + const actions = CRUDActions.fromTwoRoutes({ + http, + listRoute: '/one/:oneId/two', + specificRoute: '/one/:oneId/two/:twoId', + actionName: 'TEST', + }) + const Crud = new CRUDReducer('TEST').reduce + + const test = new TestUtils() + const reducer = test.combineReducers({ + Crud, + Logger: (state: string[] = [], action: AnyAction): string[] => { + return [...state, action.type] + }, + }) + function getStore() { + return test.createStore({reducer})() + } + + afterEach(() => { + http.mockClear() + }) + + describe('init', () => { + it('should not fail', () => { + getStore() + }) + }) + + describe('GET_MANY', () => { + + function getAction(store: ReturnType) { + const action = store.dispatch(actions.getMany({ + query: {}, + params: {oneId: 1}, + })) + const state = store.getState() + expect(state.Crud.status.getMany.isLoading).toBe(true) + expect(state.Logger).toEqual([ + jasmine.any(String), + 'TEST_GET_MANY_PENDING', + ]) + return action + } + + describe('TEST_GET_MANY_RESOLVED', () => { + beforeEach(() => { + http.mockAdd({ + method: 'get', + url: '/one/1/two', + params: {}, + }, [{id: 2, name: 'bla'}]) + }) + + it('updates state', async () => { + const store = getStore() + const action = getAction(store) + await action.payload + const state = store.getState() + expect(state.Crud.status.getMany.isLoading).toBe(false) + expect(state.Crud.ids).toEqual([2]) + expect(state.Crud.byId[2]).toEqual({id: 2, name: 'bla'}) + expect(state.Logger).toEqual([ + jasmine.any(String), + 'TEST_GET_MANY_PENDING', + 'TEST_GET_MANY_RESOLVED', + ]) + }) + }) + + describe('TEST_GET_MANY_REJECTED', () => { + beforeEach(() => { + http.mockAdd({ + method: 'get', + url: '/one/1/two', + params: {}, + }, {error: 'Test Error'}, 400) + }) + + it('updates state', async () => { + const store = getStore() + const action = getAction(store) + await getError(action.payload) + const state = store.getState() + expect(state.Crud.status.getMany.isLoading).toBe(false) + // TODO use error from response + expect(state.Crud.status.getMany.error).toBe('HTTP Status: 400') + expect(state.Crud.ids).toEqual([]) + expect(state.Crud.byId).toEqual({}) + expect(state.Logger).toEqual([ + jasmine.any(String), + 'TEST_GET_MANY_PENDING', + 'TEST_GET_MANY_REJECTED', + ]) + }) + }) + + }) + +}) diff --git a/packages/client/src/crud/CRUDActions.ts b/packages/client/src/crud/CRUDActions.ts index 05609ce..3610f3d 100644 --- a/packages/client/src/crud/CRUDActions.ts +++ b/packages/client/src/crud/CRUDActions.ts @@ -1,7 +1,7 @@ import {IRoutes} from '@rondo/common' import {IHTTPClient, ITypedRequestParams} from '../http' -interface IActionTypes { +interface ICRUDActionTypes { readonly get: string readonly put: string readonly post: string @@ -11,21 +11,21 @@ interface IActionTypes { export class CRUDActions< T extends IRoutes, + POST extends keyof T & string, + GET_MANY extends keyof T & string, GET extends keyof T & string, PUT extends keyof T & string, - POST extends keyof T & string, DELETE extends keyof T & string, - GET_MANY extends keyof T & string, > { - readonly actionTypes: IActionTypes + readonly actionTypes: ICRUDActionTypes constructor( readonly http: IHTTPClient, + readonly postRoute: POST, + readonly getManyRoute: GET_MANY, readonly getRoute: GET, readonly putRoute: PUT, - readonly postRoute: POST, readonly deleteRoute: DELETE, - readonly getManyRoute: GET_MANY, readonly actionName: string, ) { this.actionTypes = this.getActionTypes() @@ -34,26 +34,27 @@ export class CRUDActions< static fromTwoRoutes< R, S extends keyof R & string, - P extends keyof R & string, + L extends keyof R & string, >(params: { - http: IHTTPClient, - singular: S, - plural: P, - actionName: string - }) { - const {http, singular, plural, actionName} = params - return new CRUDActions( + http: IHTTPClient, + specificRoute: S, + listRoute: L, + actionName: string, + }, + ) { + const {http, specificRoute, listRoute, actionName} = params + return new CRUDActions( http, - singular, - singular, - plural, - singular, - plural, + listRoute, + listRoute, + specificRoute, + specificRoute, + specificRoute, actionName, ) } - getActionTypes(): IActionTypes { + getActionTypes(): ICRUDActionTypes { const {actionName} = this return { get: actionName + '_GET_PENDING', @@ -64,7 +65,7 @@ export class CRUDActions< } } - async get(params: { + get(params: { query: T[GET]['get']['query'], params: T[GET]['get']['params'], }) { @@ -74,7 +75,7 @@ export class CRUDActions< } } - async post(params: { + post(params: { body: T[POST]['post']['body'], params: T[POST]['post']['params'], }) { @@ -84,7 +85,7 @@ export class CRUDActions< } } - async put(params: { + put(params: { body: T[PUT]['put']['body'], params: T[PUT]['put']['params'], }) { @@ -94,7 +95,7 @@ export class CRUDActions< } } - async delete(params: { + delete(params: { body: T[DELETE]['delete']['body'], params: T[DELETE]['delete']['params'], }) { @@ -104,7 +105,7 @@ export class CRUDActions< } } - async getMany(params: { + getMany(params: { query: T[GET_MANY]['get']['query'], params: T[GET_MANY]['get']['params'], }) { diff --git a/packages/client/src/crud/CRUDReducer.ts b/packages/client/src/crud/CRUDReducer.ts index 027493e..1344ec5 100644 --- a/packages/client/src/crud/CRUDReducer.ts +++ b/packages/client/src/crud/CRUDReducer.ts @@ -1,8 +1,7 @@ import {IAction} from '../actions' import {indexBy, without} from '@rondo/common' -export type ICRUDMethod = - 'put' | 'post' | 'delete' | 'get' | 'getMany' +export type ICRUDMethod = 'put' | 'post' | 'delete' | 'get' | 'getMany' export interface ICRUDIdable { readonly id: number @@ -44,7 +43,7 @@ export class CRUDReducer { readonly actionTypes: ReturnType['getActionTypes']> constructor( - readonly actions: ICRUDActions, + readonly actionName: string, readonly pendingExtension = '_PENDING', readonly resolvedExtension = '_RESOLVED', readonly rejectedExtension = '_REJECTED', @@ -77,10 +76,10 @@ export class CRUDReducer { }, } - this.actionTypes = this.getActionTypes() + this.actionTypes = this.getActionTypes(actionName) } - getPromiseActionNames(type: string) { + protected getPromiseActionNames(type: string) { return { pending: type + this.pendingExtension, resolved: type + this.resolvedExtension, @@ -88,18 +87,17 @@ export class CRUDReducer { } } - getActionTypes() { - const {actions} = this + protected getActionTypes(actionName: string) { return { - put: this.getPromiseActionNames(actions.put), - post: this.getPromiseActionNames(actions.post), - delete: this.getPromiseActionNames(actions.delete), - get: this.getPromiseActionNames(actions.get), - getMany: this.getPromiseActionNames(actions.getMany), + put: this.getPromiseActionNames(actionName + '_PUT'), + post: this.getPromiseActionNames(actionName + '_POST'), + delete: this.getPromiseActionNames(actionName + '_DELETE'), + get: this.getPromiseActionNames(actionName + '_GET'), + getMany: this.getPromiseActionNames(actionName + '_GET_MANY'), } } - getUpdatedStatus( + protected getUpdatedStatus( state: ICRUDStatus, method: ICRUDMethod, status: ICRUDMethodStatus, @@ -110,7 +108,7 @@ export class CRUDReducer { } } - getMethod(actionType: string): ICRUDMethod { + protected getMethod(actionType: string): ICRUDMethod { const {get, put, post, delete: _delete, getMany} = this.actionTypes switch (actionType) { case get.pending: @@ -133,14 +131,14 @@ export class CRUDReducer { } } - getSuccessStatus(): ICRUDMethodStatus { + protected getSuccessStatus(): ICRUDMethodStatus { return { isLoading: false, error: '', } } - reduce = (state: ICRUDState, action: ICRUDAction) + reduce = (state: ICRUDState | undefined, action: ICRUDAction) : ICRUDState => { const {defaultState} = this state = state || defaultState diff --git a/packages/client/src/crud/index.ts b/packages/client/src/crud/index.ts new file mode 100644 index 0000000..9bd6f62 --- /dev/null +++ b/packages/client/src/crud/index.ts @@ -0,0 +1,2 @@ +export * from './CRUDActions' +export * from './CRUDReducer' diff --git a/packages/client/src/test-utils/HTTPClientMock.ts b/packages/client/src/test-utils/HTTPClientMock.ts index 70fc731..5a04182 100644 --- a/packages/client/src/test-utils/HTTPClientMock.ts +++ b/packages/client/src/test-utils/HTTPClientMock.ts @@ -48,7 +48,9 @@ export class HTTPClientMock extends HTTPClient { const key = this.serialize(req) if (!this.mocks.hasOwnProperty(key)) { setImmediate(() => { - const err = new Error('No mock for request: ' + key) + const err = new Error( + 'No mock for request: ' + key + '\nAvailable mocks:' + + Object.keys(this.mocks)) reject(err) currentRequest.finished = true this.notify(err) @@ -124,6 +126,9 @@ export class HTTPClientMock extends HTTPClient { * expect(req).toEqual({method:'get', url:'/auth/post', data: {...}}) */ async wait(): Promise { + if (this.requests.every(r => r.finished)) { + throw new Error('No requests to wait for') + } expect(this.waitPromise).toBe(undefined) const result: IReqRes = await new Promise((resolve, reject) => { this.waitPromise = {resolve, reject}