Merge pull request #9794 from overleaf/ab-endpoint-add-remove-tag-multiple-projects

[web] Handle adding/removing multiple projects from a tag at once

GitOrigin-RevId: 7d052fa9930035286f8ce41433d6c3959817148a
This commit is contained in:
Timothée Alby
2022-10-17 12:14:24 +02:00
committed by Copybot
parent fbd588eea6
commit eb35e2c19b
19 changed files with 469 additions and 142 deletions
@@ -6,6 +6,7 @@ import ProjectListRoot from '../../../../../frontend/js/features/project-list/co
import { renderWithProjectListContext } from '../helpers/render-with-context'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import {
projectsData,
owner,
archivedProjects,
makeLongProjectList,
@@ -23,7 +24,15 @@ describe('<ProjectListRoot />', function () {
global.localStorage.clear()
sendSpy = sinon.spy(eventTracking, 'send')
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-tags', [])
this.tagId = '999fff999fff'
this.tagName = 'First tag name'
window.metaAttributesCache.set('ol-tags', [
{
_id: this.tagId,
name: this.tagName,
project_ids: [projectsData[0].id, projectsData[1].id],
},
])
window.metaAttributesCache.set('ol-ExposedSettings', {
templateLinks: [],
})
@@ -138,16 +147,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
const [projectRequest1Url, projectRequest1Headers] = requests[2]
expect(projectRequest1Url).to.equal(`/project/${project1Id}/archive`)
expect(projectRequest1Headers?.method).to.equal('POST')
const [projectRequest2Url, projectRequest2Headers] = requests[3]
expect(projectRequest2Url).to.equal(`/project/${project2Id}/archive`)
expect(projectRequest2Headers?.method).to.equal('POST')
await waitFor(
() =>
expect(fetchMock.called(`/project/${project1Id}/archive`)).to.be
.true
)
await waitFor(
() =>
expect(fetchMock.called(`/project/${project2Id}/archive`)).to.be
.true
)
})
it('opens trash modal for all selected projects and trashes all', async function () {
@@ -173,16 +182,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
const [projectRequest1Url, projectRequest1Headers] = requests[2]
expect(projectRequest1Url).to.equal(`/project/${project1Id}/trash`)
expect(projectRequest1Headers?.method).to.equal('POST')
const [projectRequest2Url, projectRequest2Headers] = requests[3]
expect(projectRequest2Url).to.equal(`/project/${project2Id}/trash`)
expect(projectRequest2Headers?.method).to.equal('POST')
await waitFor(
() =>
expect(fetchMock.called(`/project/${project1Id}/trash`)).to.be
.true
)
await waitFor(
() =>
expect(fetchMock.called(`/project/${project2Id}/trash`)).to.be
.true
)
})
it('only checks the projects that are viewable when there is a load more button', async function () {
@@ -354,6 +363,141 @@ describe('<ProjectListRoot />', function () {
})
})
describe('tags dropdown', function () {
beforeEach(async function () {
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
// first one is the select all checkbox
fireEvent.click(allCheckboxes[1])
fireEvent.click(allCheckboxes[2])
actionsToolbar = screen.getAllByRole('toolbar')[0]
this.newTagName = 'Some tag name'
this.newTagId = 'abc123def456'
})
it('opens the tags dropdown and creates a new tag', async function () {
fetchMock.post(`express:/tag`, {
status: 200,
body: {
_id: this.newTagId,
name: this.newTagName,
project_ids: [],
},
})
fetchMock.post(`express:/tag/:id/projects`, {
status: 204,
})
await waitFor(() => {
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
})
screen.getByText('Add to folder')
const newTagButton = screen.getByText('Create New Folder')
fireEvent.click(newTagButton)
const modal = screen.getAllByRole('dialog')[0]
const input = within(modal).getByRole<HTMLInputElement>('textbox')
fireEvent.change(input, {
target: { value: this.newTagName },
})
const createButton = within(modal).getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
await waitFor(
() =>
expect(fetchMock.called('/tag', { name: this.newTagName })).to.be
.true
)
await waitFor(
() =>
expect(
fetchMock.called(`/tag/${this.newTagId}/projects`, {
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
})
).to.be.true
)
screen.getByRole('button', { name: `${this.newTagName} (2)` })
})
it('opens the tags dropdown and remove a tag from selected projects', async function () {
const deleteProjectsFromTagMock = fetchMock.delete(
`express:/tag/:id/projects`,
{
status: 204,
}
)
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
screen.getByText('Add to folder')
const tagButton = screen.getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await waitFor(
() =>
expect(
deleteProjectsFromTagMock.called(
`/tag/${this.tagId}/projects`,
{
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
}
)
).to.be.true
)
screen.getByRole('button', { name: `${this.tagName} (0)` })
})
it('select another project, opens the tags dropdown and add a tag only to the untagged project', async function () {
const addProjectsToTagMock = fetchMock.post(
`express:/tag/:id/projects`,
{
status: 204,
}
)
fireEvent.click(allCheckboxes[3])
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
screen.getByText('Add to folder')
const tagButton = screen.getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await waitFor(
() =>
expect(
addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, {
body: {
projectIds: [projectsData[2].id],
},
})
).to.be.true
)
screen.getByRole('button', { name: `${this.tagName} (3)` })
})
})
describe('project tools "More" dropdown', function () {
beforeEach(async function () {
const filterButton = screen.getAllByText('All Projects')[0]
@@ -374,9 +518,12 @@ describe('<ProjectListRoot />', function () {
})
it('opens the rename modal, and can rename the project, and view updated', async function () {
fetchMock.post(`express:/project/:id/rename`, {
status: 200,
})
const renameProjectMock = fetchMock.post(
`express:/project/:id/rename`,
{
status: 200,
}
)
await waitFor(() => {
const moreDropdown =
@@ -384,7 +531,8 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(moreDropdown)
})
const renameButton = screen.getByText<HTMLInputElement>('Rename')
const renameButton =
screen.getAllByText<HTMLInputElement>('Rename')[1] // first one is for the tag in the sidebar
fireEvent.click(renameButton)
const modal = screen.getAllByRole('dialog')[0]
@@ -419,8 +567,14 @@ describe('<ProjectListRoot />', function () {
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.called(
`/project/${projectsData[1].id}/rename`
)
).to.be.true
)
screen.findByText(newProjectName)
expect(screen.queryByText(oldName)).to.be.null
@@ -435,24 +589,27 @@ describe('<ProjectListRoot />', function () {
const tableRows = screen.getAllByRole('row')
const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
screen.findByText(projectNameToCopy) // make sure not just empty string
screen.getByText(projectNameToCopy) // make sure not just empty string
const copiedProjectName = `${projectNameToCopy} (Copy)`
fetchMock.post(`express:/project/:id/clone`, {
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
})
const cloneProjectMock = fetchMock.post(
`express:/project/:id/clone`,
{
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
}
)
await waitFor(() => {
const moreDropdown =
@@ -470,13 +627,17 @@ describe('<ProjectListRoot />', function () {
) as HTMLElement
fireEvent.click(copyConfirmButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
).to.be.true
)
expect(sendSpy).to.be.calledOnce
expect(sendSpy).calledWith('project-list-page-interaction')
screen.findByText(copiedProjectName)
screen.getByText(copiedProjectName)
})
})
})
@@ -27,6 +27,7 @@ describe('<TagsList />', function () {
name: 'New Tag',
project_ids: [],
})
fetchMock.post('express:/tag/:tagId/projects', 200)
fetchMock.post('express:/tag/:tagId/rename', 200)
fetchMock.delete('express:/tag/:tagId', 200)
@@ -145,7 +146,7 @@ describe('<TagsList />', function () {
await fireEvent.click(createButton)
await waitFor(() => expect(fetchMock.called(`/tag`)))
await waitFor(() => expect(fetchMock.called(`/tag`)).to.be.true)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
import {
archiveableProject,
@@ -44,8 +44,8 @@ describe('<ArchiveProjectButton />', function () {
it('should archive the projects', async function () {
const project = Object.assign({}, archiveableProject)
fetchMock.post(
`express:/project/${project.id}/archive`,
const archiveProjectMock = fetchMock.post(
`express:/project/:projectId/archive`,
{
status: 200,
},
@@ -62,14 +62,11 @@ describe('<ArchiveProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify archived
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first mock call is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/archive`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to
.be.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
import {
archivedProject,
@@ -16,6 +16,7 @@ describe('<CopyProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} />
@@ -40,8 +41,8 @@ describe('<CopyProjectButton />', function () {
})
it('opens the modal and copies the project ', async function () {
fetchMock.post(
`express:/project/${copyableProject.id}/clone`,
const copyProjectMock = fetchMock.post(
`express:/project/:projectId/clone`,
{
status: 200,
},
@@ -58,14 +59,11 @@ describe('<CopyProjectButton />', function () {
const copyBtn = screen.getByText('Copy') as HTMLButtonElement
fireEvent.click(copyBtn)
expect(copyBtn.disabled).to.be.true
// verify cloned
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first mock call is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${copyableProject.id}/clone`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`))
.to.be.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
import {
archiveableProject,
@@ -46,8 +46,8 @@ describe('<DeleteProjectButton />', function () {
it('opens the modal and deletes the project', async function () {
window.user_id = trashedProject?.owner?.id
const project = Object.assign({}, trashedProject)
fetchMock.delete(
`express:/project/${project.id}`,
const deleteProjectMock = fetchMock.delete(
`express:/project/:projectId`,
{
status: 200,
},
@@ -64,14 +64,10 @@ describe('<DeleteProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is project list api in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}`)
expect(requestHeaders?.method).to.equal('DELETE')
fetchMock.reset()
await waitFor(
() =>
expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button'
import {
trashedProject,
@@ -17,6 +17,7 @@ describe('<LeaveProjectButtton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
@@ -51,7 +52,7 @@ describe('<LeaveProjectButtton />', function () {
it('opens the modal and leaves the project', async function () {
const project = Object.assign({}, trashedAndNotOwnedProject)
fetchMock.post(
const leaveProjectMock = fetchMock.post(
`express:/project/${project.id}/leave`,
{
status: 200,
@@ -69,14 +70,11 @@ describe('<LeaveProjectButtton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is project list api in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/leave`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be
.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
import {
archivedProject,
@@ -34,8 +34,8 @@ describe('<TrashProjectButton />', function () {
it('opens the modal and trashes the project', async function () {
const project = Object.assign({}, archivedProject)
fetchMock.post(
`express:/project/${project.id}/trash`,
const trashProjectMock = fetchMock.post(
`express:/project/:projectId/trash`,
{
status: 200,
},
@@ -52,14 +52,11 @@ describe('<TrashProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/trash`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be
.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button'
import {
archiveableProject,
@@ -42,8 +42,8 @@ describe('<UnarchiveProjectButton />', function () {
it('unarchive the project and updates the view data', async function () {
const project = Object.assign({}, archivedProject)
fetchMock.delete(
`express:/project/${project.id}/archive`,
const unarchiveProjectMock = fetchMock.delete(
`express:/project/:projectId/archive`,
{
status: 200,
},
@@ -55,7 +55,10 @@ describe('<UnarchiveProjectButton />', function () {
const btn = screen.getByLabelText('Restore')
fireEvent.click(btn)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to
.be.true
)
})
})
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
import {
@@ -12,10 +12,6 @@ import {
} from '../../../../helpers/render-with-context'
describe('<UntrashProjectButton />', function () {
beforeEach(function () {
fetchMock.reset()
})
afterEach(function () {
resetProjectListContextFetch()
})
@@ -38,8 +34,8 @@ describe('<UntrashProjectButton />', function () {
it('untrashes the project and updates the view data', async function () {
const project = Object.assign({}, trashedProject)
fetchMock.delete(
`express:/project/${project.id}/trash`,
const untrashProjectMock = fetchMock.delete(
`express:/project/:projectId/trash`,
{
status: 200,
},
@@ -51,7 +47,10 @@ describe('<UntrashProjectButton />', function () {
const btn = screen.getByLabelText('Restore')
fireEvent.click(btn)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be
.true
)
})
})
@@ -1,4 +1,4 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect } from 'chai'
import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
import {
@@ -9,14 +9,21 @@ import { currentProjects } from '../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
describe('<RenameProjectModal />', function () {
beforeEach(function () {
resetProjectListContextFetch()
})
afterEach(function () {
resetProjectListContextFetch()
})
it('renders the modal and validates new name', async function () {
fetchMock.post('express:/project/:projectId/rename', {
status: 200,
})
const renameProjectMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 200,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
@@ -44,14 +51,21 @@ describe('<RenameProjectModal />', function () {
fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.called(`/project/${currentProjects[0].id}/rename`)
).to.be.true
)
})
it('shows error message from API', async function () {
fetchMock.post('express:/project/:projectId/rename', {
status: 500,
})
const postRenameMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 500,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
@@ -70,8 +84,7 @@ describe('<RenameProjectModal />', function () {
const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
fireEvent.click(submitButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(() => expect(postRenameMock.called()).to.be.true)
screen.getByText('Something went wrong. Please try again.')
})
@@ -24,6 +24,11 @@ export function renderWithProjectListContext(
body: { projects, totalSize: projects.length },
})
fetchMock.get('express:/system/messages', {
status: 200,
body: [],
})
const ProjectListProviderWrapper = ({
children,
}: {
@@ -14,7 +14,9 @@ describe('TagsController', function () {
this.TagsHandler = {
promises: {
addProjectToTag: sinon.stub().resolves(),
addProjectsToTag: sinon.stub().resolves(),
removeProjectFromTag: sinon.stub().resolves(),
removeProjectsFromTag: sinon.stub().resolves(),
deleteTag: sinon.stub().resolves(),
renameTag: sinon.stub().resolves(),
createTag: sinon.stub().resolves(),
@@ -40,6 +42,7 @@ describe('TagsController', function () {
_id: userId,
},
},
body: {},
}
this.res = {}
@@ -163,6 +166,30 @@ describe('TagsController', function () {
})
})
it('add projects to a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.body.projectIds = this.projectIds = [
'project-id-123',
'project-id-234',
]
this.req.session.user._id = this.userId = 'user-id-123'
this.TagsController.addProjectsToTag(this.req, {
status: code => {
assert.equal(code, 204)
sinon.assert.calledWith(
this.TagsHandler.promises.addProjectsToTag,
this.userId,
this.tagId,
this.projectIds
)
done()
return {
end: () => {},
}
},
})
})
it('remove a project from a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.params.projectId = this.projectId = 'project-id-123'
@@ -183,4 +210,28 @@ describe('TagsController', function () {
},
})
})
it('remove projects from a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.body.projectIds = this.projectIds = [
'project-id-123',
'project-id-234',
]
this.req.session.user._id = this.userId = 'user-id-123'
this.TagsController.removeProjectsFromTag(this.req, {
status: code => {
assert.equal(code, 204)
sinon.assert.calledWith(
this.TagsHandler.promises.removeProjectsFromTag,
this.userId,
this.tagId,
this.projectIds
)
done()
return {
end: () => {},
}
},
})
})
})