diff --git a/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.js b/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.js new file mode 100644 index 00000000..24e1e2f5 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.js @@ -0,0 +1,23 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import User from '../../../../models/user.js'; + +export default async (request, response) => { + const user = await User.query().insertAndFetch(userParams(request)); + await user.sendInvitationEmail(); + + renderObject(response, user, { + status: 201, + serializer: 'PublicUserInvitation', + }); +}; + +const userParams = (request) => { + const { fullName, email, roleId } = request.body; + + return { + fullName, + status: 'invited', + email: email?.toLowerCase(), + roleId, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.test.js b/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.test.js new file mode 100644 index 00000000..0a99f65a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/user-invitations/create-user-invitation.ee.test.js @@ -0,0 +1,92 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApiToken } from '../../../../../test/factories/api-token.js'; +import { createRole } from '../../../../../test/factories/role.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import createUserMock from '../../../../../test/mocks/rest/api/v1/user-invitations/create-user-invitation.js'; +import app from '../../../../app.js'; +import * as license from '../../../../helpers/license.ee.js'; +import User from '../../../../models/user.js'; + +describe('POST /api/v1/user-invitations', () => { + let token, roleId; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + roleId = (await createRole({ name: 'User' })).id; + token = (await createApiToken()).token; + }); + + it('should return created user with valid data', async () => { + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + roleId, + }; + + const response = await request(app) + .post('/api/v1/user-invitations') + .set('x-api-token', token) + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const expectedPayload = createUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedRegisteredUser.roleId).toStrictEqual(roleId); + }); + + it('should return unprocessable entity response with already used email', async () => { + await createUser({ + email: 'created@sample.com', + }); + + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + roleId, + }; + + const response = await request(app) + .post('/api/v1/user-invitations') + .set('x-api-token', token) + .send(userData) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + email: ["'email' must be unique."], + }); + + expect(response.body.meta).toStrictEqual({ + type: 'UniqueViolationError', + }); + }); + + it('should return unprocessable entity response with invalid user data', async () => { + const userData = { + email: null, + fullName: null, + roleId: null, + }; + + const response = await request(app) + .post('/api/v1/user-invitations') + .set('x-api-token', token) + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toStrictEqual({ + email: ["must have required property 'email'"], + fullName: ['must be string'], + roleId: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/routes/api/v1/user-invitations.ee.js b/packages/backend/src/routes/api/v1/user-invitations.ee.js index ea14eab5..53129156 100644 --- a/packages/backend/src/routes/api/v1/user-invitations.ee.js +++ b/packages/backend/src/routes/api/v1/user-invitations.ee.js @@ -1,10 +1,12 @@ import { Router } from 'express'; import getUserInvitationsAction from '../../../controllers/api/v1/user-invitations/get-user-invitations.ee.js'; +import createUserInvitationAction from '../../../controllers/api/v1/user-invitations/create-user-invitation.ee.js'; import deleteUserInvitationAction from '../../../controllers/api/v1/user-invitations/delete-user-invitation.ee.js'; const router = Router(); router.get('/', getUserInvitationsAction); +router.post('/', createUserInvitationAction); router.delete('/:userId', deleteUserInvitationAction); export default router; diff --git a/packages/backend/src/serializers/public-user-invitation.ee.js b/packages/backend/src/serializers/public-user-invitation.ee.js index 113837b7..3511857d 100644 --- a/packages/backend/src/serializers/public-user-invitation.ee.js +++ b/packages/backend/src/serializers/public-user-invitation.ee.js @@ -8,6 +8,7 @@ const publicUserInvitationSerializer = (user) => { updatedAt: user.updatedAt.getTime(), status: user.status, fullName: user.fullName, + acceptInvitationUrl: user.acceptInvitationUrl, }; if (user.role) { diff --git a/packages/backend/src/serializers/public-user-invitation.ee.test.js b/packages/backend/src/serializers/public-user-invitation.ee.test.js index 41e3019a..c6c5b1ec 100644 --- a/packages/backend/src/serializers/public-user-invitation.ee.test.js +++ b/packages/backend/src/serializers/public-user-invitation.ee.test.js @@ -22,6 +22,7 @@ describe('publicUserInvitation', () => { id: user.id, status: user.status, updatedAt: user.updatedAt.getTime(), + acceptInvitationUrl: user.acceptInvitationUrl, }; expect(publicUserInvitationSerializer(user)).toStrictEqual(expectedPayload); diff --git a/packages/backend/test/mocks/rest/api/v1/user-invitations/create-user-invitation.js b/packages/backend/test/mocks/rest/api/v1/user-invitations/create-user-invitation.js new file mode 100644 index 00000000..223ae15b --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/user-invitations/create-user-invitation.js @@ -0,0 +1,24 @@ +const createUserInvitationMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + acceptInvitationUrl: user.acceptInvitationUrl, + }; + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default createUserInvitationMock; diff --git a/packages/backend/test/mocks/rest/api/v1/user-invitations/get-user-invitations.js b/packages/backend/test/mocks/rest/api/v1/user-invitations/get-user-invitations.js index 7c069084..5f6fb095 100644 --- a/packages/backend/test/mocks/rest/api/v1/user-invitations/get-user-invitations.js +++ b/packages/backend/test/mocks/rest/api/v1/user-invitations/get-user-invitations.js @@ -19,6 +19,7 @@ const getUserInvitationsAction = async (users, roles) => { : null, status: user.status, updatedAt: user.updatedAt.getTime(), + acceptInvitationUrl: user.acceptInvitationUrl, }; }); diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index b201f322..911f8e03 100644 --- a/packages/backend/vitest.config.js +++ b/packages/backend/vitest.config.js @@ -30,7 +30,7 @@ export default defineConfig({ autoUpdate: true, statements: 99.44, branches: 98.41, - functions: 99.09, + functions: 99.1, lines: 99.44, }, },