0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(core): add unit tests for SchemaRouter

This commit is contained in:
Gao Sun 2023-10-09 18:18:38 +08:00
parent bb22ce3acc
commit 8e931d757f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
3 changed files with 199 additions and 16 deletions

View file

@ -13,12 +13,14 @@ export default function organizationRoutes<T extends AuthedRouter>(
]: RouterInitArgs<T>
) {
const router = new SchemaRouter(Organizations, {
findAll: async ({ limit, offset }) =>
get: async ({ limit, offset }) =>
Promise.all([organizations.findTotalNumber(), organizations.findAll(limit, offset)]),
findById: organizations.findById,
insert: organizations.insert,
updateGuard: Organizations.guard.omit({ id: true, createdAt: true }).partial(),
updateById: organizations.updateById,
getById: organizations.findById,
post: organizations.insert,
patchById: {
guard: Organizations.guard.omit({ id: true, createdAt: true }).partial(),
run: organizations.updateById,
},
deleteById: organizations.deleteById,
});

View file

@ -0,0 +1,121 @@
import { type GeneratedSchema } from '@logto/schemas';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import SchemaRouter, { type SchemaActions } from './SchemaRouter.js';
import { createRequester } from './test-utils.js';
const { jest } = import.meta;
type CreateSchema = {
id?: string;
};
type Schema = {
id: string;
};
describe('SchemaRouter', () => {
const schema: GeneratedSchema<CreateSchema, Schema> = {
table: 'test_table',
tableSingular: 'test_table',
fields: {
id: 'id',
},
fieldKeys: ['id'],
createGuard: z.object({ id: z.string().optional() }),
guard: z.object({ id: z.string() }),
};
const entities = [{ id: 'test' }, { id: 'test2' }] as const satisfies readonly Schema[];
const actions: SchemaActions<CreateSchema, Schema, CreateSchema> = {
get: jest.fn().mockResolvedValue([entities.length, entities]),
getById: jest.fn(async (id) => {
const entity = entities.find((entity) => entity.id === id);
if (!entity) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
return entity;
}),
post: jest.fn(async () => ({ id: 'test_new' })),
patchById: {
guard: z.object({ id: z.string().optional() }),
run: jest.fn(async (id, data) => ({ id, ...data })),
},
deleteById: jest.fn(),
};
const schemaRouter = new SchemaRouter(schema, actions);
const request = createRequester({ authedRoutes: (router) => router.use(schemaRouter.routes()) });
const baseRoute = `/${schema.table.replaceAll('_', '-')}`;
beforeEach(() => {
jest.clearAllMocks();
});
describe('get', () => {
it('should be able to get all entities', async () => {
const response = await request.get(baseRoute);
expect(actions.get).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));
expect(response.body).toStrictEqual(entities);
});
it('should be able to get all entities with pagination', async () => {
const response = await request.get(`${baseRoute}?page=1&page_size=10`);
expect(actions.get).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, limit: 10 }));
expect(response.body).toStrictEqual(entities);
expect(response.header).toHaveProperty('total-number', '2');
});
});
describe('post', () => {
it('should be able to create an entity', async () => {
const response = await request.post(baseRoute).send({});
expect(actions.post).toHaveBeenCalledWith({});
expect(response.body).toStrictEqual({ id: 'test_new' });
});
it('should throw with invalid input body', async () => {
const response = await request.post(baseRoute).send({ id: 1 });
expect(response.status).toEqual(400);
});
});
describe('getById', () => {
it('should be able to get an entity by id', async () => {
const response = await request.get(`${baseRoute}/test`);
expect(actions.getById).toHaveBeenCalledWith('test');
expect(response.body).toStrictEqual(entities[0]);
});
// This test case is actually nice-to-have. It's not required for the router to work.
it('should throw with invalid id', async () => {
const response = await request.get(`${baseRoute}/2`);
expect(response.status).toEqual(404);
});
});
describe('patchById', () => {
it('should be able to patch an entity by id', async () => {
const response = await request.patch(`${baseRoute}/test`).send({ id: 'test_new' });
expect(actions.patchById.run).toHaveBeenCalledWith('test', { id: 'test_new' });
expect(response.body).toStrictEqual({ id: 'test_new' });
expect(response.status).toEqual(200);
});
});
describe('deleteById', () => {
it('should be able to delete an entity by id', async () => {
const response = await request.delete(`${baseRoute}/test`);
expect(actions.deleteById).toHaveBeenCalledWith('test');
expect(response.status).toEqual(204);
});
});
});

View file

@ -5,19 +5,79 @@ import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js';
type SchemaActions<
/**
* Actions configuration for a {@link SchemaRouter}.
*/
export type SchemaActions<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
UpdateSchema extends Partial<Schema>,
> = {
findAll: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>;
findById: (id: string) => Promise<Readonly<Schema>>;
insert: (data: CreateSchema) => Promise<Readonly<Schema>>;
updateGuard: z.ZodType<UpdateSchema>;
updateById: (id: string, data: UpdateSchema) => Promise<Readonly<Schema>>;
/**
* The function for `GET /` route to get a list of entities.
*
* @param pagination The request pagination info parsed from `koa-pagination`. If
* pagination is disabled, the function should return all entities.
* @returns A tuple of `[count, entities]`. `count` is the total count of entities
* in the database; `entities` is the list of entities to be returned.
*/
get: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>;
/**
* The function for `GET /:id` route to get an entity by ID.
*
* @param id The ID of the entity to be fetched.
* @returns The entity to be returned.
*/
getById: (id: string) => Promise<Readonly<Schema>>;
/**
* The function for `POST /` route to create an entity.
*
* @param data The data of the entity to be created.
* @returns The created entity.
*/
post: (data: CreateSchema) => Promise<Readonly<Schema>>;
/**
* The configuration for `PATCH /:id` route to update an entity by ID.
* It contains a `guard` for the request body and a `run` function to update the entity.
*/
patchById: {
/**
* The guard for the request body. You can specify a partial schema and disable
* some fields for business logic reasons.
*/
guard: z.ZodType<UpdateSchema>;
/**
* The function to update the entity by ID.
*
* @param id The ID of the entity to be updated.
* @param data The data of the entity to be updated.
* @returns The updated entity.
*/
run: (id: string, data: UpdateSchema) => Promise<Readonly<Schema>>;
};
/**
* The function for `DELETE /:id` route to delete an entity by ID.
*
* @param id The ID of the entity to be deleted.
*/
deleteById: (id: string) => Promise<void>;
};
/**
* A standard RESTful router for a schema.
*
* It provides the following routes by configuring the `actions`:
*
* - `GET /`: Get a list of entities.
* - `POST /`: Create an entity.
* - `GET /:id`: Get an entity by ID.
* - `PATCH /:id`: Update an entity by ID.
* - `DELETE /:id`: Delete an entity by ID.
*
* Browse the source code for more details about request/response validation.
*
* @see {@link SchemaActions} for the `actions` configuration.
*/
export default class SchemaRouter<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
@ -36,7 +96,7 @@ export default class SchemaRouter<
koaPagination({ isOptional: true }),
koaGuard({ response: schema.guard.array(), status: [200] }),
async (ctx, next) => {
const [count, entities] = await actions.findAll(ctx.pagination);
const [count, entities] = await actions.get(ctx.pagination);
ctx.pagination.totalCount = count;
ctx.body = entities;
return next();
@ -51,7 +111,7 @@ export default class SchemaRouter<
status: [201, 422],
}),
async (ctx, next) => {
ctx.body = await actions.insert(ctx.guard.body);
ctx.body = await actions.post(ctx.guard.body);
ctx.status = 201;
return next();
}
@ -65,7 +125,7 @@ export default class SchemaRouter<
status: [200, 404],
}),
async (ctx, next) => {
ctx.body = await actions.findById(ctx.guard.params.id);
ctx.body = await actions.getById(ctx.guard.params.id);
return next();
}
);
@ -74,12 +134,12 @@ export default class SchemaRouter<
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: actions.updateGuard,
body: actions.patchById.guard,
response: schema.guard,
status: [200, 404],
}),
async (ctx, next) => {
void actions.updateById(ctx.guard.params.id, ctx.guard.body);
ctx.body = await actions.patchById.run(ctx.guard.params.id, ctx.guard.body);
return next();
}
);