Skip to content

Commit 7e1568f

Browse files
committed
test: improve GmailExportAdapter tests
1 parent 706939c commit 7e1568f

File tree

4 files changed

+291
-3
lines changed

4 files changed

+291
-3
lines changed

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
"@types/crypto-js": "4.2.2",
116116
"@types/google-apps-script": "1.0.97",
117117
"@types/jest": "29.5.14",
118+
"@types/jest-when": "^3.5.5",
118119
"@types/node": "22.15.3",
119120
"@types/sha1": "1.1.5",
120121
"@typescript-eslint/eslint-plugin": "8.31.1",
@@ -130,6 +131,7 @@
130131
"eta": "3.5.0",
131132
"jest": "29.7.0",
132133
"jest-mock-extended": "3.0.7",
134+
"jest-when": "^3.7.0",
133135
"jscpd": "4.0.5",
134136
"jsdoc": "4.0.4",
135137
"madge": "8.0.0",

src/lib/adapter/GmailExportAdapter.spec.ts

Lines changed: 261 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
1+
import { MockProxy, mock } from "jest-mock-extended"
2+
import { when } from "jest-when"
13
import { GMailMocks } from "../../test/mocks/GMailMocks"
24
import { MockFactory, Mocks } from "../../test/mocks/MockFactory"
35
import { GmailExportAdapter } from "./GmailExportAdapter"
46

7+
// Helper Mocks
58
let mocks: Mocks
9+
let urlFetchApp: MockProxy<GoogleAppsScript.URL_Fetch.UrlFetchApp>
10+
let utilities: MockProxy<GoogleAppsScript.Utilities.Utilities>
611
let adapter: GmailExportAdapter
12+
let avatarBlob: MockProxy<GoogleAppsScript.Base.Blob>
13+
let avatarUrlResponse: MockProxy<GoogleAppsScript.URL_Fetch.HTTPResponse>
714

8-
beforeEach(() => {
15+
beforeAll(() => {
916
mocks = MockFactory.newMocks()
17+
urlFetchApp = mocks.envContext.env
18+
.urlFetchApp as MockProxy<GoogleAppsScript.URL_Fetch.UrlFetchApp>
19+
utilities = mocks.envContext.env
20+
.utilities as MockProxy<GoogleAppsScript.Utilities.Utilities>
1021
adapter = new GmailExportAdapter(
1122
mocks.envContext,
1223
mocks.processingContext.proc.config.settings,
1324
)
25+
avatarBlob = mock<GoogleAppsScript.Base.Blob>()
26+
avatarUrlResponse = mock<GoogleAppsScript.URL_Fetch.HTTPResponse>()
27+
})
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks()
1431
})
1532

1633
const emailHeader =
@@ -30,6 +47,99 @@ it("should generate a HTML message without header", () => {
3047
expect(actual).not.toContain(emailHeader)
3148
})
3249

50+
describe("generateMessageHtmlHeader", () => {
51+
it("should generate a HTML header with avatar", () => {
52+
// Arrange
53+
const senderEmail = "test1@example.com"
54+
const avatarContentType = "image/png"
55+
const avatarBytes = [1, 2, 3, 4] // Some dummy bytes
56+
const avatarBase64 = "AQIDBA==" // Base64 representation of [1, 2, 3, 4]
57+
const expectedDataUri = `data:${avatarContentType};base64,${avatarBase64}`
58+
59+
const message =
60+
mocks.message as MockProxy<GoogleAppsScript.Gmail.GmailMessage>
61+
62+
urlFetchApp.fetch
63+
.mockReturnValue(avatarUrlResponse)
64+
.mockName("fetch-avatar")
65+
avatarUrlResponse.getBlob.mockReturnValue(avatarBlob)
66+
avatarUrlResponse.getResponseCode.mockReturnValue(200) // Simulate successful fetch
67+
avatarBlob.getContentType.mockReturnValue(avatarContentType)
68+
avatarBlob.getBytes.mockReturnValue(avatarBytes)
69+
utilities.base64Encode.mockReturnValue(avatarBase64)
70+
message.getFrom.mockReturnValue(senderEmail)
71+
72+
// Act
73+
const actual = adapter.generateMessageHtmlHeader(mocks.message, {
74+
embedAvatar: true,
75+
includeHeader: true,
76+
})
77+
78+
// Assert
79+
expect(urlFetchApp.fetch).toHaveBeenCalled()
80+
expect(utilities.base64Encode).toHaveBeenCalledWith(avatarBytes)
81+
expect(actual).toContain(
82+
`<dd class="avatar"><img src="${expectedDataUri}" /></dd>`,
83+
)
84+
expect(actual).toContain(
85+
`<a href="mailto:${senderEmail}">${senderEmail}</a>`,
86+
)
87+
})
88+
89+
it("should generate a HTML header without avatar if none is available", () => {
90+
// Arrange
91+
const senderEmail = "test2@example.com"
92+
const avatarUrlResponse: MockProxy<GoogleAppsScript.URL_Fetch.HTTPResponse> =
93+
mock<GoogleAppsScript.URL_Fetch.HTTPResponse>()
94+
const message =
95+
mocks.message as MockProxy<GoogleAppsScript.Gmail.GmailMessage>
96+
97+
urlFetchApp.fetch
98+
.mockReturnValue(avatarUrlResponse)
99+
.mockName("fetch-avatar")
100+
avatarUrlResponse.getResponseCode.mockReturnValue(404) // Simulate failed fetch
101+
message.getFrom.mockReturnValue(senderEmail)
102+
103+
// Act
104+
const actual = adapter.generateMessageHtmlHeader(mocks.message, {
105+
embedAvatar: true,
106+
includeHeader: true,
107+
})
108+
109+
// Assert
110+
expect(urlFetchApp.fetch).toHaveBeenCalled()
111+
expect(avatarUrlResponse.getBlob).not.toHaveBeenCalled()
112+
expect(utilities.base64Encode).not.toHaveBeenCalled()
113+
expect(actual).not.toContain(`<dd class="avatar">`)
114+
expect(actual).toContain(
115+
`<a href="mailto:${senderEmail}">${senderEmail}</a>`,
116+
)
117+
})
118+
119+
it("should include CC and BCC addresses in the header", () => {
120+
// Arrange
121+
const message =
122+
mocks.message as MockProxy<GoogleAppsScript.Gmail.GmailMessage>
123+
const ccEmail = "cc@example.com"
124+
const bccEmail = "bcc@example.com"
125+
message.getCc.mockReturnValue(ccEmail)
126+
message.getBcc.mockReturnValue(bccEmail)
127+
128+
// Act
129+
const actual = adapter.generateMessageHtmlHeader(message, {
130+
includeHeader: true,
131+
})
132+
133+
// Assert
134+
expect(actual).toContain(
135+
`<dt>cc:</dt><dd><a href="mailto:${ccEmail}">${ccEmail}</a></dd>`,
136+
)
137+
expect(actual).toContain(
138+
`<dt>bcc:</dt><dd><a href="mailto:${bccEmail}">${bccEmail}</a></dd>`,
139+
)
140+
})
141+
})
142+
33143
it("should process data urls in HTML", () => {
34144
const img =
35145
'<img width="16" height="16" alt="tick" src="data:image/gif;base64,R0lGODdhEAAQAMwAAPj7+FmhUYjNfGuxYYDJdYTIeanOpT+DOTuANXi/bGOrWj6CONzv2sPjv2CmV1unU4zPgISg6DJnJ3ImTh8Mtbs00aNP1CZSGy0YqLEn47RgXW8amasW7XWsmmvX2iuXiwAAAAAEAAQAAAFVyAgjmRpnihqGCkpDQPbGkNUOFk6DZqgHCNGg2T4QAQBoIiRSAwBE4VA4FACKgkB5NGReASFZEmxsQ0whPDi9BiACYQAInXhwOUtgCUQoORFCGt/g4QAIQA7">'
@@ -45,3 +155,153 @@ it("should process data urls in HTML", () => {
45155
})
46156
expect(actual).toContain(img)
47157
})
158+
159+
describe("Image Embedding", () => {
160+
const remoteImageUrl = "http://example.com/remote.png"
161+
const remoteImageContentType = "image/png"
162+
const remoteImageBytes: number[] = [5, 6, 7, 8]
163+
const remoteImageBase64 = "BQYHCA=="
164+
const remoteImageDataUri = `data:${remoteImageContentType};base64,${remoteImageBase64}`
165+
166+
const inlineImageCid = "?view=att&th=inline_123"
167+
const inlineImageContentType = "image/jpeg"
168+
const inlineImageBytes: number[] = [9, 10, 11, 12]
169+
const inlineImageBase64 = "CQoLDA=="
170+
const inlineImageDataUri = `data:${inlineImageContentType};base64,${inlineImageBase64}`
171+
172+
const failedImageUrl = "http://example.com/failed.png"
173+
174+
beforeEach(() => {
175+
const remoteResponse = mock<GoogleAppsScript.URL_Fetch.HTTPResponse>()
176+
const failedResponse = mock<GoogleAppsScript.URL_Fetch.HTTPResponse>()
177+
const remoteBlob = mock<GoogleAppsScript.Base.Blob>()
178+
const failedBlob = mock<GoogleAppsScript.Base.Blob>()
179+
when(urlFetchApp.fetch)
180+
.calledWith(remoteImageUrl, expect.anything())
181+
.mockReturnValue(remoteResponse)
182+
.calledWith(failedImageUrl, expect.anything())
183+
.mockReturnValue(failedResponse)
184+
remoteResponse.getResponseCode.mockReturnValue(200)
185+
remoteResponse.getBlob.mockReturnValue(remoteBlob)
186+
remoteBlob.getContentType.mockReturnValue(remoteImageContentType)
187+
remoteBlob.getBytes.mockReturnValue(remoteImageBytes)
188+
failedResponse.getResponseCode.mockReturnValue(404)
189+
failedResponse.getBlob.mockReturnValue(failedBlob)
190+
failedBlob.getContentType.mockReturnValue(null)
191+
failedBlob.getBytes.mockReturnValue([])
192+
utilities.base64Encode.mockImplementation(
193+
(bytes: string, _charset: unknown) => {
194+
if (String(remoteImageBytes) === String(bytes)) {
195+
return remoteImageBase64
196+
}
197+
if (String(inlineImageBytes) === String(bytes)) {
198+
return inlineImageBase64
199+
}
200+
return ""
201+
},
202+
)
203+
const inlineBlob = mock<
204+
GoogleAppsScript.Base.Blob & GoogleAppsScript.Gmail.GmailAttachment
205+
>()
206+
inlineBlob.getContentType.mockReturnValue(inlineImageContentType)
207+
inlineBlob.getBytes.mockReturnValue(inlineImageBytes)
208+
inlineBlob.copyBlob.mockReturnValue(inlineBlob) // Return self for copyBlob
209+
})
210+
211+
it("should embed remote images in <img> tags", () => {
212+
const body = `<p>Remote image: <img src="${remoteImageUrl}"></p>`
213+
const message = GMailMocks.newMessageMock({ body })
214+
const actual = adapter.generateMessagesHtml([message], {
215+
embedRemoteImages: true,
216+
includeHeader: false,
217+
})
218+
expect(actual).toContain(`<img src="${remoteImageDataUri}">`)
219+
expect(urlFetchApp.fetch).toHaveBeenCalledWith(
220+
remoteImageUrl,
221+
expect.anything(),
222+
)
223+
expect(utilities.base64Encode).toHaveBeenCalledWith(remoteImageBytes)
224+
})
225+
226+
it("should embed remote images in style attributes", () => {
227+
const body = `<p style="background: url('${remoteImageUrl}')">Styled</p>`
228+
const message = GMailMocks.newMessageMock({ body })
229+
const actual = adapter.generateMessagesHtml([message], {
230+
embedRemoteImages: true,
231+
includeHeader: false,
232+
})
233+
expect(actual).toContain(`style="background: url('${remoteImageDataUri}')"`)
234+
expect(urlFetchApp.fetch).toHaveBeenCalledWith(
235+
remoteImageUrl,
236+
expect.anything(),
237+
)
238+
expect(utilities.base64Encode).toHaveBeenCalledWith(remoteImageBytes)
239+
})
240+
241+
it("should embed remote images in <style> tags", () => {
242+
const body = `<style>.bg { background-image: url("${remoteImageUrl}"); }</style><p class="bg">Styled</p>`
243+
const message = GMailMocks.newMessageMock({ body })
244+
const actual = adapter.generateMessagesHtml([message], {
245+
embedRemoteImages: true,
246+
includeHeader: false,
247+
})
248+
expect(actual).toContain(`background-image: url("${remoteImageDataUri}");`)
249+
expect(urlFetchApp.fetch).toHaveBeenCalledWith(
250+
remoteImageUrl,
251+
expect.anything(),
252+
)
253+
expect(utilities.base64Encode).toHaveBeenCalledWith(remoteImageBytes)
254+
})
255+
256+
it("should embed inline images in <img> tags", () => {
257+
const body = `<p>Inline image: <img src="${inlineImageCid}"></p>`
258+
const message = GMailMocks.newMessageMock({ body })
259+
const inlineBlob = mock<
260+
GoogleAppsScript.Gmail.GmailAttachment & GoogleAppsScript.Base.Blob
261+
>()
262+
inlineBlob.getContentType.mockReturnValue(inlineImageContentType)
263+
inlineBlob.getBytes.mockReturnValue(inlineImageBytes)
264+
inlineBlob.copyBlob.mockReturnValue(inlineBlob)
265+
message.getAttachments.mockReturnValue([inlineBlob])
266+
const actual = adapter.generateMessagesHtml([message], {
267+
embedInlineImages: true,
268+
includeHeader: false,
269+
})
270+
expect(actual).toContain(`<img src="${inlineImageDataUri}">`)
271+
expect(
272+
(message as MockProxy<GoogleAppsScript.Gmail.GmailMessage>)
273+
.getAttachments,
274+
).toHaveBeenCalledWith({
275+
includeInlineImages: true,
276+
includeAttachments: false,
277+
})
278+
expect(utilities.base64Encode).toHaveBeenCalledWith(inlineImageBytes)
279+
})
280+
281+
it("should keep original src if remote image fetch fails", () => {
282+
const body = `<p>Remote image: <img src="${failedImageUrl}"></p>`
283+
const message = GMailMocks.newMessageMock({ body })
284+
const actual = adapter.generateMessagesHtml([message], {
285+
embedRemoteImages: true,
286+
includeHeader: false,
287+
})
288+
expect(actual).toContain(`<img src="${failedImageUrl}">`)
289+
})
290+
291+
it("should keep original src if inline image is missing", () => {
292+
const body = `<p>Inline image: <img src="${inlineImageCid}"></p>`
293+
const message = GMailMocks.newMessageMock({ body })
294+
message.getAttachments.mockReturnValue([]) // No inline attachments found
295+
const actual = adapter.generateMessagesHtml([message], {
296+
embedInlineImages: true,
297+
includeHeader: false,
298+
})
299+
expect(actual).toContain(`<img src="${inlineImageCid}">`)
300+
expect(utilities.base64Encode).not.toHaveBeenCalledWith(inlineImageBytes) // Ensure it wasn't called for this image
301+
})
302+
})
303+
304+
// TODO: Add tests for Attachment Handling (includeAttachments, embedAttachments)
305+
// - Test embedding image attachments
306+
// - Test listing non-image attachments when embedding is on
307+
// - Test listing all attachments when embedding is off

src/lib/adapter/GmailExportAdapter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ export class GmailExportAdapter extends BaseAdapter {
9797
imageBlob = this.fetchRemoteFile(image)
9898
} else if (typeof image === "object") {
9999
imageBlob = image as GoogleAppsScript.Base.Blob // Note: GmailAttachment has all methods of Blob
100+
} else {
101+
this.ctx.log.warn(
102+
`GMailExportAdapter.getDataUri(): Invalid image object '${String(image)}'`,
103+
)
100104
}
101105
if (!imageBlob) {
102106
return null
103107
}
104108
if (imageBlob.getContentType()) {
105109
const type = imageBlob.getContentType()?.toLowerCase()
106-
const data = Utilities.base64Encode(imageBlob.getBytes())
110+
const data = this.ctx.env.utilities.base64Encode(imageBlob.getBytes())
107111
if (type?.indexOf("image") === 0) {
108112
const dataUrl = "data:" + type + ";base64," + data
109113
return dataUrl
@@ -196,7 +200,7 @@ export class GmailExportAdapter extends BaseAdapter {
196200
)
197201
}
198202

199-
protected generateMessageHtmlHeader(
203+
public generateMessageHtmlHeader(
200204
message: GoogleAppsScript.Gmail.GmailMessage,
201205
opts: ExportOptionsType,
202206
): string {

0 commit comments

Comments
 (0)