mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core): roles crud (#2672)
This commit is contained in:
parent
be69a81478
commit
9b5dbeea34
12 changed files with 334 additions and 8 deletions
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<Role>(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<CreateRole, Role>(Roles, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
export const findRoleById = buildFindEntityById<CreateRole, Role>(Roles);
|
||||
|
||||
const updateRole = buildUpdateWhere<CreateRole, Role>(Roles, true);
|
||||
|
||||
export const updateRoleById = async (id: string, set: Partial<OmitAutoSetFields<CreateRole>>) =>
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -8,9 +8,23 @@ const { jest } = import.meta;
|
|||
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
mockEsm('#src/queries/roles.js', () => ({
|
||||
findAllRoles: jest.fn(async (): Promise<Role[]> => [mockRole]),
|
||||
}));
|
||||
const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm(
|
||||
'#src/queries/roles.js',
|
||||
() => ({
|
||||
findAllRoles: jest.fn(async (): Promise<Role[]> => [mockRole]),
|
||||
findRoleByRoleName: jest.fn(async (): Promise<Role | undefined> => 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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<T extends AuthedRouter>(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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -168,6 +168,9 @@ const errors = {
|
|||
log: {
|
||||
invalid_type: '로그 종류가 유효하지 않아요.',
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue