Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(form-workspaces-be/2): add get workspaces functionality #4247

Merged
1 change: 0 additions & 1 deletion shared/types/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export type WorkspaceId = Opaque<string, 'WorkspaceId'>
export type Workspace = {
_id: WorkspaceId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought this was the wrong type to add the _id in. I've reverted this change

title: string
count: number
formIds: FormId[]
admin: UserId
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/models/__tests__/workspace.server.model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const Workspace = getWorkspaceModel(mongoose)
const MOCK_USER_ID = new ObjectId()
const MOCK_FORM_ID = new ObjectId()
const MOCK_WORKSPACE_ID = new ObjectId()
const MOCK_WORKSPACE_FIELDS = {
const MOCK_WORKSPACE_DOC = {
_id: MOCK_WORKSPACE_ID,
title: 'Workspace1',
admin: MOCK_USER_ID,
Expand All @@ -29,7 +29,7 @@ describe('Workspace Model', () => {
userId: MOCK_USER_ID,
})

await Workspace.create(MOCK_WORKSPACE_FIELDS)
await Workspace.create(MOCK_WORKSPACE_DOC)
FORM_ADMIN_USER = adminUser
})
afterEach(async () => await dbHandler.clearDatabase())
Expand Down
14 changes: 12 additions & 2 deletions src/app/models/workspace.server.model.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Mongoose, Schema } from 'mongoose'

import { IWorkspaceModel, IWorkspaceSchema } from '../../types'
import { IUserSchema, IWorkspaceModel, IWorkspaceSchema } from '../../types'

export const WORKSPACE_SCHEMA_ID = 'Workspace'

const compileWorkspaceModel = (db: Mongoose): IWorkspaceModel => {
const schemaOptions = {
id: false,
timestamps: true,
}
const WorkspaceSchema = new Schema<IWorkspaceSchema, IWorkspaceModel>(
{
title: {
Expand All @@ -29,13 +33,19 @@ const compileWorkspaceModel = (db: Mongoose): IWorkspaceModel => {
message: "Failed to update workspace document's formIds",
},
},
{ timestamps: true },
schemaOptions,
)

WorkspaceSchema.index({
admin: 1,
})

WorkspaceSchema.statics.getWorkspaces = async function (
admin: IUserSchema['_id'],
) {
return this.find({ admin: admin }).sort('title').exec()
}

return db.model<IWorkspaceSchema, IWorkspaceModel>(
WORKSPACE_SCHEMA_ID,
WorkspaceSchema,
Expand Down
46 changes: 46 additions & 0 deletions src/app/modules/workspace/__tests__/workspace.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { errAsync, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import * as WorkspaceService from 'src/app/modules/workspace/workspace.service'

import expressHandler from 'tests/unit/backend/helpers/jest-express'

import { DatabaseError } from '../../core/core.errors'
import * as WorkspaceController from '../workspace.controller'

jest.mock('../workspace.service')
const MockWorkspaceService = mocked(WorkspaceService)

describe('workspace.controller', () => {
beforeEach(() => jest.clearAllMocks())

describe('getWorkspaces', () => {
const MOCK_REQ = expressHandler.mockRequest({
session: {
user: {
_id: 'exists',
},
},
})

it('should return 200 with an array of workspaces', async () => {
const mockRes = expressHandler.mockResponse()
MockWorkspaceService.getWorkspaces.mockReturnValueOnce(okAsync([]))
await WorkspaceController.getWorkspaces(MOCK_REQ, mockRes, jest.fn())

expect(mockRes.json).toHaveBeenCalledWith([])
})

it('should return 500 when database error occurs', async () => {
const mockRes = expressHandler.mockResponse()
const mockErrorString = 'something went wrong'
MockWorkspaceService.getWorkspaces.mockReturnValueOnce(
errAsync(new DatabaseError(mockErrorString)),
)
await WorkspaceController.getWorkspaces(MOCK_REQ, mockRes, jest.fn())

expect(mockRes.status).toBeCalledWith(500)
expect(mockRes.json).toBeCalledWith({ message: mockErrorString })
})
})
})
112 changes: 112 additions & 0 deletions src/app/modules/workspace/__tests__/workspace.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { ObjectId } from 'bson-ext'
import mongoose from 'mongoose'
import supertest, { Session } from 'supertest-session'

import { getWorkspaceModel } from 'src/app/models/workspace.server.model'
import { WorkspacesRouter } from 'src/app/routes/api/v3/admin/workspaces'

import {
createAuthedSession,
logoutSession,
} from 'tests/integration/helpers/express-auth'
import { setupApp } from 'tests/integration/helpers/express-setup'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data'

const WorkspaceModel = getWorkspaceModel(mongoose)

const app = setupApp('/workspaces', WorkspacesRouter, {
setupWithAuth: true,
})

const MOCK_USER_ID = new ObjectId()
const MOCK_FORM_ID = new ObjectId()
const MOCK_WORKSPACE_ID = new ObjectId()
const MOCK_WORKSPACE_DOC = {
_id: MOCK_WORKSPACE_ID,
title: 'Workspace1',
admin: MOCK_USER_ID,
formIds: [],
}

describe('workspaces.routes', () => {
let request: Session

beforeAll(async () => await dbHandler.connect())
beforeEach(async () => {
request = supertest(app)
const { user } = await dbHandler.insertEncryptForm({
formId: MOCK_FORM_ID,
userId: MOCK_USER_ID,
})
request = await createAuthedSession(user.email, request)
})
afterEach(async () => {
await dbHandler.clearDatabase()
jest.restoreAllMocks()
})
afterAll(async () => await dbHandler.closeDatabase())

describe('GET /workspaces', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we test the ordering of workspaces as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I've added test for workspace ordering as well

const GET_WORKSPACES_ENDPOINT = '/workspaces'

it('should return 200 with an empty array when a user has no workspaces', async () => {
const response = await request.get('/workspaces')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should this use the GET_WORKSPACES_ENDPOINT constant too?


expect(response.status).toEqual(200)
expect(response.body).toEqual([])
})

it("should return 200 with an array of the user's workspaces sorted by title", async () => {
const workspaceIds = [
MOCK_WORKSPACE_DOC._id,
new ObjectId(),
new ObjectId(),
]
const workspaceDocs = [
{
_id: workspaceIds[1],
title: 'aSecondInOrder',
admin: MOCK_USER_ID,
formIds: [],
},
{
_id: workspaceIds[2],
title: 'bThirdInOrder',
admin: MOCK_USER_ID,
formIds: [],
},
MOCK_WORKSPACE_DOC,
]
await WorkspaceModel.insertMany(workspaceDocs)
const response = await request.get(GET_WORKSPACES_ENDPOINT)
const expected = await WorkspaceModel.find({ _id: { $in: workspaceIds } })
const expectedWithVirtuals = expected.map((workspace) =>
workspace.toJSON(),
)

expect(response.status).toEqual(200)
expect(response.body).toEqual(jsonParseStringify(expectedWithVirtuals))
})

it('should return 401 when user is not logged in', async () => {
await logoutSession(request)
const response = await request.get(GET_WORKSPACES_ENDPOINT)

expect(response.status).toEqual(401)
expect(response.body).toEqual({ message: 'User is unauthorized.' })
})

it('should return 500 when database errors occur', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can test for the other types of database errors as well. probably sufficient to do it just in integration tests, no need to add them in every other layer

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the other testcases in the codebase, I added the test to the controller layer as we can mock the DatabaseConflictError there, whereas in the integration test it's harder to mock the difference in mongoose versions

jest
.spyOn(WorkspaceModel, 'getWorkspaces')
.mockRejectedValueOnce(new Error('something went wrong'))
const response = await request.get(GET_WORKSPACES_ENDPOINT)

expect(response.status).toEqual(500)
expect(response.body).toEqual({
message: 'Something went wrong. Please try again.',
})
})
})
})
49 changes: 49 additions & 0 deletions src/app/modules/workspace/__tests__/workspace.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import mongoose from 'mongoose'
import { FormId, UserId } from 'shared/types'
import { WorkspaceDto } from 'shared/types/workspace'

import { getWorkspaceModel } from 'src/app/models/workspace.server.model'
import * as WorkspaceService from 'src/app/modules/workspace/workspace.service'

import { DatabaseError } from '../../core/core.errors'

const WorkspaceModel = getWorkspaceModel(mongoose)

describe('workspace.service', () => {
beforeEach(async () => {
jest.clearAllMocks()
})

describe('getWorkspaces', () => {
it('should return an array of workspaces that belong to the user', async () => {
const mockWorkspaces = [
{
admin: 'user' as UserId,
title: 'workspace1',
formIds: [] as FormId[],
},
] as WorkspaceDto[]
const mockUserId = 'mockUserId'
const getSpy = jest
.spyOn(WorkspaceModel, 'getWorkspaces')
.mockResolvedValueOnce(mockWorkspaces)
const actual = await WorkspaceService.getWorkspaces(mockUserId)

expect(getSpy).toHaveBeenCalledWith(mockUserId)
expect(actual.isOk()).toEqual(true)
expect(actual._unsafeUnwrap()).toEqual(mockWorkspaces)
})

it('should return DatabaseError when error occurs whilst querying the database', async () => {
const mockUserId = 'mockUserId'
const getSpy = jest
.spyOn(WorkspaceModel, 'getWorkspaces')
.mockRejectedValueOnce(new Error('some error'))
const actual = await WorkspaceService.getWorkspaces(mockUserId)

expect(getSpy).toHaveBeenCalledWith(mockUserId)
expect(actual.isErr()).toEqual(true)
expect(actual._unsafeUnwrapErr()).toEqual(new DatabaseError())
})
})
})
29 changes: 23 additions & 6 deletions src/app/modules/workspace/workspace.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { celebrate, Segments } from 'celebrate'
import { AuthedSessionData } from 'express-session'
import { StatusCodes } from 'http-status-codes'
import { ErrorDto } from 'shared/types'
import { WorkspaceDto } from 'shared/types/workspace'

import { createLoggerWithLabel } from '../../config/logger'
import { ControllerHandler } from '../core/core.types'

import * as WorkspaceService from './workspace.service'
import { mapRouteError } from './workspace.utils'

const logger = createLoggerWithLabel(module)

// Validators
const createWorkspaceValidator = celebrate({
Expand All @@ -20,18 +26,29 @@ const updateWorkspaceTitleValidator = celebrate({
* @security session
*
* @returns 200 with list of user's workspaces if workspaces are retrieved successfully
* @returns 422 when user of given id cannnot be found in the database
* @returns 500 when database errors occur
*/
export const getWorkspaces: ControllerHandler<
unknown,
any[] | ErrorDto
WorkspaceDto[] | ErrorDto
> = async (req, res) => {
return WorkspaceService.getWorkspaces('')
const userId = (req.session as AuthedSessionData).user._id

return WorkspaceService.getWorkspaces(userId)
.map((workspaces) => res.status(StatusCodes.OK).json(workspaces))
.mapErr((err) =>
res.status(StatusCodes.BAD_REQUEST).json({ message: err.message }),
)
.mapErr((error) => {
logger.error({
message: 'Error getting workspaces',
meta: {
action: 'getWorkspaces',
userId,
},
error,
})

const { statusCode, errorMessage } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}

/**
Expand Down
25 changes: 22 additions & 3 deletions src/app/modules/workspace/workspace.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import mongoose from 'mongoose'
import { okAsync, ResultAsync } from 'neverthrow'
import { WorkspaceDto } from 'shared/types/workspace'

import { createLoggerWithLabel } from '../../config/logger'
import { getWorkspaceModel } from '../../models/workspace.server.model'
import { DatabaseError } from '../core/core.errors'
import { MissingUserError } from '../user/user.errors'

const logger = createLoggerWithLabel(module)
const WorkspaceModel = getWorkspaceModel(mongoose)

export const getWorkspaces = (
userId: string,
): ResultAsync<any, MissingUserError | DatabaseError> => {
return okAsync([userId])
): ResultAsync<WorkspaceDto[], DatabaseError> => {
return ResultAsync.fromPromise(
WorkspaceModel.getWorkspaces(userId),
(error) => {
logger.error({
message: 'Database error when retrieving workspaces',
meta: {
action: 'getWorkspaces',
userId,
},
error,
})
return new DatabaseError()
},
)
}

export const createWorkspace = (
Expand Down
Loading