Merge pull request #16292 from mozilla/fxa-8841-paypel-refund-on-delete

feat(auth): Refund Stripe and PayPal invoices on delete
This commit is contained in:
Reino Muhl 2024-01-31 17:42:02 -05:00 коммит произвёл GitHub
Родитель f37f930bbc 0b60612c47
Коммит 1e50382016
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
9 изменённых файлов: 649 добавлений и 15 удалений

Просмотреть файл

@ -184,18 +184,22 @@ export class AccountDeleteManager {
* @param uid string
* @param options AccountDeleteOptions optional object with an optional `notify` function that will be called after the account's removed from MySQL.
*/
public async deleteAccount(uid: string, options?: AccountDeleteOptions) {
public async deleteAccount(
uid: string,
customerId?: string,
options?: AccountDeleteOptions
) {
await this.deleteAccountFromDb(uid, options);
await this.deleteOAuthTokens(uid);
// see comment in the function on why we are not awaiting
this.deletePushboxRecords(uid);
await this.deleteSubscriptions(uid);
await this.deleteSubscriptions(uid, customerId);
await this.deleteFirestoreCustomer(uid);
}
public async cleanupAccount(uid: string) {
await this.deleteSubscriptions(uid);
public async cleanupAccount(uid: string, customerId?: string) {
await this.deleteSubscriptions(uid, customerId);
await this.deleteFirestoreCustomer(uid);
await this.deleteOAuthTokens(uid);
}
@ -242,15 +246,68 @@ export class AccountDeleteManager {
});
}
public async refundSubscriptions(
deleteReason: ReasonForDeletion,
customerId?: string,
refundPeriod?: number
) {
this.log.debug('AccountDeleteManager.refundSubscriptions.start', {
customerId,
});
// Currently only support auto refund of invoices for unverified accounts
if (deleteReason !== 'fxa_unverified_account_delete' || !customerId) {
return;
}
const createdDate = refundPeriod
? new Date(new Date().setDate(new Date().getDate() - refundPeriod))
: undefined;
const invoices =
await this.stripeHelper?.fetchInvoicesForActiveSubscriptions(
customerId,
'paid',
createdDate
);
if (!invoices?.length) {
return;
}
this.log.debug('AccountDeleteManager.refundSubscriptions', {
customerId,
invoicesToRefund: invoices.length,
});
// Attempt Stripe and PayPal refunds
const results = await Promise.allSettled([
this.stripeHelper?.refundInvoices(invoices),
this.paypalHelper?.refundInvoices(invoices),
]);
results.forEach((result) => {
if (result.status === 'rejected') {
throw result.reason;
}
});
}
/**
* Delete the account's subscriptions from Stripe and PayPal.
* This will cancel any active subscriptions and remove the customer.
*
* @param accountRecord
* @param uid - Account UID
* @param deleteReason -- @@TODO temporary default deleteReason, remove if necessary
* @param refundPeriod -- @@TODO temporary default to 30. Remove if not necessary
*/
public async deleteSubscriptions(uid: string) {
public async deleteSubscriptions(
uid: string,
customerId?: string,
deleteReason: ReasonForDeletion = 'fxa_user_requested_account_delete',
refundPeriod?: number
) {
if (this.config.subscriptions?.enabled && this.stripeHelper) {
try {
// Before removing the Stripe Customer, refund the subscriptions if necessary
await this.refundSubscriptions(deleteReason, customerId, refundPeriod);
await this.stripeHelper.removeCustomer(uid);
} catch (err) {
if (err.message === 'Customer not available') {

Просмотреть файл

@ -169,6 +169,15 @@ function throwPaypalCodeError(err: PayPalClientError) {
);
}
export class RefundError extends Error {
constructor(message: string) {
super(message);
this.name = 'RefundError';
}
}
const MAX_REFUND_DAYS = 180;
export class PayPalHelper {
private log: Logger;
private client: PayPalClient;
@ -608,4 +617,64 @@ export class PayPalHelper {
message: 'PayPal refund transaction unsuccessful',
});
}
/**
* Attempts to refund all of the invoices passed, provided they're created via PayPal
* This will invisibly do nothing if the invoice is not billed through PayPal, so be mindful
* if using it elsewhere and need confirmation of a refund.
*/
public async refundInvoices(invoices: Stripe.Invoice[]) {
this.log.debug('PayPalHelper.refundInvoices', {
numberOfInvoices: invoices.length,
});
const minCreated = Math.floor(
new Date().setDate(new Date().getDate() - MAX_REFUND_DAYS) / 1000
);
const payPalInvoices = invoices.filter(
(invoice) => invoice.collection_method === 'send_invoice'
);
for (const invoice of payPalInvoices) {
this.log.debug('PayPalHelper.refundInvoices', { invoiceId: invoice.id });
try {
if (invoice.created < minCreated) {
throw new RefundError(
'Invoice created outside of maximum refund period'
);
}
const transactionId =
this.stripeHelper.getInvoicePaypalTransactionId(invoice);
if (!transactionId) {
throw new RefundError('Missing transactionId');
}
const refundTransactionId =
this.stripeHelper.getInvoicePaypalRefundTransactionId(invoice);
if (refundTransactionId) {
throw new RefundError('Invoice already refunded with PayPal');
}
await this.issueRefund(invoice, transactionId, RefundType.Full);
this.log.info('refundInvoices', {
invoiceId: invoice.id,
priceId: this.stripeHelper.getPriceIdFromInvoice(invoice),
total: invoice.total,
currency: invoice.currency,
});
} catch (error) {
this.log.error('PayPalHelper.refundInvoices', {
error,
invoiceId: invoice.id,
});
if (
!(error instanceof RefusedError) &&
!(error instanceof RefundError)
) {
throw error;
}
}
}
return;
}
}

Просмотреть файл

@ -1201,6 +1201,56 @@ export class StripeHelper extends StripeHelperBase {
});
}
/**
* Attempts to refund all of the invoices passed, provided they're created via Stripe
* This will invisibly do nothing if the invoice is not billed through Stripe, so be mindful
* if using it elsewhere and need confirmation of a refund.
*/
async refundInvoices(invoices: Stripe.Invoice[]) {
const stripeInvoices = invoices.filter(
(invoice) => invoice.collection_method === 'charge_automatically'
);
for (const invoice of stripeInvoices) {
const chargeId =
typeof invoice.charge === 'string'
? invoice.charge
: invoice.charge?.id;
if (!chargeId) continue;
const charge = await this.stripe.charges.retrieve(chargeId);
if (charge.refunded) continue;
try {
await this.stripe.refunds.create({
charge: chargeId,
});
this.log.info('refundInvoices', {
invoiceId: invoice.id,
priceId: this.getPriceIdFromInvoice(invoice),
total: invoice.total,
currency: invoice.currency,
});
} catch (error) {
this.log.error('StripeHelper.refundInvoices', {
error,
invoiceId: invoice.id,
});
if (
[
'StripeRateLimitError',
'StripeAPIError',
'StripeConnectionError',
'StripeAuthenticationError',
].includes(error.type)
) {
throw error;
}
}
}
return;
}
/**
* Updates invoice metadata with the PayPal Transaction ID.
*/
@ -1246,6 +1296,13 @@ export class StripeHelper extends StripeHelperBase {
});
}
/**
* Returns the Paypal transaction id for the invoice if one exists.
*/
getInvoicePaypalRefundTransactionId(invoice: Stripe.Invoice) {
return invoice.metadata?.paypalRefundTransactionId;
}
/**
* Returns the Paypal transaction id for the invoice if one exists.
*/
@ -1717,6 +1774,40 @@ export class StripeHelper extends StripeHelperBase {
}
}
async fetchInvoicesForActiveSubscriptions(
customerId: string,
status: Stripe.InvoiceListParams.Status,
earliestCreatedDate?: Date
) {
const customer = await this.fetchCustomer(customerId, ['subscriptions']);
const subscriptions = customer?.subscriptions?.data;
if (subscriptions) {
const activeSubscriptionIds = subscriptions.map((sub) => sub.id);
const created = earliestCreatedDate
? { gte: Math.floor(earliestCreatedDate.getTime() / 1000) }
: undefined;
const invoices = await this.stripe.invoices.list({
customer: customerId,
status,
created,
});
return invoices.data.filter((invoice) => {
if (!invoice?.subscription) {
return false;
}
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription.id;
return activeSubscriptionIds.includes(subscriptionId);
});
}
return [];
}
/**
* On FxA deletion, if the user is a Stripe Customer:
* - delete the stripe customer to delete
@ -2427,6 +2518,15 @@ export class StripeHelper extends StripeHelperBase {
);
}
/**
* Get PriceId of subscription from invoice
*/
getPriceIdFromInvoice(invoice: Stripe.Invoice) {
return invoice.lines.data.find(
(invoiceLine) => invoiceLine.type === 'subscription'
)?.price?.id;
}
/**
* Extract invoice details for billing emails.
*

Просмотреть файл

@ -1858,7 +1858,7 @@ export class AccountHandler {
}
};
await this.accountDeleteManager.deleteAccount(uid, { notify });
await this.accountDeleteManager.deleteAccount(uid, '', { notify });
return {};
}

Просмотреть файл

@ -79,15 +79,22 @@ export class CloudTaskHandler {
}
};
// the account still exists in MySQL, delete as usual
await this.accountDeleteManager.deleteAccount(taskPayload.uid, {
notify,
});
await this.accountDeleteManager.deleteAccount(
taskPayload.uid,
taskPayload.customerId,
{
notify,
}
);
} catch (err) {
// if the account is already deleted from the db, then try to clean up
// some potentially remaining other records
if (err.errno === ERRNO.ACCOUNT_UNKNOWN) {
this.log.info('accountCleanup.byCloudTask', { uid: taskPayload.uid });
await this.accountDeleteManager.cleanupAccount(taskPayload.uid);
await this.accountDeleteManager.cleanupAccount(
taskPayload.uid,
taskPayload.customerId
);
} else {
throw err;
}

Просмотреть файл

@ -12,6 +12,7 @@ const uuid = require('uuid');
const email = 'foo@example.com';
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
const customerId = 'cus_123';
const expectedSubscriptions = [
{ uid, subscriptionId: '123' },
{ uid, subscriptionId: '456' },
@ -75,8 +76,13 @@ describe('AccountDeleteManager', function () {
]);
mockStripeHelper.removeCustomer = sandbox.stub().resolves();
mockStripeHelper.removeFirestoreCustomer = sandbox.stub().resolves();
mockStripeHelper.fetchInvoicesForActiveSubscriptions = sandbox
.stub()
.resolves();
mockStripeHelper.refundInvoices = sandbox.stub().resolves();
mockPaypalHelper = mocks.mockPayPalHelper(['cancelBillingAgreement']);
mockPaypalHelper.cancelBillingAgreement = sandbox.stub().resolves();
mockPaypalHelper.refundInvoices = sandbox.stub().resolves();
mockAuthModels = {};
mockAuthModels.getAllPayPalBAByUid = sinon.spy(async () => {
return [{ status: 'Active', billingAgreementId: 'B-test' }];
@ -223,7 +229,7 @@ describe('AccountDeleteManager', function () {
it('should delete the account', async () => {
const options = { notify: sandbox.stub().resolves() };
await accountDeleteManager.deleteAccount(uid, options);
await accountDeleteManager.deleteAccount(uid, customerId, options);
sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, {
uid,
@ -304,4 +310,101 @@ describe('AccountDeleteManager', function () {
sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid);
});
});
describe('refundSubscriptions', () => {
it('returns immediately when delete reason is not for unverified account', async () => {
await accountDeleteManager.refundSubscriptions('invalid_reason');
sinon.assert.notCalled(
mockStripeHelper.fetchInvoicesForActiveSubscriptions
);
});
it('returns if no invoices are found', async () => {
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
await accountDeleteManager.refundSubscriptions(
'fxa_unverified_account_delete',
'customerid'
);
sinon.assert.calledOnceWithExactly(
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
'customerid',
'paid',
undefined
);
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
});
it('attempts refunds on invoices created within refundPeriod', async () => {
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
await accountDeleteManager.refundSubscriptions(
'fxa_unverified_account_delete',
'customerid',
34
);
sinon.assert.calledOnceWithExactly(
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
'customerid',
'paid',
sinon.match.date
);
sinon.assert.calledOnce(
mockStripeHelper.fetchInvoicesForActiveSubscriptions
);
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
});
it('attempts refunds on invoices', async () => {
const expectedInvoices = ['invoice1', 'invoice2'];
const expectedRefundResult = [
{
invoiceId: 'id1',
priceId: 'priceId1',
total: '123',
currency: 'usd',
},
];
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
expectedInvoices
);
mockStripeHelper.refundInvoices.resolves(expectedRefundResult);
await accountDeleteManager.refundSubscriptions(
'fxa_unverified_account_delete',
'customerId'
);
sinon.assert.calledOnceWithExactly(
mockStripeHelper.refundInvoices,
expectedInvoices
);
sinon.assert.calledOnceWithExactly(
mockPaypalHelper.refundInvoices,
expectedInvoices
);
});
it('rejects on refundInvoices handler exception', async () => {
const expectedInvoices = ['invoice1', 'invoice2'];
const expectedError = new Error('expected');
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
expectedInvoices
);
mockStripeHelper.refundInvoices.rejects(expectedError);
try {
await accountDeleteManager.refundSubscriptions(
'fxa_unverified_account_delete',
'customerId'
);
assert.fail('expecting refundSubscriptions exception');
} catch (error) {
sinon.assert.calledOnceWithExactly(
mockStripeHelper.refundInvoices,
expectedInvoices
);
sinon.assert.calledOnceWithExactly(
mockPaypalHelper.refundInvoices,
expectedInvoices
);
assert.deepEqual(error, expectedError);
}
});
});
});

Просмотреть файл

@ -35,6 +35,7 @@ const {
PAYPAL_APP_ERRORS,
PAYPAL_RETRY_ERRORS,
} = require('../../../lib/payments/paypal/error-codes');
const { RefundError } = require('../../../lib/payments/paypal/helper');
describe('PayPalHelper', () => {
/** @type PayPalHelper */
@ -484,6 +485,163 @@ describe('PayPalHelper', () => {
});
});
describe('refundInvoices', () => {
const validInvoice = {
id: 'id1',
collection_method: 'send_invoice',
created: Date.now(),
};
beforeEach(() => {
paypalHelper.log = {
debug: sinon.fake.returns({}),
info: sinon.fake.returns({}),
error: sinon.fake.returns({}),
};
paypalHelper.issueRefund = sinon.fake.resolves();
});
it('returns empty array if no payPalInvoices exist', async () => {
await paypalHelper.refundInvoices([{ collection_method: 'notpaypal' }]);
sinon.assert.notCalled(paypalHelper.issueRefund);
});
it('returns on empty array input', async () => {
await paypalHelper.refundInvoices([]);
sinon.assert.notCalled(paypalHelper.issueRefund);
});
it('does not refund when created date older than 180 days', async () => {
const expectedErrorMessage =
'Invoice created outside of maximum refund period';
await paypalHelper.refundInvoices([
{
id: validInvoice.id,
collection_method: 'send_invoice',
created: Math.floor(
new Date().setDate(new Date().getDate() - 200) / 1000
),
},
]);
sinon.assert.notCalled(paypalHelper.issueRefund);
sinon.assert.calledWithExactly(
paypalHelper.log.error,
'PayPalHelper.refundInvoices',
{
error: sinon.match
.instanceOf(RefundError)
.and(sinon.match.has('message', expectedErrorMessage)),
invoiceId: validInvoice.id,
}
);
});
it('logs error and continues if transactionId is missing', async () => {
const expectedErrorMessage = 'Missing transactionId';
mockStripeHelper.getInvoicePaypalTransactionId =
sinon.fake.returns(undefined);
await paypalHelper.refundInvoices([validInvoice]);
sinon.assert.notCalled(paypalHelper.issueRefund);
sinon.assert.calledWithExactly(
paypalHelper.log.error,
'PayPalHelper.refundInvoices',
{
error: sinon.match
.instanceOf(RefundError)
.and(sinon.match.has('message', expectedErrorMessage)),
invoiceId: validInvoice.id,
}
);
});
it('continues if refundTransactionId exists', async () => {
const expectedErrorMessage = 'Invoice already refunded with PayPal';
mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123);
mockStripeHelper.getInvoicePaypalRefundTransactionId =
sinon.fake.returns(123);
await paypalHelper.refundInvoices([validInvoice]);
sinon.assert.calledOnce(mockStripeHelper.getInvoicePaypalTransactionId);
sinon.assert.calledOnce(
mockStripeHelper.getInvoicePaypalRefundTransactionId
);
sinon.assert.notCalled(paypalHelper.issueRefund);
sinon.assert.calledWithExactly(
paypalHelper.log.error,
'PayPalHelper.refundInvoices',
{
error: sinon.match
.instanceOf(RefundError)
.and(sinon.match.has('message', expectedErrorMessage)),
invoiceId: validInvoice.id,
}
);
});
it('returns on non-retry-able error', async () => {
const expectedError = new RefusedError('Helper error');
mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123);
mockStripeHelper.getInvoicePaypalRefundTransactionId =
sinon.fake.returns(undefined);
paypalHelper.issueRefund = sinon.fake.rejects(expectedError);
await paypalHelper.refundInvoices([validInvoice]);
sinon.assert.calledWithExactly(
paypalHelper.log.error,
'PayPalHelper.refundInvoices',
{
error: sinon.match
.instanceOf(RefusedError)
.and(sinon.match.has('message', 'Helper error')),
invoiceId: validInvoice.id,
}
);
});
it('rejects on error that can be retried', async () => {
const expectedError = new Error('Helper error');
mockStripeHelper.getInvoicePaypalTransactionId =
sinon.fake.throws(expectedError);
try {
await paypalHelper.refundInvoices([validInvoice]);
assert.fail('Should throw error on failure');
} catch (error) {
sinon.assert.calledWithExactly(
paypalHelper.log.error,
'PayPalHelper.refundInvoices',
{
error: sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'Helper error')),
invoiceId: validInvoice.id,
}
);
assert.deepEqual(error, expectedError);
}
});
it('returns refund results on success', async () => {
const expectedInvoiceResults = {
invoiceId: validInvoice.id,
priceId: 'priceId1',
total: 400,
currency: 'usd',
};
mockStripeHelper.getInvoicePaypalTransactionId =
sinon.fake.returns('123');
mockStripeHelper.getInvoicePaypalRefundTransactionId =
sinon.fake.returns(undefined);
mockStripeHelper.getPriceIdFromInvoice = sinon.fake.returns(
expectedInvoiceResults.priceId
);
await paypalHelper.refundInvoices([
{ ...validInvoice, ...expectedInvoiceResults },
]);
sinon.assert.calledOnceWithExactly(
paypalHelper.log.info,
'refundInvoices',
expectedInvoiceResults
);
sinon.assert.notCalled(paypalHelper.log.error);
});
});
describe('cancelBillingAgreement', () => {
it('cancels an agreement', async () => {
paypalHelper.client.doRequest = sinon.fake.resolves(

Просмотреть файл

@ -2534,6 +2534,54 @@ describe('#integration - StripeHelper', () => {
});
});
describe('refundInvoices', () => {
it('refunds invoice with charge unexpanded', async () => {
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
sandbox
.stub(stripeHelper.stripe.charges, 'retrieve')
.resolves({ refunded: false });
await stripeHelper.refundInvoices([
{
...paidInvoice,
collection_method: 'charge_automatically',
},
]);
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, {
charge: paidInvoice.charge,
});
});
it('refunds invoice with charge expanded', async () => {
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
sandbox
.stub(stripeHelper.stripe.charges, 'retrieve')
.resolves({ refunded: false });
await stripeHelper.refundInvoices([
{
...paidInvoice,
collection_method: 'charge_automatically',
charge: {
id: paidInvoice.charge,
},
},
]);
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, {
charge: paidInvoice.charge,
});
});
it('does not refund invoice from PayPal', async () => {
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
await stripeHelper.refundInvoices([
{
...paidInvoice,
collection_method: 'send_invoice',
},
]);
sinon.assert.notCalled(stripeHelper.stripe.refunds.create);
});
});
describe('updateInvoiceWithPaypalTransactionId', () => {
it('works successfully', async () => {
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
@ -4037,6 +4085,95 @@ describe('#integration - StripeHelper', () => {
});
});
describe('fetchInvoicesForActiveSubscriptions', () => {
it('returns empty array if no stripe customer', async () => {
const noCustomer = '192499bcb0cf4da2bf1b37f1a37f3b88';
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
noCustomer,
'paid'
);
assert.deepEqual(result, []);
});
it('returns empty array if customer has no active subscriptions', async () => {
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({});
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
existingUid,
'paid'
);
assert.deepEqual(result, []);
});
it('fetches invoices no older than earliestCreatedDate', async () => {
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
subscriptions: { data: [] },
});
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({ data: [] });
const expectedDateTime = 1706667661086;
const expectedDate = new Date(expectedDateTime);
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
'customerId',
'paid',
expectedDate
);
assert.deepEqual(result, []);
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.invoices.list, {
customer: 'customerId',
status: 'paid',
created: { gte: Math.floor(expectedDateTime / 1000) },
});
});
it('returns only invoices of active subscriptions', async () => {
const expectedExpanded = {
id: 'idExpanded',
subscription: {
id: 'subIdExpanded',
},
};
const expectedString = {
id: 'idString',
subscription: 'idSub',
};
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
subscriptions: {
data: [
{
id: 'idNull',
},
{
id: 'subIdExpanded',
},
{
id: 'idSub',
},
],
},
});
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({
data: [
{
id: 'idNull',
subscription: null,
},
{
...expectedExpanded,
},
{
...expectedString,
},
],
});
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
existingUid,
'paid'
);
assert.deepEqual(result, [expectedExpanded, expectedString]);
});
});
describe('removeCustomer', () => {
let stripeCustomerDel;
const email = 'test@example.com';

Просмотреть файл

@ -34,9 +34,11 @@ describe('/cloud-tasks/accounts/delete', () => {
mockPush = mocks.mockPush();
sandbox.reset();
deleteAccountStub = sandbox.stub().callsFake((uid, { notify }) => {
notify();
});
deleteAccountStub = sandbox
.stub()
.callsFake((uid, customerId, { notify }) => {
notify();
});
cleanupAccountStub = sandbox.stub().resolves();
Container.set(AccountDeleteManager, {
deleteAccount: deleteAccountStub,
@ -86,6 +88,7 @@ describe('/cloud-tasks/accounts/delete', () => {
event: 'account.deleted',
});
} catch (err) {
console.log(err);
assert.fail('An error should not have been thrown.');
}
});