Replace email mocks and real email sending with maildev.
This commit is contained in:
parent
92b391e9ef
commit
478d28285b
|
@ -85,12 +85,14 @@
|
|||
"@types/jsonwebtoken": "9.0.3",
|
||||
"@types/koa__router": "12.0.4",
|
||||
"@types/lodash": "4.14.200",
|
||||
"@types/maildev": "^0.0.7",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/server-destroy": "1.0.1",
|
||||
"@types/supertest": "2.0.14",
|
||||
"@types/uuid": "8.3.4",
|
||||
"jest": "29.7.0",
|
||||
"maildev": "^2.2.1",
|
||||
"nock": "^13.5.4",
|
||||
"nodemon": "2.0.15",
|
||||
"rimraf": "3.0.2",
|
||||
|
|
|
@ -1,33 +1,97 @@
|
|||
jest.mock("nodemailer")
|
||||
import { EmailTemplatePurpose } from "@budibase/types"
|
||||
import { TestConfiguration, mocks } from "../../../../tests"
|
||||
|
||||
const sendMailMock = mocks.email.mock()
|
||||
import { TestConfiguration } from "../../../../tests"
|
||||
import {
|
||||
captureEmail,
|
||||
deleteAllEmail,
|
||||
getAttachment,
|
||||
getUnusedPort,
|
||||
Mailserver,
|
||||
startMailserver,
|
||||
stopMailserver,
|
||||
} from "../../../../tests/mocks/email"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
|
||||
describe("/api/global/email", () => {
|
||||
const config = new TestConfiguration()
|
||||
let mailserver: Mailserver
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.beforeAll()
|
||||
const port = await getUnusedPort()
|
||||
mailserver = await startMailserver(config, { smtp: port })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await stopMailserver(mailserver)
|
||||
await config.afterAll()
|
||||
})
|
||||
|
||||
it("should be able to send an email (with mocking)", async () => {
|
||||
// initially configure settings
|
||||
await config.saveSmtpConfig()
|
||||
await config.saveSettingsConfig()
|
||||
beforeEach(async () => {
|
||||
await deleteAllEmail(mailserver)
|
||||
})
|
||||
|
||||
const res = await config.api.emails.sendEmail(
|
||||
EmailTemplatePurpose.INVITATION
|
||||
it.each([
|
||||
{
|
||||
purpose: EmailTemplatePurpose.WELCOME,
|
||||
expectedText: `Thanks for getting started with Budibase's Budibase platform.`,
|
||||
},
|
||||
{
|
||||
purpose: EmailTemplatePurpose.INVITATION,
|
||||
expectedText: `Use the button below to set up your account and get started:`,
|
||||
},
|
||||
{
|
||||
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
|
||||
expectedText: `You recently requested to reset your password for your Budibase account in your Budibase platform`,
|
||||
},
|
||||
])("can send $purpose emails", async ({ purpose, expectedText }) => {
|
||||
const email = await captureEmail(mailserver, async () => {
|
||||
const res = await config.api.emails.sendEmail(purpose)
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
expect(email.html).toContain(expectedText)
|
||||
expect(email.html).not.toContain("Invalid binding")
|
||||
})
|
||||
|
||||
it("should be able to send an email with an attachment", async () => {
|
||||
let bucket = "testbucket"
|
||||
let filename = "test.txt"
|
||||
await objectStore.upload({
|
||||
bucket,
|
||||
filename,
|
||||
body: Buffer.from("test data"),
|
||||
})
|
||||
let presignedUrl = await objectStore.getPresignedUrl(
|
||||
bucket,
|
||||
filename,
|
||||
60000
|
||||
)
|
||||
|
||||
let attachmentObject = {
|
||||
url: presignedUrl,
|
||||
filename,
|
||||
}
|
||||
|
||||
const email = await captureEmail(mailserver, async () => {
|
||||
const res = await config.api.emails.sendEmail(
|
||||
EmailTemplatePurpose.WELCOME,
|
||||
[attachmentObject]
|
||||
)
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(sendMailMock).toHaveBeenCalled()
|
||||
const emailCall = sendMailMock.mock.calls[0][0]
|
||||
expect(emailCall.subject).toBe("Hello!")
|
||||
expect(emailCall.html).not.toContain("Invalid binding")
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
expect(email.html).toContain(
|
||||
"Thanks for getting started with Budibase's Budibase platform."
|
||||
)
|
||||
expect(email.html).not.toContain("Invalid binding")
|
||||
|
||||
const attachment = await getAttachment(
|
||||
mailserver,
|
||||
email,
|
||||
email.attachments[0]
|
||||
)
|
||||
expect(attachment).toEqual("test data")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
jest.unmock("node-fetch")
|
||||
import { TestConfiguration } from "../../../../tests"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
import tk from "timekeeper"
|
||||
import { EmailAttachment, EmailTemplatePurpose } from "@budibase/types"
|
||||
|
||||
const fetch = require("node-fetch")
|
||||
|
||||
const nodemailer = require("nodemailer")
|
||||
|
||||
// for the real email tests give them a long time to try complete/fail
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("/api/global/email", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
tk.reset()
|
||||
await config.beforeAll()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await config.afterAll()
|
||||
})
|
||||
|
||||
async function sendRealEmail(
|
||||
purpose: string,
|
||||
attachments?: EmailAttachment[]
|
||||
) {
|
||||
let response, text
|
||||
try {
|
||||
await helpers.withTimeout(20000, () => config.saveEtherealSmtpConfig())
|
||||
await helpers.withTimeout(20000, () => config.saveSettingsConfig())
|
||||
let res
|
||||
if (attachments) {
|
||||
res = await config.api.emails
|
||||
.sendEmail(purpose, attachments)
|
||||
.timeout(20000)
|
||||
} else {
|
||||
res = await config.api.emails.sendEmail(purpose).timeout(20000)
|
||||
}
|
||||
// ethereal hiccup, can't test right now
|
||||
if (res.status >= 300) {
|
||||
return
|
||||
}
|
||||
expect(res.body.message).toBeDefined()
|
||||
const testUrl = nodemailer.getTestMessageUrl(res.body)
|
||||
expect(testUrl).toBeDefined()
|
||||
response = await fetch(testUrl)
|
||||
text = await response.text()
|
||||
} catch (err: any) {
|
||||
// ethereal hiccup, can't test right now
|
||||
if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) {
|
||||
return
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
let toCheckFor
|
||||
switch (purpose) {
|
||||
case EmailTemplatePurpose.WELCOME:
|
||||
toCheckFor = `Thanks for getting started with Budibase's Budibase platform.`
|
||||
break
|
||||
case EmailTemplatePurpose.INVITATION:
|
||||
toCheckFor = `Use the button below to set up your account and get started:`
|
||||
break
|
||||
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
||||
toCheckFor = `You recently requested to reset your password for your Budibase account in your Budibase platform`
|
||||
break
|
||||
}
|
||||
expect(text).toContain(toCheckFor)
|
||||
}
|
||||
|
||||
it("should be able to send a welcome email", async () => {
|
||||
await sendRealEmail(EmailTemplatePurpose.WELCOME)
|
||||
})
|
||||
|
||||
it("should be able to send a invitation email", async () => {
|
||||
await sendRealEmail(EmailTemplatePurpose.INVITATION)
|
||||
})
|
||||
|
||||
it("should be able to send a password recovery email", async () => {
|
||||
await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
|
||||
})
|
||||
|
||||
it("should be able to send an email with attachments", async () => {
|
||||
let bucket = "testbucket"
|
||||
let filename = "test.txt"
|
||||
await objectStore.upload({
|
||||
bucket,
|
||||
filename,
|
||||
body: Buffer.from("test data"),
|
||||
})
|
||||
let presignedUrl = await objectStore.getPresignedUrl(
|
||||
bucket,
|
||||
filename,
|
||||
60000
|
||||
)
|
||||
|
||||
let attachmentObject = {
|
||||
url: presignedUrl,
|
||||
filename,
|
||||
}
|
||||
await sendRealEmail(EmailTemplatePurpose.WELCOME, [attachmentObject])
|
||||
})
|
||||
})
|
|
@ -32,6 +32,8 @@ import {
|
|||
AuthToken,
|
||||
SCIMConfig,
|
||||
ConfigType,
|
||||
SMTPConfig,
|
||||
SMTPInnerConfig,
|
||||
} from "@budibase/types"
|
||||
import API from "./api"
|
||||
import jwt, { Secret } from "jsonwebtoken"
|
||||
|
@ -348,9 +350,15 @@ class TestConfiguration {
|
|||
|
||||
// CONFIGS - SMTP
|
||||
|
||||
async saveSmtpConfig() {
|
||||
async saveSmtpConfig(config?: SMTPInnerConfig) {
|
||||
await this.deleteConfig(Config.SMTP)
|
||||
await this._req(structures.configs.smtp(), null, controllers.config.save)
|
||||
|
||||
let smtpConfig: SMTPConfig = structures.configs.smtp()
|
||||
if (config) {
|
||||
smtpConfig = { type: ConfigType.SMTP, config }
|
||||
}
|
||||
|
||||
await this._req(smtpConfig, null, controllers.config.save)
|
||||
}
|
||||
|
||||
async saveEtherealSmtpConfig() {
|
||||
|
|
|
@ -1,10 +1,148 @@
|
|||
export function mock() {
|
||||
// mock the email system
|
||||
const sendMailMock = jest.fn()
|
||||
const nodemailer = require("nodemailer")
|
||||
nodemailer.createTransport.mockReturnValue({
|
||||
sendMail: sendMailMock,
|
||||
verify: jest.fn(),
|
||||
})
|
||||
return sendMailMock
|
||||
import MailDev from "maildev"
|
||||
import { promisify } from "util"
|
||||
import TestConfiguration from "../TestConfiguration"
|
||||
|
||||
export type Mailserver = InstanceType<typeof MailDev>
|
||||
export type MailserverConfig = ConstructorParameters<typeof MailDev>[0]
|
||||
|
||||
export interface Attachment {
|
||||
checksum: string
|
||||
contentId: string
|
||||
contentType: string
|
||||
fileName: string
|
||||
generatedFileName: string
|
||||
length: number
|
||||
transferEncoding: string
|
||||
transformed: boolean
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
address: string
|
||||
args?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface Envelope {
|
||||
from: Address
|
||||
to: Address[]
|
||||
host: string
|
||||
remoteAddress: string
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
attachments: Attachment[]
|
||||
calculatedBcc: string[]
|
||||
date: string
|
||||
envelope: Envelope
|
||||
from: Address[]
|
||||
headers: Record<string, string>
|
||||
html: string
|
||||
id: string
|
||||
messageId: string
|
||||
priority: string
|
||||
read: boolean
|
||||
size: number
|
||||
sizeHuman: string
|
||||
source: string
|
||||
time: Date
|
||||
to: Address[]
|
||||
}
|
||||
|
||||
export function getUnusedPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = require("net").createServer()
|
||||
server.unref()
|
||||
server.on("error", reject)
|
||||
server.listen(0, () => {
|
||||
const port = server.address().port
|
||||
server.close(() => {
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForEmail(
|
||||
mailserver: Mailserver,
|
||||
timeoutMs = 5000
|
||||
): Promise<Email> {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error("Timed out waiting for email"))
|
||||
}, timeoutMs)
|
||||
})
|
||||
const mailPromise = new Promise<Email>(resolve => {
|
||||
// @ts-expect-error - types are wrong
|
||||
mailserver.once("new", email => {
|
||||
resolve(email as Email)
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
})
|
||||
return Promise.race([mailPromise, timeoutPromise])
|
||||
}
|
||||
|
||||
export async function captureEmail(
|
||||
mailserver: Mailserver,
|
||||
f: () => Promise<void>
|
||||
): Promise<Email> {
|
||||
const emailPromise = waitForEmail(mailserver)
|
||||
await f()
|
||||
return await emailPromise
|
||||
}
|
||||
|
||||
export async function startMailserver(
|
||||
config: TestConfiguration,
|
||||
opts?: MailserverConfig
|
||||
): Promise<Mailserver> {
|
||||
if (!opts) {
|
||||
opts = {}
|
||||
}
|
||||
if (!opts.smtp) {
|
||||
opts.smtp = await getUnusedPort()
|
||||
}
|
||||
const mailserver = new MailDev(opts || {})
|
||||
await new Promise((resolve, reject) => {
|
||||
mailserver.listen(err => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
resolve(mailserver)
|
||||
})
|
||||
})
|
||||
await config.saveSmtpConfig({
|
||||
host: "localhost",
|
||||
port: opts.smtp,
|
||||
secure: false,
|
||||
from: "test@example.com",
|
||||
})
|
||||
return mailserver
|
||||
}
|
||||
|
||||
export function deleteAllEmail(mailserver: Mailserver) {
|
||||
return promisify(mailserver.deleteAllEmail).bind(mailserver)()
|
||||
}
|
||||
|
||||
export function stopMailserver(mailserver: Mailserver) {
|
||||
return promisify(mailserver.close).bind(mailserver)()
|
||||
}
|
||||
|
||||
export function getAttachment(
|
||||
mailserver: Mailserver,
|
||||
email: Email,
|
||||
attachment: Attachment
|
||||
) {
|
||||
return new Promise<string>(resolve => {
|
||||
// @ts-expect-error - types are wrong
|
||||
mailserver.getEmailAttachment(
|
||||
email.id,
|
||||
attachment.generatedFileName,
|
||||
(err: any, _contentType: string, stream: ReadableStream) => {
|
||||
if (err) {
|
||||
throw err
|
||||
}
|
||||
resolve(new Response(stream).text())
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
SendEmailOpts,
|
||||
SMTPInnerConfig,
|
||||
EmailTemplatePurpose,
|
||||
User,
|
||||
} from "@budibase/types"
|
||||
import { configs, cache, objectStore } from "@budibase/backend-core"
|
||||
import ical from "ical-generator"
|
||||
|
@ -71,8 +72,8 @@ function createSMTPTransport(config?: SMTPInnerConfig) {
|
|||
async function buildEmail(
|
||||
purpose: EmailTemplatePurpose,
|
||||
email: string,
|
||||
context: any,
|
||||
{ user, contents }: any = {}
|
||||
context: Record<string, any>,
|
||||
{ user, contents }: { user?: User; contents?: string } = {}
|
||||
) {
|
||||
// this isn't a full email
|
||||
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
|
||||
|
@ -90,8 +91,8 @@ async function buildEmail(
|
|||
throw "Unable to build email, missing base components"
|
||||
}
|
||||
|
||||
let name = user ? user.name : undefined
|
||||
if (user && !name && user.firstName) {
|
||||
let name: string | undefined
|
||||
if (user && user.firstName) {
|
||||
name = user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName
|
||||
}
|
||||
context = {
|
||||
|
|
Loading…
Reference in New Issue