From e63ca4c06fba2bcfbf4013a03b638479f9239505 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 24 Feb 2022 12:29:34 +0800 Subject: [PATCH] feat(core): get `/users` with search (#270) --- packages/core/src/queries/user.ts | 29 ++++++-- packages/core/src/routes/admin-user.test.ts | 42 ++++++++++-- packages/core/src/routes/admin-user.ts | 32 +++++---- packages/core/src/utils/mock.ts | 75 +++++++++++++++++++++ 4 files changed, 154 insertions(+), 24 deletions(-) diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index ef0e298ed..dfb8036c1 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -1,11 +1,10 @@ import { User, CreateUser, Users } from '@logto/schemas'; import { sql } from 'slonik'; -import { buildFindMany } from '@/database/find-many'; import { buildInsertInto } from '@/database/insert-into'; import pool from '@/database/pool'; import { buildUpdateWhere } from '@/database/update-where'; -import { convertToIdentifiers, getTotalRowCount, OmitAutoSetFields } from '@/database/utils'; +import { conditionalSql, convertToIdentifiers, OmitAutoSetFields } from '@/database/utils'; import { DeletionError } from '@/errors/SlonikError'; const { table, fields } = convertToIdentifiers(Users); @@ -86,12 +85,30 @@ export const hasUserWithIdentity = async (connectorId: string, userId: string) = export const insertUser = buildInsertInto(pool, Users, { returning: true }); -export const findTotalNumberOfUsers = async () => getTotalRowCount(table); +const buildUserSearchConditionSql = (search: string) => { + const searchFields = [fields.primaryEmail, fields.primaryPhone, fields.username, fields.name]; + const conditions = searchFields.map((filedName) => sql`${filedName} like ${'%' + search + '%'}`); -const findUserMany = buildFindMany(pool, Users); + return sql`${sql.join(conditions, sql` or `)}`; +}; -export const findAllUsers = async (limit: number, offset: number) => - findUserMany({ limit, offset }); +export const countUsers = async (search?: string) => + pool.one<{ count: number }>(sql` + select count(*) + from ${table} + ${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)} + `); + +export const findUsers = async (limit: number, offset: number, search?: string) => + pool.many( + sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + ${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)} + limit ${limit} + offset ${offset} + ` + ); const updateUser = buildUpdateWhere(pool, Users, true); diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index f22bc18de..7234ee4ad 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -1,14 +1,27 @@ -import { CreateUser, User } from '@logto/schemas'; +import { CreateUser, User, userInfoSelectFields } from '@logto/schemas'; +import pick from 'lodash.pick'; import { hasUser, findUserById } from '@/queries/user'; -import { mockUser, mockUserResponse } from '@/utils/mock'; +import { mockUser, mockUserList, mockUserListResponse, mockUserResponse } from '@/utils/mock'; import { createRequester } from '@/utils/test-utils'; import adminUserRoutes from './admin-user'; +const filterUsersWithSearch = (users: User[], search: string) => + users.filter((user) => + [user.username, user.primaryEmail, user.primaryPhone, user.name].some((value) => + value ? !value.includes(search) : false + ) + ); + jest.mock('@/queries/user', () => ({ - findTotalNumberOfUsers: jest.fn(async () => ({ count: 10 })), - findAllUsers: jest.fn(async (): Promise => [mockUser]), + countUsers: jest.fn(async (search) => ({ + count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length, + })), + findUsers: jest.fn( + async (limit, offset, search): Promise => + search ? filterUsersWithSearch(mockUserList, search) : mockUserList + ), findUserById: jest.fn(async (): Promise => mockUser), hasUser: jest.fn(async () => false), updateUserById: jest.fn( @@ -37,11 +50,28 @@ jest.mock('@/lib/user', () => ({ describe('adminUserRoutes', () => { const userRequest = createRequester({ authedRoutes: adminUserRoutes }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('GET /users', async () => { const response = await userRequest.get('/users'); expect(response.status).toEqual(200); - expect(response.body).toEqual([mockUserResponse]); - expect(response.header).toHaveProperty('total-number', '10'); + expect(response.body).toEqual(mockUserListResponse); + expect(response.header).toHaveProperty('total-number', `${mockUserList.length}`); + }); + + it('GET /users should return matched data', async () => { + const search = 'foo'; + const response = await userRequest.get('/users').send({ search }); + expect(response.status).toEqual(200); + expect(response.body).toEqual( + filterUsersWithSearch(mockUserList, search).map((user) => pick(user, ...userInfoSelectFields)) + ); + expect(response.header).toHaveProperty( + 'total-number', + `${filterUsersWithSearch(mockUserList, search).length}` + ); }); it('GET /users/:userId', async () => { diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index a341f8610..5e26a6d7a 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -11,8 +11,8 @@ import { findRolesByRoleNames } from '@/queries/roles'; import { clearUserCustomDataById, deleteUserById, - findAllUsers, - findTotalNumberOfUsers, + findUsers, + countUsers, findUserById, hasUser, insertUser, @@ -23,19 +23,27 @@ import assertThat from '@/utils/assert-that'; import { AuthedRouter } from './types'; export default function adminUserRoutes(router: T) { - router.get('/users', koaPagination(), async (ctx, next) => { - const { limit, offset } = ctx.pagination; + router.get( + '/users', + koaPagination(), + koaGuard({ query: object({ search: string().optional() }) }), + async (ctx, next) => { + const { limit, offset } = ctx.pagination; + const { + query: { search }, + } = ctx.guard; - const [{ count }, users] = await Promise.all([ - findTotalNumberOfUsers(), - findAllUsers(limit, offset), - ]); + const [{ count }, users] = await Promise.all([ + countUsers(search), + findUsers(limit, offset, search), + ]); - ctx.pagination.totalCount = count; - ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); + ctx.pagination.totalCount = count; + ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); - return next(); - }); + return next(); + } + ); router.get( '/users/:userId', diff --git a/packages/core/src/utils/mock.ts b/packages/core/src/utils/mock.ts index 71073b5e0..62224bc6e 100644 --- a/packages/core/src/utils/mock.ts +++ b/packages/core/src/utils/mock.ts @@ -30,6 +30,81 @@ export const mockUser: User = { export const mockUserResponse = pick(mockUser, ...userInfoSelectFields); +export const mockUserList: User[] = [ + { + id: '1', + username: 'foo1', + primaryEmail: 'foo1@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, + }, + { + id: '2', + username: 'foo2', + primaryEmail: 'foo2@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, + }, + { + id: '3', + username: 'foo3', + primaryEmail: 'foo3@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, + }, + { + id: '4', + username: 'bar1', + primaryEmail: 'bar1@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, + }, + { + id: '5', + username: 'bar2', + primaryEmail: 'bar2@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, + }, +]; + +export const mockUserListResponse = mockUserList.map((user) => pick(user, ...userInfoSelectFields)); + export const mockApplication: Application = { id: 'foo', name: 'foo',