From 9b5dbeea34ad88d48d90f91cce3d9999f197d0f9 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Wed, 4 Jan 2023 17:02:08 +0800 Subject: [PATCH] feat(core): roles crud (#2672) --- packages/core/src/queries/roles.test.ts | 127 ++++++++++++++++++- packages/core/src/queries/roles.ts | 34 ++++- packages/core/src/routes/role.test.ts | 65 +++++++++- packages/core/src/routes/role.ts | 92 +++++++++++++- packages/phrases/src/locales/de/errors.ts | 3 + packages/phrases/src/locales/en/errors.ts | 3 + packages/phrases/src/locales/fr/errors.ts | 3 + packages/phrases/src/locales/ko/errors.ts | 3 + packages/phrases/src/locales/pt-br/errors.ts | 3 + packages/phrases/src/locales/pt-pt/errors.ts | 3 + packages/phrases/src/locales/tr-tr/errors.ts | 3 + packages/phrases/src/locales/zh-cn/errors.ts | 3 + 12 files changed, 334 insertions(+), 8 deletions(-) diff --git a/packages/core/src/queries/roles.test.ts b/packages/core/src/queries/roles.test.ts index 485b598b9..a07a8fd84 100644 --- a/packages/core/src/queries/roles.test.ts +++ b/packages/core/src/queries/roles.test.ts @@ -1,17 +1,23 @@ import { Roles } from '@logto/schemas'; -import { convertToIdentifiers } from '@logto/shared'; +import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { mockRole } from '#src/__mocks__/index.js'; import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { QueryType } from '#src/utils/test-utils.js'; import { expectSqlAssert } from '#src/utils/test-utils.js'; import { + deleteRoleById, findAllRoles, + findRoleById, findRoleByRoleName, findRolesByRoleIds, findRolesByRoleNames, + insertRole, + insertRoles, + updateRoleById, } from './roles.js'; const { jest } = import.meta; @@ -80,6 +86,24 @@ describe('roles query', () => { await expect(findRoleByRoleName(mockRole.name)).resolves.toEqual(mockRole); }); + it('findRoleByRoleName with excludeRoleId', async () => { + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.name} = ${mockRole.name} + and ${fields.id}<>${mockRole.id} + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockRole.name, mockRole.id]); + + return createMockQueryResult([mockRole]); + }); + + await expect(findRoleByRoleName(mockRole.name, mockRole.id)).resolves.toEqual(mockRole); + }); + it('findRolesByRoleNames', async () => { const roleNames = ['foo']; @@ -98,4 +122,105 @@ describe('roles query', () => { await expect(findRolesByRoleNames(roleNames)).resolves.toEqual([mockRole]); }); + + it('insertRoles', async () => { + const expectSql = sql` + insert into ${table} (${fields.id}, ${fields.name}, ${fields.description}) values + ($1, $2, $3) + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + + expect(values).toEqual([mockRole.id, mockRole.name, mockRole.description]); + + return createMockQueryResult([mockRole]); + }); + + await insertRoles([mockRole]); + }); + + it('insertRole', async () => { + const keys = excludeAutoSetFields(Roles.fieldKeys); + + const expectSql = ` + insert into "roles" ("id", "name", "description") + values (${keys.map((_, index) => `$${index + 1}`).join(', ')}) + returning * + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + const rowData = { id: 'foo' }; + expectSqlAssert(sql, expectSql); + + expect(values).toEqual(keys.map((k) => convertToPrimitiveOrSql(k, mockRole[k]))); + + return createMockQueryResult([rowData]); + }); + + await insertRole(mockRole); + }); + + it('findRoleById', async () => { + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockRole.id]); + + return createMockQueryResult([mockRole]); + }); + + await findRoleById(mockRole.id); + }); + + it('updateRoleById', async () => { + const { id, description } = mockRole; + + const expectSql = sql` + update ${table} + set ${fields.description}=$1 + where ${fields.id}=$2 + returning * + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([description, id]); + + return createMockQueryResult([{ id, description }]); + }); + + await updateRoleById(id, { description }); + }); + + it('deleteRoleById', async () => { + const expectSql = sql` + delete from ${table} + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockRole.id]); + + return createMockQueryResult([mockRole]); + }); + + await deleteRoleById(mockRole.id); + }); + + it('deleteRoleById throw error if return row count is 0', async () => { + const { id } = mockRole; + + mockQuery.mockImplementationOnce(async () => { + return createMockQueryResult([]); + }); + + await expect(deleteRoleById(id)).rejects.toMatchError(new DeletionError(Roles.table, id)); + }); }); diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 2071b6501..559fe64c0 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -1,9 +1,14 @@ -import type { Role } from '@logto/schemas'; +import type { CreateRole, Role } from '@logto/schemas'; import { Roles } from '@logto/schemas'; -import { convertToIdentifiers } from '@logto/shared'; +import type { OmitAutoSetFields } from '@logto/shared'; +import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Roles); @@ -28,11 +33,12 @@ export const findRolesByRoleNames = async (roleNames: string[]) => where ${fields.name} in (${sql.join(roleNames, sql`, `)}) `); -export const findRoleByRoleName = async (roleName: string) => +export const findRoleByRoleName = async (roleName: string, excludeRoleId?: string) => envSet.pool.maybeOne(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} where ${fields.name} = ${roleName} + ${conditionalSql(excludeRoleId, (id) => sql`and ${fields.id}<>${id}`)} `); export const insertRoles = async (roles: Role[]) => @@ -43,3 +49,25 @@ export const insertRoles = async (roles: Role[]) => sql`, ` )} `); + +export const insertRole = buildInsertInto(Roles, { + returning: true, +}); + +export const findRoleById = buildFindEntityById(Roles); + +const updateRole = buildUpdateWhere(Roles, true); + +export const updateRoleById = async (id: string, set: Partial>) => + updateRole({ set, where: { id }, jsonbMode: 'merge' }); + +export const deleteRoleById = async (id: string) => { + const { rowCount } = await envSet.pool.query(sql` + delete from ${table} + where ${fields.id}=${id} + `); + + if (rowCount < 1) { + throw new DeletionError(Roles.table, id); + } +}; diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index a15cd5bb4..34dd8d07e 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -8,9 +8,23 @@ const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); -mockEsm('#src/queries/roles.js', () => ({ - findAllRoles: jest.fn(async (): Promise => [mockRole]), -})); +const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm( + '#src/queries/roles.js', + () => ({ + findAllRoles: jest.fn(async (): Promise => [mockRole]), + findRoleByRoleName: jest.fn(async (): Promise => undefined), + insertRole: jest.fn(async (data) => ({ + ...data, + id: mockRole.id, + })), + deleteRoleById: jest.fn(), + findRoleById: jest.fn(), + updateRoleById: jest.fn(async (id, data) => ({ + ...mockRole, + ...data, + })), + }) +); const roleRoutes = await pickDefault(import('./role.js')); describe('role routes', () => { @@ -21,4 +35,49 @@ describe('role routes', () => { expect(response.status).toEqual(200); expect(response.body).toEqual([mockRole]); }); + + it('POST /roles', async () => { + const { name, description } = mockRole; + + const response = await roleRequester.post('/roles').send({ name, description }); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockRole); + expect(findRoleByRoleName).toHaveBeenCalled(); + }); + + it('GET /roles/:id', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + const response = await roleRequester.get(`/roles/${mockRole.id}`); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockRole); + }); + + describe('PATCH /roles/:id', () => { + it('updated successfully', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + const response = await roleRequester + .patch(`/roles/${mockRole.id}`) + .send({ description: 'new' }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + ...mockRole, + description: 'new', + }); + }); + + it('name conflict', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findRoleByRoleName.mockResolvedValueOnce(mockRole); + const response = await roleRequester + .patch(`/roles/${mockRole.id}`) + .send({ name: mockRole.name }); + expect(response.status).toEqual(400); + }); + }); + + it('DELETE /roles/:id', async () => { + const response = await roleRequester.delete(`/roles/${mockRole.id}`); + expect(response.status).toEqual(204); + expect(deleteRoleById).toHaveBeenCalledWith(mockRole.id); + }); }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 8043abf0f..9cb1e35c6 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,11 +1,101 @@ -import { findAllRoles } from '#src/queries/roles.js'; +import { buildIdGenerator } from '@logto/core-kit'; +import { Roles } from '@logto/schemas'; +import { object, string } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import { + deleteRoleById, + findAllRoles, + findRoleById, + findRoleByRoleName, + insertRole, + updateRoleById, +} from '#src/queries/roles.js'; +import assertThat from '#src/utils/assert-that.js'; import type { AuthedRouter } from './types.js'; +const roleId = buildIdGenerator(21); + export default function roleRoutes(router: T) { router.get('/roles', async (ctx, next) => { ctx.body = await findAllRoles(); return next(); }); + + router.post( + '/roles', + koaGuard({ + body: Roles.createGuard.omit({ id: true }), + }), + async (ctx, next) => { + const { + body, + body: { name }, + } = ctx.guard; + + assertThat(!(await findRoleByRoleName(name)), 'role.name_in_use'); + + ctx.body = await insertRole({ + ...body, + id: roleId(), + }); + + return next(); + } + ); + + router.get( + '/roles/:id', + koaGuard({ + params: object({ id: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + ctx.body = await findRoleById(id); + + return next(); + } + ); + + router.patch( + '/roles/:id', + koaGuard({ + body: Roles.createGuard.pick({ name: true, description: true }).partial(), + params: object({ id: string().min(1) }), + }), + async (ctx, next) => { + const { + body, + body: { name }, + params: { id }, + } = ctx.guard; + + await findRoleById(id); + assertThat(!name || !(await findRoleByRoleName(name, id)), 'role.name_in_use'); + ctx.body = await updateRoleById(id, body); + + return next(); + } + ); + + router.delete( + '/roles/:id', + koaGuard({ + params: object({ id: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + await deleteRoleById(id); + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 2ccf0a686..88c2c890e 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -175,6 +175,9 @@ const errors = { log: { invalid_type: 'Der Log Typ ist ungültig.', }, + role: { + name_in_use: 'This role name is already in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 574e119e1..f2252a246 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -174,6 +174,9 @@ const errors = { log: { invalid_type: 'The log type is invalid.', }, + role: { + name_in_use: 'This role name isalready in use', + }, }; export default errors; diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 2072820cd..36bf1a6fc 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -181,6 +181,9 @@ const errors = { log: { invalid_type: 'The log type is invalid.', // UNTRANSLATED }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index 8d59f3a26..5ce6e516e 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -168,6 +168,9 @@ const errors = { log: { invalid_type: '로그 종류가 유효하지 않아요.', }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/pt-br/errors.ts b/packages/phrases/src/locales/pt-br/errors.ts index de6e8a8d7..748bce7fd 100644 --- a/packages/phrases/src/locales/pt-br/errors.ts +++ b/packages/phrases/src/locales/pt-br/errors.ts @@ -182,6 +182,9 @@ const errors = { log: { invalid_type: 'O tipo de registro é inválido.', }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 4fe262255..a14806ce4 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -176,6 +176,9 @@ const errors = { log: { invalid_type: 'The log type is invalid.', // UNTRANSLATED }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 8c2740ee2..28f0ca11f 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -176,6 +176,9 @@ const errors = { log: { invalid_type: 'The log type is invalid.', // UNTRANSLATED }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors; diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index 3b97bae5b..ee7fd7b07 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -157,6 +157,9 @@ const errors = { log: { invalid_type: 'The log type is invalid.', // UNTRANSLATED }, + role: { + name_in_use: 'This role name isalready in use', // UNTRANSLATED + }, }; export default errors;