mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(core): get /users
with search (#270)
This commit is contained in:
parent
89a185c845
commit
e63ca4c06f
4 changed files with 154 additions and 24 deletions
|
@ -1,11 +1,10 @@
|
||||||
import { User, CreateUser, Users } from '@logto/schemas';
|
import { User, CreateUser, Users } from '@logto/schemas';
|
||||||
import { sql } from 'slonik';
|
import { sql } from 'slonik';
|
||||||
|
|
||||||
import { buildFindMany } from '@/database/find-many';
|
|
||||||
import { buildInsertInto } from '@/database/insert-into';
|
import { buildInsertInto } from '@/database/insert-into';
|
||||||
import pool from '@/database/pool';
|
import pool from '@/database/pool';
|
||||||
import { buildUpdateWhere } from '@/database/update-where';
|
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';
|
import { DeletionError } from '@/errors/SlonikError';
|
||||||
|
|
||||||
const { table, fields } = convertToIdentifiers(Users);
|
const { table, fields } = convertToIdentifiers(Users);
|
||||||
|
@ -86,12 +85,30 @@ export const hasUserWithIdentity = async (connectorId: string, userId: string) =
|
||||||
|
|
||||||
export const insertUser = buildInsertInto<CreateUser, User>(pool, Users, { returning: true });
|
export const insertUser = buildInsertInto<CreateUser, User>(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<CreateUser, User>(pool, Users);
|
return sql`${sql.join(conditions, sql` or `)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const findAllUsers = async (limit: number, offset: number) =>
|
export const countUsers = async (search?: string) =>
|
||||||
findUserMany({ limit, offset });
|
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<User>(
|
||||||
|
sql`
|
||||||
|
select ${sql.join(Object.values(fields), sql`,`)}
|
||||||
|
from ${table}
|
||||||
|
${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)}
|
||||||
|
limit ${limit}
|
||||||
|
offset ${offset}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
const updateUser = buildUpdateWhere<CreateUser, User>(pool, Users, true);
|
const updateUser = buildUpdateWhere<CreateUser, User>(pool, Users, true);
|
||||||
|
|
||||||
|
|
|
@ -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 { 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 { createRequester } from '@/utils/test-utils';
|
||||||
|
|
||||||
import adminUserRoutes from './admin-user';
|
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', () => ({
|
jest.mock('@/queries/user', () => ({
|
||||||
findTotalNumberOfUsers: jest.fn(async () => ({ count: 10 })),
|
countUsers: jest.fn(async (search) => ({
|
||||||
findAllUsers: jest.fn(async (): Promise<User[]> => [mockUser]),
|
count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length,
|
||||||
|
})),
|
||||||
|
findUsers: jest.fn(
|
||||||
|
async (limit, offset, search): Promise<User[]> =>
|
||||||
|
search ? filterUsersWithSearch(mockUserList, search) : mockUserList
|
||||||
|
),
|
||||||
findUserById: jest.fn(async (): Promise<User> => mockUser),
|
findUserById: jest.fn(async (): Promise<User> => mockUser),
|
||||||
hasUser: jest.fn(async () => false),
|
hasUser: jest.fn(async () => false),
|
||||||
updateUserById: jest.fn(
|
updateUserById: jest.fn(
|
||||||
|
@ -37,11 +50,28 @@ jest.mock('@/lib/user', () => ({
|
||||||
describe('adminUserRoutes', () => {
|
describe('adminUserRoutes', () => {
|
||||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes });
|
const userRequest = createRequester({ authedRoutes: adminUserRoutes });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /users', async () => {
|
it('GET /users', async () => {
|
||||||
const response = await userRequest.get('/users');
|
const response = await userRequest.get('/users');
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
expect(response.body).toEqual([mockUserResponse]);
|
expect(response.body).toEqual(mockUserListResponse);
|
||||||
expect(response.header).toHaveProperty('total-number', '10');
|
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 () => {
|
it('GET /users/:userId', async () => {
|
||||||
|
|
|
@ -11,8 +11,8 @@ import { findRolesByRoleNames } from '@/queries/roles';
|
||||||
import {
|
import {
|
||||||
clearUserCustomDataById,
|
clearUserCustomDataById,
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
findAllUsers,
|
findUsers,
|
||||||
findTotalNumberOfUsers,
|
countUsers,
|
||||||
findUserById,
|
findUserById,
|
||||||
hasUser,
|
hasUser,
|
||||||
insertUser,
|
insertUser,
|
||||||
|
@ -23,19 +23,27 @@ import assertThat from '@/utils/assert-that';
|
||||||
import { AuthedRouter } from './types';
|
import { AuthedRouter } from './types';
|
||||||
|
|
||||||
export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
||||||
router.get('/users', koaPagination(), async (ctx, next) => {
|
router.get(
|
||||||
const { limit, offset } = ctx.pagination;
|
'/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([
|
const [{ count }, users] = await Promise.all([
|
||||||
findTotalNumberOfUsers(),
|
countUsers(search),
|
||||||
findAllUsers(limit, offset),
|
findUsers(limit, offset, search),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ctx.pagination.totalCount = count;
|
ctx.pagination.totalCount = count;
|
||||||
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
|
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/users/:userId',
|
'/users/:userId',
|
||||||
|
|
|
@ -30,6 +30,81 @@ export const mockUser: User = {
|
||||||
|
|
||||||
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
|
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 = {
|
export const mockApplication: Application = {
|
||||||
id: 'foo',
|
id: 'foo',
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
|
|
Loading…
Add table
Reference in a new issue