0
Fork 0
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:
wangsijie 2023-01-04 17:02:08 +08:00 committed by GitHub
parent be69a81478
commit 9b5dbeea34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 334 additions and 8 deletions

View file

@ -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));
});
});

View file

@ -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);
}
};

View file

@ -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);
});
});

View file

@ -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();
}
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -168,6 +168,9 @@ const errors = {
log: {
invalid_type: '로그 종류가 유효하지 않아요.',
},
role: {
name_in_use: 'This role name isalready in use', // UNTRANSLATED
},
};
export default errors;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;