Verified Commit 1b45c7ac authored by aguestuser's avatar aguestuser

[215][wip] cleanup registrar.phoneNumber.recycle

* fix failing unit tests
  * while at it: inline and clean up some stub helpers (decouple find
    stubs from destroy stubs)
* refactor for modularity
  * extract `registrar.phoneNumber.common.destroyChannel` to channel repository
  * extract `notifyMembers`/ `notifyAdmins` to `registrar.phoneNumber.common`
parent 54cccd4d
......@@ -30,6 +30,17 @@ const update = (phoneNumber, attrs) =>
.update({ ...attrs }, { where: { phoneNumber }, returning: true })
.then(([, [pNumInstance]]) => pNumInstance)
// (ChannelInstance | null, Transaction) -> Promise<boolean>
const destroy = async (channel, tx) => {
if (channel == null) return false
try {
await channel.destroy({ transaction: tx })
return true
} catch (error) {
return Promise.reject('Failed to destroy channel')
}
}
const findAll = () => app.db.channel.findAll()
const findAllDeep = () =>
......@@ -89,6 +100,7 @@ const getSubscriberPhoneNumbers = channel =>
module.exports = {
create,
destroy,
findAll,
findAllDeep,
findByPhoneNumber,
......
......@@ -2,6 +2,8 @@ const { pick } = require('lodash')
const { statuses } = require('../../db/models/phoneNumber')
const channelRepository = require('../../db/repositories/channel')
const signal = require('../../signal')
const { getAdminMemberships } = require("../../db/repositories/channel")
const { getAdminPhoneNumbers } = require('../../db/repositories/channel')
const { messagesIn } = require('../../dispatcher/strings/messages')
const { sdMessageOf } = require('../../signal/constants')
const {
......@@ -12,6 +14,11 @@ const {
// STRINGS
const notificationKeys = {
CHANNEL_DESTROYED: 'channelDestroyed',
CHANNEL_RECYCLED: 'channelRecycled',
}
const errors = {
searchEmpty: 'search returned empty list',
searchFailed: err => `twilio number search failed: ${err}`,
......@@ -40,15 +47,23 @@ const notifyMembersExcept = async (channel, message, sender) => {
// (string) -> Promise<Array<string>>
const notifyMaintainers = async message => {
const adminChannel = await channelRepository.findDeep(supportPhoneNumber)
const adminPhoneNumbers = channelRepository.getAdminPhoneNumbers(adminChannel)
await signal.broadcastMessage(adminPhoneNumbers, sdMessageOf(adminChannel, message))
if (!supportPhoneNumber) return Promise.resolve([])
const supportChannel = await channelRepository.findDeep(supportPhoneNumber)
const maintainerPhoneNumbers = getAdminPhoneNumbers(supportChannel)
await signal.broadcastMessage(maintainerPhoneNumbers, sdMessageOf(supportChannel, message))
}
// (string, string) -> Promise<Array<string>>
const notifyAdmins = async (channel, notificationKey) => {
const recipients = await channelRepository.getAdminMemberships(channel)
return Promise.all(
const notifyAdmins = async (channel, notificationKey) =>
_notifyMany(channel, notificationKey, getAdminMemberships(channel))
// (Channel, string) -> Promise<Array<string>>
const notifyMembers = async (channel, notificationKey) =>
_notifyMany(channel, notificationKey, channel.memberships)
// (Channel, string, Array<Member>) => Promise<Array<string>>
const _notifyMany = (channel, notificationKey, recipients) =>
Promise.all(
recipients.map(recipient =>
signal.sendMessage(
recipient.memberPhoneNumber,
......@@ -56,7 +71,6 @@ const notifyAdmins = async (channel, notificationKey) => {
),
),
)
}
// (DB, Socket, ChannelInstance, String) -> Promise<void>
const destroyChannel = async (channel, tx) => {
......@@ -79,8 +93,10 @@ module.exports = {
errorStatus,
extractStatus,
notifyAdmins,
notifyMembers,
notifyMaintainers,
notifyMembersExcept,
notificationKeys,
destroyChannel,
getTwilioClient,
smsUrl,
......
const channelRepository = require('../../db/repositories/channel')
const eventRepository = require('../../db/repositories/event')
const phoneNumberRepository = require('../../db/repositories/phoneNumber')
const common = require('./common')
const { notificationKeys } = require('./common')
const { statuses } = require('../../util')
const { defaultLanguage } = require('../../config')
const signal = require('../../signal')
const { eventTypes } = require('../../db/models/event')
const { messagesIn } = require('../../dispatcher/strings/messages')
const phoneNumberRepository = require('../../db/repositories/phoneNumber')
const channelRepository = require('../../db/repositories/channel')
// (string) -> SignalboostStatus
const recycle = async channelPhoneNumber => {
const channel = await channelRepository.findDeep(channelPhoneNumber)
if (channel) {
return notifyMembers(channel)
.then(() => common.destroyChannel(channel))
.then(() => eventRepository.log(eventTypes.CHANNEL_DESTROYED, channelPhoneNumber))
.then(() => recordStatusChange(channelPhoneNumber, common.statuses.VERIFIED))
.then(phoneNumberStatus => ({ status: statuses.SUCCESS, data: phoneNumberStatus }))
.catch(err => handleRecycleFailure(err, channelPhoneNumber))
} else {
return { status: statuses.ERROR, message: `Channel not found for ${channelPhoneNumber}` }
}
}
/********************
* HELPER FUNCTIONS
********************/
// (Channel) -> Promise([])
const notifyMembers = async channel => {
const recipients = await channelRepository.getMemberPhoneNumbers(channel)
return Promise.all(
recipients.map(recipient =>
signal.sendMessage(
recipient.memberPhoneNumber,
signal.sdMessageOf(channel, messagesIn(recipient.language).notifications.channelRecycled),
),
),
)
}
// (Database, string, PhoneNumberStatus) -> PhoneNumberStatus
const recordStatusChange = async (phoneNumber, status) =>
phoneNumberRepository.update(phoneNumber, { status }).then(common.extractStatus)
const recycle = async phoneNumber => {
const channel = await channelRepository.findDeep(phoneNumber)
if (!channel) return { status: statuses.ERROR, message: `Channel not found for ${phoneNumber}` }
const handleRecycleFailure = async (err, phoneNumber) => {
await common.notifyMaintainers(
messagesIn(defaultLanguage).notifications.recycleChannelFailed(phoneNumber),
)
try {
await common.notifyMembers(channel, notificationKeys.CHANNEL_RECYCLED)
await channelRepository.destroy(channel)
await eventRepository.log(eventTypes.CHANNEL_DESTROYED, phoneNumber)
const result = await phoneNumberRepository.update(phoneNumber, {
status: common.statuses.VERIFIED,
})
return {
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: ${err}`,
return {
status: statuses.SUCCESS,
data: common.extractStatus(result),
}
} catch (err) {
await common.notifyMaintainers(
messagesIn(defaultLanguage).notifications.recycleChannelFailed(phoneNumber),
)
return {
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: ${err}`,
}
}
}
......
import chai, { expect } from 'chai'
import { describe, it, before, beforeEach, after, afterEach } from 'mocha'
import chaiAsPromised from 'chai-as-promised'
import { deepChannelFactory } from '../../../support/factories/channel'
import { channelFactory, deepChannelFactory } from '../../../support/factories/channel'
import { genPhoneNumber } from '../../../support/factories/phoneNumber'
import { omit, keys, times } from 'lodash'
import channelRepository, { isSysadmin } from '../../../../app/db/repositories/channel'
......@@ -122,6 +122,36 @@ describe('channel repository', () => {
})
})
describe('#destroy', () => {
let channel, channelCount
describe('when given a channel instance', () => {
beforeEach(async () => (channel = await db.channel.create(channelFactory())))
it('deletes the instance', async () => {
channelCount = await db.channel.count()
expect(await channelRepository.destroy(channel)).to.eql(true)
expect(await db.channel.count()).to.eql(channelCount - 1)
})
})
describe('when given null', () => {
it('does nothing', async () => {
channelCount = await db.channel.count()
expect(await channelRepository.destroy(channel)).to.eql(false)
expect(await db.channel.count()).to.eql(channelCount)
})
})
describe('when given a channel but db error occurs', () => {
it('rejects with an error', async () => {
channelCount = await db.channel.count()
expect(await channelRepository.destroy('foobar').catch(e => e)).to.include('Failed')
expect(await db.channel.count()).to.eql(channelCount)
})
})
})
describe('#findDeep', () => {
const attrs = deepChannelFactory()
let result
......
import { expect } from 'chai'
import { describe, it, beforeEach, afterEach } from 'mocha'
import sinon from 'sinon'
import channelRepository from '../../../../app/db/repositories/channel'
import signal from '../../../../app/signal'
import { deepChannelFactory } from '../../../support/factories/channel'
import { times } from 'lodash'
import {
adminMembershipFactory,
subscriberMembershipFactory,
} from '../../../support/factories/membership'
import common, { notificationKeys } from '../../../../app/registrar/phoneNumber/common'
import { sdMessageOf } from '../../../../app/signal/constants'
import { messagesIn } from '../../../../app/dispatcher/strings/messages'
import { defaultLanguage } from '../../../../app/config'
describe('phone number registrar -- common module', () => {
const channel = deepChannelFactory({
memberships: [
adminMembershipFactory({ language: 'DE' }),
adminMembershipFactory({ language: 'FR' }),
subscriberMembershipFactory({ language: 'ES' }),
subscriberMembershipFactory({ language: 'DE' }),
],
})
let findChannelStub, broadcastMessageStub, sendMessageStub
beforeEach(() => {
findChannelStub = sinon.stub(channelRepository, 'findDeep').returns(Promise.resolve(channel))
broadcastMessageStub = sinon
.stub(signal, 'broadcastMessage')
.callsFake(numbers => numbers.map(() => '42'))
sendMessageStub = sinon.stub(signal, 'sendMessage').returns(Promise.resolve('42'))
})
afterEach(() => sinon.restore())
describe('#notifyAdmins', () => {
it('sends a notification to each admin in their language', async () => {
await common.notifyAdmins(channel, notificationKeys.CHANNEL_RECYCLED)
expect(sendMessageStub.callCount).to.eql(2)
expect(sendMessageStub.getCalls().map(x => x.args)).to.have.deep.members([
[
channel.memberships[0].memberPhoneNumber,
sdMessageOf(channel, messagesIn('DE').notifications[notificationKeys.CHANNEL_RECYCLED]),
],
[
channel.memberships[1].memberPhoneNumber,
sdMessageOf(channel, messagesIn('FR').notifications[notificationKeys.CHANNEL_RECYCLED]),
],
])
})
})
describe('#notifyMembers', () => {
it('sends a notification to each member in their language', async () => {
await common.notifyMembers(channel, notificationKeys.CHANNEL_DESTROYED)
expect(sendMessageStub.callCount).to.eql(4)
expect(sendMessageStub.getCalls().map(x => x.args)).to.have.deep.members([
[
channel.memberships[0].memberPhoneNumber,
sdMessageOf(channel, messagesIn('DE').notifications[notificationKeys.CHANNEL_DESTROYED]),
],
[
channel.memberships[1].memberPhoneNumber,
sdMessageOf(channel, messagesIn('FR').notifications[notificationKeys.CHANNEL_DESTROYED]),
],
[
channel.memberships[2].memberPhoneNumber,
sdMessageOf(channel, messagesIn('ES').notifications[notificationKeys.CHANNEL_DESTROYED]),
],
[
channel.memberships[3].memberPhoneNumber,
sdMessageOf(channel, messagesIn('DE').notifications[notificationKeys.CHANNEL_DESTROYED]),
],
])
})
})
describe('#notifyMaintainers', () => {
it('sends an untranslated notification to sysadmins of the instance', async () => {
await common.notifyMaintainers('foo')
expect(broadcastMessageStub.callCount).to.eql(1)
expect(broadcastMessageStub.getCall(0).args).to.eql([
[channel.memberships[0].memberPhoneNumber, channel.memberships[1].memberPhoneNumber],
sdMessageOf(channel, 'foo'),
])
})
})
})
import { expect } from 'chai'
import { afterEach, beforeEach, describe, it } from 'mocha'
import { recycle } from '../../../../app/registrar/phoneNumber'
import phoneNumberService, { recycle } from '../../../../app/registrar/phoneNumber'
import sinon from 'sinon'
import phoneNumberRepository from '../../../../app/db/repositories/phoneNumber'
import channelRepository from '../../../../app/db/repositories/channel'
import eventRepository from '../../../../app/db/repositories/event'
import signal from '../../../../app/signal'
import common from '../../../../app/registrar/phoneNumber/common'
import { eventTypes } from '../../../../app/db/models/event'
import { genPhoneNumber } from '../../../support/factories/phoneNumber'
import { deepChannelFactory } from '../../../support/factories/channel'
describe('phone number services -- recycle module', () => {
const phoneNumbers = ['+11111111111', '+12222222222']
const phoneNumber = genPhoneNumber()
let updatePhoneNumberStub,
broadcastMessageStub,
findChannelStub,
getAdminPhoneNumbersStub,
destroyChannelSpy,
destroyChannelStub,
notifyMaintainersStub,
notifyMembersStub,
logEventStub
beforeEach(() => {
updatePhoneNumberStub = sinon.stub(phoneNumberRepository, 'update')
broadcastMessageStub = sinon.stub(signal, 'broadcastMessage')
findChannelStub = sinon.stub(channelRepository, 'findDeep')
sinon.stub(channelRepository, 'getMemberPhoneNumbers')
getAdminPhoneNumbersStub = sinon.stub(channelRepository, 'getAdminPhoneNumbers')
destroyChannelSpy = sinon.spy()
destroyChannelStub = sinon.stub(channelRepository, 'destroy')
logEventStub = sinon.stub(eventRepository, 'log')
notifyMaintainersStub = sinon.stub(common, 'notifyMaintainers')
notifyMembersStub = sinon.stub(common, 'notifyMembers')
})
afterEach(() => {
sinon.restore()
})
afterEach(() => sinon.restore())
const updatePhoneNumberSucceeds = () =>
updatePhoneNumberStub.callsFake((phoneNumber, { status }) =>
Promise.resolve({ phoneNumber, status }),
)
const updatePhoneNumberFails = () =>
updatePhoneNumberStub.callsFake(() =>
Promise.resolve({
then: () => {
throw 'DB phoneNumber update failure'
},
}),
)
const destroyChannelSucceeds = () =>
findChannelStub.callsFake(phoneNumber =>
Promise.resolve({ destroy: destroyChannelSpy, phoneNumber }),
)
const destroyChannelFails = () =>
findChannelStub.callsFake(phoneNumber =>
Promise.resolve({
destroy: () => {
throw 'Failed to destroy channel'
},
phoneNumber,
}),
)
const broadcastMessageSucceeds = () => broadcastMessageStub.callsFake(() => Promise.resolve())
const broadcastMessageFails = () =>
broadcastMessageStub.callsFake(() => Promise.reject('Failed to broadcast message'))
describe('recycling phone numbers', () => {
describe('recycling a phone number', () => {
describe('when the phone number does not belong to a valid channel', () => {
beforeEach(async () => {
findChannelStub.returns(Promise.resolve(null))
})
it('returns a channel not found status', async () => {
const response = await recycle(phoneNumbers[0])
const response = await recycle(phoneNumber)
expect(response).to.eql({
message: 'Channel not found for +11111111111',
message: `Channel not found for ${phoneNumber}`,
status: 'ERROR',
})
})
......@@ -84,100 +52,79 @@ describe('phone number services -- recycle module', () => {
describe('when the phone number does belong to a valid channel', () => {
beforeEach(async () => {
findChannelStub.returns(Promise.resolve({}))
findChannelStub.returns(Promise.resolve(deepChannelFactory({ phoneNumber })))
})
describe('when notifying members of channel recycling fails', () => {
beforeEach(async () => {
await broadcastMessageFails()
notifyMaintainersStub = sinon.stub(common, 'notifyMaintainers')
})
afterEach(() => {
notifyMaintainersStub.restore()
notifyMembersStub.callsFake(() => Promise.reject('Failed to broadcast message'))
})
it('returns a failed status', async () => {
const response = await recycle(phoneNumbers[0])
expect(response).to.eql({
message:
'Failed to recycle channel for +11111111111. Error: Failed to broadcast message',
expect(await recycle(phoneNumber)).to.eql({
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: Failed to broadcast message`,
})
})
})
describe('when notifying members of channel recycling succeeds', () => {
beforeEach(async () => {
await broadcastMessageSucceeds()
})
beforeEach(async () => notifyMembersStub.returns(Promise.resolve()))
describe('when the channel destruction succeeds', () => {
beforeEach(() => {
destroyChannelSucceeds()
})
beforeEach(() => destroyChannelStub.returns(Promise.resolve()))
describe('when the phoneNumber update succeeds', () => {
beforeEach(() => {
updatePhoneNumberSucceeds()
})
beforeEach(() => updatePhoneNumberSucceeds())
it('notifies the members of the channel of destruction', async () => {
await recycle(phoneNumbers[0])
expect(broadcastMessageStub.callCount).to.eql(1)
await recycle(phoneNumber)
expect(notifyMembersStub.callCount).to.eql(1)
})
it('adds a CHANNEL_DESTROYED event to the event log', async () => {
await phoneNumberService.recycle({ phoneNumbers })
expect(logEventStub.getCalls().map(call => call.args)).to.have.deep.members(
phoneNumbers
.split(',')
.map(phoneNumber => [eventTypes.CHANNEL_DESTROYED, phoneNumber]),
)
await phoneNumberService.recycle(phoneNumber)
expect(logEventStub.getCall(0).args).to.eql([
eventTypes.CHANNEL_DESTROYED,
phoneNumber,
])
})
it('updates the phone number record to verified', async () => {
await recycle(phoneNumbers[0])
await recycle(phoneNumber)
expect(updatePhoneNumberStub.getCall(0).args).to.eql([
'+11111111111',
phoneNumber,
{ status: 'VERIFIED' },
])
})
it('successfully destroys the channel', async () => {
await recycle(phoneNumbers[0])
expect(destroyChannelSpy.callCount).to.eql(1)
await recycle(phoneNumber)
expect(destroyChannelStub.callCount).to.eql(1)
})
it('returns successful recycled phone number statuses', async () => {
const response = await recycle(phoneNumbers[0])
const response = await recycle(phoneNumber)
expect(response).to.eql({
data: {
phoneNumber: '+11111111111',
status: 'VERIFIED',
},
status: 'SUCCESS',
data: { phoneNumber: phoneNumber, status: 'VERIFIED' },
})
})
})
describe('when the phoneNumber status update fails', () => {
beforeEach(() => {
updatePhoneNumberFails()
})
beforeEach(() =>
updatePhoneNumberStub.callsFake(() =>
Promise.reject('DB phoneNumber update failure'),
),
)
it('returns a failed status', async () => {
const response = await recycle(phoneNumbers[0])
const response = await recycle(phoneNumber)
expect(response).to.eql({
message:
'Failed to recycle channel for +11111111111. Error: DB phoneNumber update failure',
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: DB phoneNumber update failure`,
})
})
})
......@@ -186,32 +133,22 @@ describe('phone number services -- recycle module', () => {
describe('when the channel destruction fails', () => {
beforeEach(() => {
destroyChannelFails()
getAdminPhoneNumbersStub.returns(['+16154804259', '+12345678910'])
})
it('notifies the correct instance maintainers', async () => {
await recycle(phoneNumbers[0])
expect(broadcastMessageStub.getCall(1).args[0]).to.eql(['+16154804259', '+12345678910'])
notifyMembersStub.returns(Promise.resolve())
destroyChannelStub.callsFake(() => Promise.reject('Failed to destroy channel'))
})
it('notifies the instance maintainers with a channel failure message', async () => {
await recycle(phoneNumbers[0])
expect(broadcastMessageStub.getCall(1).args[1]).to.eql({
messageBody: 'Failed to recycle channel for phone number: +11111111111',
type: 'send',
username: '+15555555555',
})
await recycle(phoneNumber)
expect(notifyMaintainersStub.getCall(0).args).to.eql([
`Failed to recycle channel for phone number: ${phoneNumber}`,
])
})
it('returns a failed status', async () => {
const response = await recycle(phoneNumbers[0])
const response = await recycle(phoneNumber)
expect(response).to.eql({
message: 'Failed to recycle channel for +11111111111. Error: Failed to destroy channel',
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: Failed to destroy channel`,
})
})
})
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment