From 2c739d0a0cb1a4648942fb234f168e2b8a01afc9 Mon Sep 17 00:00:00 2001
From: mic Hello
');
}
-function convertBrToNewlines(html) {
+export function convertBrToNewlines(html) {
return html.replace(/
/gi, '\n');
}
@@ -374,7 +374,7 @@ export function i18nConditionalGet(str) {
return str; // Return the original string if the conditions are not met
}
-function compareThunderbirdVersions(v1, v2) {
+export function compareThunderbirdVersions(v1, v2) {
const v1parts = v1.split('.').map(Number);
const v2parts = v2.split('.').map(Number);
@@ -472,7 +472,7 @@ export async function assignTagsToMessage(messageId, tags) {
}
}
-function getTagsKeyFromLabel(tag_names, all_tags_list) {
+export function getTagsKeyFromLabel(tag_names, all_tags_list) {
const result = [];
tag_names.forEach(name => {
@@ -488,7 +488,7 @@ function getTagsKeyFromLabel(tag_names, all_tags_list) {
return result;
}
-function sanitizeString(input) {
+export function sanitizeString(input) {
// Define the regex to match valid characters
const validChar = /^[^ ()/{%*<>"]+$/;
// Filter out invalid characters from the string
@@ -799,7 +799,7 @@ export async function getLocalStorageUsedSpace(){
return formatBytes(customprompts_space);
}
-function formatBytes(bytes, decimals = 2) {
+export function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const step = 1024;
const suffixes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..6febc6a7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "private": true,
+ "description": "Dev-only: unit tests for ThunderAI. Not used by the addon.",
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "vitest": "^3.1.1",
+ "jsdom": "^26.1.0"
+ }
+}
diff --git a/tests/mzta-logger.test.js b/tests/mzta-logger.test.js
new file mode 100644
index 00000000..bab18f22
--- /dev/null
+++ b/tests/mzta-logger.test.js
@@ -0,0 +1,59 @@
+import { taLogger } from '../js/mzta-logger.js';
+
+describe('taLogger', () => {
+ let logSpy, errorSpy, warnSpy;
+
+ beforeEach(() => {
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('sets prefix and debug flag in constructor', () => {
+ const logger = new taLogger('Test', true);
+ expect(logger.prefix).toBe('[ThunderAI Logger | Test] ');
+ expect(logger.do_debug).toBe(true);
+ });
+
+ it('log() outputs when debug is true', () => {
+ const logger = new taLogger('Test', true);
+ logger.log('hello');
+ expect(logSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] hello');
+ });
+
+ it('log() is silent when debug is false', () => {
+ const logger = new taLogger('Test', false);
+ logger.log('hello');
+ expect(logSpy).not.toHaveBeenCalled();
+ });
+
+ it('log() with do_debug parameter changes debug state', () => {
+ const logger = new taLogger('Test', false);
+ logger.log('hello', true);
+ expect(logSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] hello');
+ expect(logger.do_debug).toBe(true);
+ });
+
+ it('error() always outputs regardless of debug flag', () => {
+ const logger = new taLogger('Test', false);
+ logger.error('something broke');
+ expect(errorSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] something broke');
+ });
+
+ it('warn() always outputs regardless of debug flag', () => {
+ const logger = new taLogger('Test', false);
+ logger.warn('watch out');
+ expect(warnSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] watch out');
+ });
+
+ it('changeDebug() updates the debug flag', () => {
+ const logger = new taLogger('Test', false);
+ expect(logger.do_debug).toBe(false);
+ logger.changeDebug(true);
+ expect(logger.do_debug).toBe(true);
+ });
+});
diff --git a/tests/mzta-placeholders.test.js b/tests/mzta-placeholders.test.js
new file mode 100644
index 00000000..bfda1f43
--- /dev/null
+++ b/tests/mzta-placeholders.test.js
@@ -0,0 +1,206 @@
+import {
+ mapPlaceholderToSuggestion,
+ prepareCustomDataPHsForExport,
+ placeholdersUtils,
+} from '../js/mzta-placeholders.js';
+
+// ─── mapPlaceholderToSuggestion ──────────────────────────────────
+
+describe('mapPlaceholderToSuggestion', () => {
+ it('transforms non-dynamic placeholder', () => {
+ const p = { id: 'mail_subject', type: 'default', is_dynamic: 0 };
+ expect(mapPlaceholderToSuggestion(p)).toEqual({
+ command: '{%mail_subject%}',
+ type: 'default',
+ is_dynamic: 0,
+ });
+ });
+
+ it('transforms dynamic placeholder (adds colon)', () => {
+ const p = { id: 'mail_headers', type: 'default', is_dynamic: 1 };
+ expect(mapPlaceholderToSuggestion(p)).toEqual({
+ command: '{%mail_headers:%}',
+ type: 'default',
+ is_dynamic: 1,
+ });
+ });
+});
+
+// ─── prepareCustomDataPHsForExport ───────────────────────────────
+
+describe('prepareCustomDataPHsForExport', () => {
+ it('removes idnum from non-default placeholders', () => {
+ const placeholders = [
+ { id: 'thunderai_custom_test', is_default: 0, idnum: 1, text: 'hello' },
+ ];
+ const result = prepareCustomDataPHsForExport(placeholders);
+ expect(result[0]).not.toHaveProperty('idnum');
+ expect(result[0].text).toBe('hello');
+ });
+
+ it('keeps idnum for default placeholders', () => {
+ const placeholders = [
+ { id: 'mail_subject', is_default: 1, idnum: 1 },
+ ];
+ const result = prepareCustomDataPHsForExport(placeholders);
+ expect(result[0].idnum).toBe(1);
+ });
+});
+
+// ─── placeholdersUtils ───────────────────────────────────────────
+
+describe('placeholdersUtils.validateCustomDataPH_ID', () => {
+ it('prepends prefix if missing', () => {
+ expect(placeholdersUtils.validateCustomDataPH_ID('my_ph')).toBe('thunderai_custom_my_ph');
+ });
+
+ it('returns unchanged if prefix already present', () => {
+ expect(placeholdersUtils.validateCustomDataPH_ID('thunderai_custom_my_ph')).toBe('thunderai_custom_my_ph');
+ });
+});
+
+describe('placeholdersUtils.stripCustomDataPH_ID_Prefix', () => {
+ it('removes prefix if present', () => {
+ expect(placeholdersUtils.stripCustomDataPH_ID_Prefix('thunderai_custom_my_ph')).toBe('my_ph');
+ });
+
+ it('returns unchanged if prefix not present', () => {
+ expect(placeholdersUtils.stripCustomDataPH_ID_Prefix('other_ph')).toBe('other_ph');
+ });
+});
+
+describe('placeholdersUtils.hasPlaceholder', () => {
+ it('detects any placeholder when no specific placeholder given', () => {
+ expect(placeholdersUtils.hasPlaceholder('Hello {%mail_subject%}')).toBe(true);
+ });
+
+ it('returns false when no placeholder present', () => {
+ expect(placeholdersUtils.hasPlaceholder('Hello world')).toBe(false);
+ });
+
+ it('detects specific placeholder', () => {
+ expect(placeholdersUtils.hasPlaceholder('Text {%mail_subject%}', 'mail_subject')).toBe(true);
+ });
+
+ it('returns false for non-matching specific placeholder', () => {
+ expect(placeholdersUtils.hasPlaceholder('Text {%mail_subject%}', 'author')).toBe(false);
+ });
+
+ it('detects dynamic placeholder with value', () => {
+ expect(placeholdersUtils.hasPlaceholder('Header: {%mail_headers:X-Custom%}', 'mail_headers')).toBe(true);
+ });
+});
+
+describe('placeholdersUtils.hasCustomPlaceholder', () => {
+ it('detects thunderai_custom_ placeholders', () => {
+ expect(placeholdersUtils.hasCustomPlaceholder('Text {%thunderai_custom_myph%}')).toBe(true);
+ });
+
+ it('returns false when none present', () => {
+ expect(placeholdersUtils.hasCustomPlaceholder('Text {%mail_subject%}')).toBe(false);
+ });
+
+ it('checks specific custom placeholder', () => {
+ expect(placeholdersUtils.hasCustomPlaceholder('{%thunderai_custom_test%}', 'thunderai_custom_test')).toBe(true);
+ expect(placeholdersUtils.hasCustomPlaceholder('{%thunderai_custom_other%}', 'thunderai_custom_test')).toBe(false);
+ });
+});
+
+describe('placeholdersUtils.getPlaceholdersAdditionalTextArray', () => {
+ it('extracts additional_text placeholders', () => {
+ const result = placeholdersUtils.getPlaceholdersAdditionalTextArray('Text {%additional_text%}');
+ expect(result).toEqual([{ placeholder: '{%additional_text%}', info: '' }]);
+ });
+
+ it('extracts additional_text with custom info', () => {
+ const result = placeholdersUtils.getPlaceholdersAdditionalTextArray('Text {%additional_text:Subject line%}');
+ expect(result).toEqual([{ placeholder: '{%additional_text:Subject line%}', info: 'Subject line' }]);
+ });
+
+ it('deduplicates entries with same info', () => {
+ const text = '{%additional_text:#1%} and {%additional_text:#1%}';
+ const result = placeholdersUtils.getPlaceholdersAdditionalTextArray(text);
+ expect(result).toHaveLength(1);
+ });
+
+ it('returns multiple entries with different info', () => {
+ const text = '{%additional_text:#1%} and {%additional_text:#2%}';
+ const result = placeholdersUtils.getPlaceholdersAdditionalTextArray(text);
+ expect(result).toHaveLength(2);
+ });
+
+ it('returns empty array when none found', () => {
+ expect(placeholdersUtils.getPlaceholdersAdditionalTextArray('no placeholders')).toEqual([]);
+ });
+});
+
+describe('placeholdersUtils.failSafePlaceholders', () => {
+ it('returns empty string for null', () => {
+ expect(placeholdersUtils.failSafePlaceholders(null)).toBe('');
+ });
+
+ it('returns empty string for undefined', () => {
+ expect(placeholdersUtils.failSafePlaceholders(undefined)).toBe('');
+ });
+
+ it('passes through strings unchanged', () => {
+ expect(placeholdersUtils.failSafePlaceholders('hello')).toBe('hello');
+ });
+
+ it('passes through numbers unchanged', () => {
+ expect(placeholdersUtils.failSafePlaceholders(42)).toBe(42);
+ });
+
+ it('passes through empty string', () => {
+ expect(placeholdersUtils.failSafePlaceholders('')).toBe('');
+ });
+});
+
+describe('placeholdersUtils.replacePlaceholders', () => {
+ it('replaces known placeholders with provided values', () => {
+ const result = placeholdersUtils.replacePlaceholders({
+ text: 'Subject: {%mail_subject%}',
+ replacements: { mail_subject: 'Test Email' },
+ });
+ expect(result).toBe('Subject: Test Email');
+ });
+
+ it('replaces multiple placeholders', () => {
+ const result = placeholdersUtils.replacePlaceholders({
+ text: 'From: {%author%}, Subject: {%mail_subject%}',
+ replacements: { author: 'John', mail_subject: 'Hello' },
+ });
+ expect(result).toBe('From: John, Subject: Hello');
+ });
+
+ it('leaves unknown placeholders unchanged', () => {
+ const result = placeholdersUtils.replacePlaceholders({
+ text: 'Value: {%nonexistent_thing%}',
+ replacements: {},
+ });
+ expect(result).toBe('Value: {%nonexistent_thing%}');
+ });
+
+ it('skips additional_text when skip_additional_text is true', () => {
+ const result = placeholdersUtils.replacePlaceholders({
+ text: 'Text: {%additional_text%}',
+ replacements: { additional_text: 'replaced' },
+ skip_additional_text: true,
+ });
+ expect(result).toBe('Text: {%additional_text%}');
+ });
+
+ it('skips additional_text with suffix when skip_additional_text is true', () => {
+ const result = placeholdersUtils.replacePlaceholders({
+ text: 'Text: {%additional_text:#1%}',
+ replacements: {},
+ skip_additional_text: true,
+ });
+ expect(result).toBe('Text: {%additional_text:#1%}');
+ });
+
+ it('handles empty args gracefully', () => {
+ const result = placeholdersUtils.replacePlaceholders();
+ expect(result).toBe('');
+ });
+});
diff --git a/tests/mzta-prompts.test.js b/tests/mzta-prompts.test.js
new file mode 100644
index 00000000..9824cb4b
--- /dev/null
+++ b/tests/mzta-prompts.test.js
@@ -0,0 +1,98 @@
+import { preparePromptsForExport } from '../js/mzta-prompts.js';
+
+describe('preparePromptsForExport', () => {
+ // Helper: create a custom prompt with integration keys
+ function makeCustomPrompt(overrides = {}) {
+ return {
+ id: 'custom_1',
+ is_default: 0,
+ idnum: 5,
+ text: 'Do something',
+ enabled: 1,
+ api_type: '',
+ chatgpt_api_key: 'key1',
+ chatgpt_model: 'gpt-5',
+ ollama_host: 'http://localhost',
+ ollama_model: 'llama3',
+ ...overrides,
+ };
+ }
+
+ // Helper: create a default prompt
+ function makeDefaultPrompt(overrides = {}) {
+ return {
+ id: 'prompt_reply',
+ is_default: 1,
+ idnum: 1,
+ text: 'Reply to this email',
+ enabled: 1,
+ position_compose: 1,
+ position_display: 2,
+ need_custom_text: 0,
+ api_type: '',
+ chatgpt_api_key: 'key1',
+ chatgpt_model: 'gpt-5',
+ ollama_host: 'http://localhost',
+ ...overrides,
+ };
+ }
+
+ it('removes api_type and integration keys when include_api_settings is false', () => {
+ const prompts = [makeCustomPrompt()];
+ const result = preparePromptsForExport(prompts, false);
+ expect(result[0]).not.toHaveProperty('api_type');
+ expect(result[0]).not.toHaveProperty('chatgpt_api_key');
+ expect(result[0]).not.toHaveProperty('chatgpt_model');
+ expect(result[0]).not.toHaveProperty('ollama_host');
+ expect(result[0]).not.toHaveProperty('ollama_model');
+ expect(result[0].text).toBe('Do something');
+ });
+
+ it('removes idnum from custom prompts', () => {
+ const prompts = [makeCustomPrompt()];
+ const result = preparePromptsForExport(prompts, false);
+ expect(result[0]).not.toHaveProperty('idnum');
+ });
+
+ it('for default prompts, keeps only allowed keys when include_api_settings is false', () => {
+ const prompts = [makeDefaultPrompt()];
+ const result = preparePromptsForExport(prompts, false);
+ const keys = Object.keys(result[0]);
+ expect(keys).toContain('id');
+ expect(keys).toContain('enabled');
+ expect(keys).toContain('position_compose');
+ expect(keys).toContain('position_display');
+ expect(keys).toContain('need_custom_text');
+ expect(keys).not.toContain('text');
+ expect(keys).not.toContain('api_type');
+ expect(keys).not.toContain('chatgpt_api_key');
+ });
+
+ it('keeps api_type when include_api_settings is true', () => {
+ const prompts = [makeCustomPrompt({ api_type: 'chatgpt_api' })];
+ const result = preparePromptsForExport(prompts, true);
+ expect(result[0].api_type).toBe('chatgpt_api');
+ });
+
+ it('keeps only active integration keys when api_type is set and include_api_settings is true', () => {
+ const prompts = [makeCustomPrompt({ api_type: 'chatgpt_api' })];
+ const result = preparePromptsForExport(prompts, true);
+ // chatgpt keys should remain (active integration = "chatgpt" from "chatgpt_api")
+ expect(result[0]).toHaveProperty('chatgpt_api_key');
+ expect(result[0]).toHaveProperty('chatgpt_model');
+ // ollama keys should be removed
+ expect(result[0]).not.toHaveProperty('ollama_host');
+ expect(result[0]).not.toHaveProperty('ollama_model');
+ });
+
+ it('does not mutate the original array', () => {
+ const original = [makeCustomPrompt()];
+ const originalCopy = JSON.parse(JSON.stringify(original));
+ preparePromptsForExport(original, false);
+ expect(original).toEqual(originalCopy);
+ });
+
+ it('handles empty prompts array', () => {
+ expect(preparePromptsForExport([], false)).toEqual([]);
+ });
+});
diff --git a/tests/mzta-utils-prompt.test.js b/tests/mzta-utils-prompt.test.js
new file mode 100644
index 00000000..105b07bb
--- /dev/null
+++ b/tests/mzta-utils-prompt.test.js
@@ -0,0 +1,73 @@
+import { taPromptUtils } from '../js/mzta-utils-prompt.js';
+
+// ─── finalizePrompt_get_calendar_event ───────────────────────────
+
+describe('taPromptUtils.finalizePrompt_get_calendar_event', () => {
+ it('removes {%cc_list%} placeholder', () => {
+ const result = taPromptUtils.finalizePrompt_get_calendar_event('Send to {%cc_list%} please');
+ expect(result).toBe('Send to please');
+ });
+
+ it('removes {%recipients%} placeholder', () => {
+ const result = taPromptUtils.finalizePrompt_get_calendar_event('Invite {%recipients%} now');
+ expect(result).toBe('Invite now');
+ });
+
+ it('removes both placeholders', () => {
+ const result = taPromptUtils.finalizePrompt_get_calendar_event('To: {%recipients%}, CC: {%cc_list%}');
+ expect(result).toBe('To: , CC: ');
+ });
+
+ it('returns prompt unchanged if no placeholders present', () => {
+ const prompt = 'Create a calendar event from this email';
+ expect(taPromptUtils.finalizePrompt_get_calendar_event(prompt)).toBe(prompt);
+ });
+});
+
+// ─── getTagsFromResponse ─────────────────────────────────────────
+
+describe('taPromptUtils.getTagsFromResponse', () => {
+ it('parses JSON response with tags array', () => {
+ const response = '{"tags": ["urgent", "work"]}';
+ expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work']);
+ });
+
+ it('parses JSON response with tags as string', () => {
+ const response = '{"tags": "urgent, work"}';
+ expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work']);
+ });
+
+ it('handles JSON embedded in text', () => {
+ const response = 'Here are the tags: {"tags": ["a", "b"]} done';
+ expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['a', 'b']);
+ });
+
+ it('falls back to comma splitting on invalid JSON', () => {
+ const response = 'urgent, work, personal';
+ expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work', 'personal']);
+ });
+
+ it('returns empty array for empty input', () => {
+ expect(taPromptUtils.getTagsFromResponse('')).toEqual([]);
+ expect(taPromptUtils.getTagsFromResponse(null)).toEqual([]);
+ expect(taPromptUtils.getTagsFromResponse(undefined)).toEqual([]);
+ });
+
+ it('filters tags when filter_tags is true', () => {
+ const response = '{"tags": ["urgent", "work", "personal"]}';
+ const result = taPromptUtils.getTagsFromResponse(response, true, 'urgent, personal');
+ expect(result).toEqual(['urgent', 'personal']);
+ });
+
+ it('filter is case-insensitive', () => {
+ const response = '{"tags": ["Urgent", "WORK"]}';
+ const result = taPromptUtils.getTagsFromResponse(response, true, 'urgent, work');
+ expect(result).toEqual(['Urgent', 'WORK']);
+ });
+
+ it('does not filter when filter_tags is false', () => {
+ const response = '{"tags": ["urgent", "work"]}';
+ const result = taPromptUtils.getTagsFromResponse(response, false, 'urgent');
+ expect(result).toEqual(['urgent', 'work']);
+ });
+});
diff --git a/tests/mzta-utils.test.js b/tests/mzta-utils.test.js
new file mode 100644
index 00000000..1baf1d06
--- /dev/null
+++ b/tests/mzta-utils.test.js
@@ -0,0 +1,697 @@
+import {
+ getLanguageDisplayName,
+ sanitizeHtml,
+ sanitizeMailHeaders,
+ stripHtmlKeepLines,
+ htmlBodyToPlainText,
+ cleanupNewlines,
+ convertNewlinesToBr,
+ convertNewlinesToParagraphs,
+ getGPTWebModelString,
+ getMenuContextCompose,
+ getMenuContextDisplay,
+ checkIfTagLabelExists,
+ normalizeStringList,
+ prepareOriginURL,
+ checkAPIIntegration,
+ hasSpecificIntegration,
+ extractJsonObject,
+ isAPIKeyValue,
+ validateChatGPTWebCustomData,
+ sanitizeChatGPTModelData,
+ sanitizeChatGPTWebCustomData,
+ getActiveSpecialPromptsIDs,
+ getConnectionType,
+ getMailBody,
+ generateCallID,
+ ChatGPTWeb_models,
+ fixMsgHeader,
+ extractEmail,
+ convertBrToNewlines,
+ compareThunderbirdVersions,
+ getTagsKeyFromLabel,
+ sanitizeString,
+ formatBytes,
+} from '../js/mzta-utils.js';
+
+// ─── Constants ───────────────────────────────────────────────────
+
+describe('ChatGPTWeb_models', () => {
+ it('is an array of model strings', () => {
+ expect(Array.isArray(ChatGPTWeb_models)).toBe(true);
+ expect(ChatGPTWeb_models.length).toBeGreaterThan(0);
+ ChatGPTWeb_models.forEach(m => expect(typeof m).toBe('string'));
+ });
+});
+
+describe('getMenuContextCompose', () => {
+ it('returns compose_action_menu', () => {
+ expect(getMenuContextCompose()).toBe('compose_action_menu');
+ });
+});
+
+describe('getMenuContextDisplay', () => {
+ it('returns message_display_action_menu', () => {
+ expect(getMenuContextDisplay()).toBe('message_display_action_menu');
+ });
+});
+
+// ─── String / HTML manipulation ──────────────────────────────────
+
+describe('sanitizeHtml', () => {
+ it('strips HTML tags but keeps
', () => {
+ expect(sanitizeHtml('
World')).toBe('Hello
World');
+ });
+
+ it('keeps
and
variants', () => {
+ expect(sanitizeHtml('A
B
C')).toBe('A
B
C');
+ });
+
+ it('passes through plain text unchanged', () => {
+ expect(sanitizeHtml('just text')).toBe('just text');
+ });
+
+ it('handles empty string', () => {
+ expect(sanitizeHtml('')).toBe('');
+ });
+
+ it('strips nested tags', () => {
+ expect(sanitizeHtml('
', () => { + expect(stripHtmlKeepLines('
line1
line2
')).toBe('line1\nline2'); + }); + + it('convertstext
')).toBe('text'); + }); +}); + +describe('htmlBodyToPlainText', () => { + it('extracts text from simple HTML', () => { + expect(htmlBodyToPlainText('Hello World
')).toBe('Hello World'); + }); + + it('removes style elements', () => { + expect(htmlBodyToPlainText('text
')).toBe('text'); + }); + + it('removes display:none elements', () => { + expect(htmlBodyToPlainText('visible
')).toBe('visible'); + }); + + it('handles empty string', () => { + expect(htmlBodyToPlainText('')).toBe(''); + }); +}); + +describe('cleanupNewlines', () => { + it('normalizes \\r\\n to \\n', () => { + expect(cleanupNewlines('a\r\nb')).toBe('a\nb'); + }); + + it('collapses multiple newlines to one', () => { + expect(cleanupNewlines('a\n\n\nb')).toBe('a\nb'); + }); + + it('collapses spaces/tabs before newlines', () => { + expect(cleanupNewlines('a \nb')).toBe('a\nb'); + }); + + it('replaces with space', () => { + expect(cleanupNewlines('hello world')).toBe('hello world'); + }); + + it('trims result', () => { + expect(cleanupNewlines(' text ')).toBe('text'); + }); +}); + +describe('convertNewlinesToBr', () => { + it('converts \\n totags', () => { + expect(convertNewlinesToParagraphs('line1\nline2')).toBe('
line1
line2
'); + }); + + it('handles single line', () => { + expect(convertNewlinesToParagraphs('single')).toBe('single
'); + }); +}); + +describe('convertBrToNewlines', () => { + it('convertsHello html
' }, + ], + }; + expect(getMailBody(msg)).toEqual({ text: 'Hello plain', html: 'Hello html
' }); + }); + + it('converts text to html withnested html
' }, + ], + }, + ], + }; + expect(getMailBody(msg)).toEqual({ text: 'nested text', html: 'nested html
' }); + }); + + it('returns empty strings when no parts', () => { + expect(getMailBody({ parts: [] })).toEqual({ text: '', html: '' }); + expect(getMailBody({})).toEqual({ text: '', html: '' }); + }); +}); + +// ─── getLanguageDisplayName ────────────────────────────────────── + +describe('getLanguageDisplayName', () => { + it('returns capitalized language name', () => { + const name = getLanguageDisplayName('en'); + expect(name).toBe('English'); + }); + + it('returns localized name for other languages', () => { + const name = getLanguageDisplayName('fr'); + expect(name.charAt(0)).toBe(name.charAt(0).toUpperCase()); + expect(name.length).toBeGreaterThan(0); + }); +}); + +// ─── fixMsgHeader ──────────────────────────────────────────────── + +describe('fixMsgHeader', () => { + it('adds missing bccList, ccList, and recipients', () => { + const header = {}; + const result = fixMsgHeader(header); + expect(result.bccList).toEqual([]); + expect(result.ccList).toEqual([]); + expect(result.recipients).toEqual([]); + }); + + it('preserves existing values', () => { + const header = { + bccList: ['a@b.com'], + ccList: ['c@d.com'], + recipients: ['e@f.com'], + }; + const result = fixMsgHeader(header); + expect(result.bccList).toEqual(['a@b.com']); + expect(result.ccList).toEqual(['c@d.com']); + expect(result.recipients).toEqual(['e@f.com']); + }); +}); + +// ─── extractEmail ──────────────────────────────────────────────── + +describe('extractEmail', () => { + it('extracts email from text', () => { + expect(extractEmail('John Doe