diff --git a/__tests__/deepL.test.ts b/__tests__/deepL.test.ts new file mode 100644 index 0000000..6b57ba6 --- /dev/null +++ b/__tests__/deepL.test.ts @@ -0,0 +1,209 @@ +// Jest tests for deepLTranslate() and deepLGetLanguages() + +import { + deepLTranslate, + deepLGetLanguages, + DeepLSupportedLanguages, + DeepLLanguageType, +} from '../src/sheetsl'; + +const ADDON_NAME = 'SheetsL'; +const DEEPL_API_BASE_URL_FREE = 'https://api-free.deepl.com/v2/'; +const translateUrl = DEEPL_API_BASE_URL_FREE + 'translate'; +const getLanguageUrl = DEEPL_API_BASE_URL_FREE + 'languages'; +const mockApiKey = 'apiKeyString:fx'; + +PropertiesService.getUserProperties = jest.fn(() => ({ + getProperty: jest.fn(() => mockApiKey), +})) as any; + +UrlFetchApp.fetch = jest.fn((url: string, options: UrlFetchAppOptions) => ({ + _options: options, + getResponseCode: jest.fn((): number => 200), + getContentText: jest.fn((text: string = url) => { + // Switch the returning value of the mock function for UrlFetchApp.fetch + // depending on the input URL. + if (text.startsWith(translateUrl)) { + // URL with the endpoint of DeepL API translation will return + // a stringified object of type DeepLTranslationResponse + return JSON.stringify({ + translations: [ + { + detected_source_language: 'JA', + text: text, // This mock translated text will return the string of the input URL for UrlFetchApp.fetch + }, + ], + }); + } else if (text.startsWith(getLanguageUrl)) { + // URL with the endpoint of DeepL API to retrieve the list of supported languages + // will return a stringified list of type DeepLSupportedLanguages objects. + return JSON.stringify([ + { + language: 'EN-US', + name: 'English (American)', + supports_formality: false, + }, + { + language: 'JA', + name: 'Japanese', + supports_formality: false, + }, + { + language: 'MOCK', + name: text, // This mock language will return the string of the input URL for UrlFetchApp.fetch + supports_formality: true, + }, + ]); + } + }), +})) as any; + +type UrlFetchAppOptions = { + muteHttpExceptions: boolean; +}; + +type DeepLTranslatePattern = { + title: string; + input: DeepLTranslatePatternInput; + expectedOutput: string[]; +}; + +type DeepLTranslatePatternInput = { + sourceText: string | string[]; + sourceLocale: string | null | undefined; + targetLocale: string; +}; + +type DeepLGetLanguages = { + title: string; + input: DeepLLanguageType; + expectedOutput: DeepLSupportedLanguages[]; +}; + +const deepLTranslatePatterns: DeepLTranslatePattern[] = [ + { + title: 'sourceText as string', + input: { + sourceText: 'text to translate', + sourceLocale: 'JA', + targetLocale: 'EN-US', + }, + expectedOutput: [ + `${translateUrl}?auth_key=${mockApiKey}&target_lang=EN-US&text=${encodeURIComponent( + 'text to translate' + )}&source_lang=JA`, + ], + }, + { + title: 'sourceText as an array of strings', + input: { + sourceText: [ + 'text to translate 1', + 'text to translate 2', + 'text to translate 3', + ], + sourceLocale: 'JA', + targetLocale: 'EN-US', + }, + expectedOutput: [ + `${translateUrl}?auth_key=${mockApiKey}&target_lang=EN-US&text=${encodeURIComponent( + 'text to translate 1' + )}&text=${encodeURIComponent( + 'text to translate 2' + )}&text=${encodeURIComponent('text to translate 3')}&source_lang=JA`, + ], + }, +]; + +const deepLTranslatePatternsWithErrors: DeepLTranslatePattern[] = [ + { + title: 'Empty sourceText', + input: { + sourceText: '', + sourceLocale: 'JA', + targetLocale: 'EN-US', + }, + expectedOutput: ['returns an error'], + }, +]; + +const deepLGetLanguagesPatterns: DeepLGetLanguages[] = [ + { + title: 'type = source', + input: 'source', + expectedOutput: [ + { + language: 'EN-US', + name: 'English (American)', + supports_formality: false, + }, + { + language: 'JA', + name: 'Japanese', + supports_formality: false, + }, + { + language: 'MOCK', + name: `${getLanguageUrl}?auth_key=${mockApiKey}&type=source`, + supports_formality: true, + }, + ], + }, + { + title: 'type = target', + input: 'target', + expectedOutput: [ + { + language: 'EN-US', + name: 'English (American)', + supports_formality: false, + }, + { + language: 'JA', + name: 'Japanese', + supports_formality: false, + }, + { + language: 'MOCK', + name: `${getLanguageUrl}?auth_key=${mockApiKey}&type=target`, + supports_formality: true, + }, + ], + }, +]; + +describe.each(deepLTranslatePatterns)( + 'deepLTranslate', + ({ title, input, expectedOutput }) => { + test(`deepLTranslate test: ${title}`, () => { + expect( + deepLTranslate(input.sourceText, input.sourceLocale, input.targetLocale) + ).toEqual(expectedOutput); + }); + } +); + +// Error patterns in deepLTranslate +describe.each(deepLTranslatePatternsWithErrors)( + 'deepLTranslate Errors', + ({ title, input }) => { + test(`deepLTranslate error test: ${title}`, () => { + expect(() => { + deepLTranslate( + input.sourceText, + input.sourceLocale, + input.targetLocale + ); + }).toThrowError(new Error(`[${ADDON_NAME}] Empty input.`)); + }); + } +); + +describe.each(deepLGetLanguagesPatterns)( + 'deepLGetLanguages', + ({ title, input, expectedOutput }) => { + test(`deepLGetLanguages test: ${title}`, () => { + expect(deepLGetLanguages(input)).toEqual(expectedOutput); + }); + } +); diff --git a/__tests__/getDeepLApiBaseUrl.test.ts b/__tests__/getDeepLApiBaseUrl.test.ts index 757c8d2..2a8b75f 100644 --- a/__tests__/getDeepLApiBaseUrl.test.ts +++ b/__tests__/getDeepLApiBaseUrl.test.ts @@ -1,8 +1,8 @@ -import { - DEEPL_API_BASE_URL_FREE, - DEEPL_API_BASE_URL_PRO, - getDeepLApiBaseUrl, -} from '../src/sheetsl'; +import { getDeepLApiBaseUrl } from '../src/sheetsl'; + +const DEEPL_API_VERSION = 'v2'; +const DEEPL_API_BASE_URL_FREE = `https://api-free.deepl.com/${DEEPL_API_VERSION}/`; +const DEEPL_API_BASE_URL_PRO = `https://api.deepl.com/${DEEPL_API_VERSION}/`; const patterns = [ { diff --git a/__tests__/getDeepLApiKey.test.ts b/__tests__/getDeepLApiKey.test.ts new file mode 100644 index 0000000..61b2e04 --- /dev/null +++ b/__tests__/getDeepLApiKey.test.ts @@ -0,0 +1,21 @@ +import { getDeepLApiKey } from '../src/sheetsl'; + +const ADDON_NAME = 'SheetsL'; + +PropertiesService.getUserProperties = jest.fn(() => ({ + getProperty: jest.fn(() => undefined), +})) as any; + +const testObj = { + title: + 'Case when DeepL API Authentication Key is not saved in the user property', + errorMessage: `[${ADDON_NAME}] API Key Unavailable: Set the DeepL API Authentication Key from the Settings > Set Auth Key of the add-on menu.`, +}; + +describe('getDeepLApiKey Error', () => { + test(testObj.title, () => { + expect(() => { + getDeepLApiKey(); + }).toThrowError(new Error(testObj.errorMessage)); + }); +}); diff --git a/__tests__/handleDeepLErrors.test.ts b/__tests__/handleDeepLErrors.test.ts new file mode 100644 index 0000000..dd78f2e --- /dev/null +++ b/__tests__/handleDeepLErrors.test.ts @@ -0,0 +1,70 @@ +import { handleDeepLErrors } from '../src/sheetsl'; + +const ADDON_NAME = 'SheetsL'; + +const successPattern = { + title: 'HTTP Response Code 200', + inputResponse: { + getResponseCode: () => 200, + getContentText: () => 'Testing HTTP Response Code 200', + }, +} as any; +const errorPatterns = [ + { + title: 'HTTP Response Code 429', + inputResponse: { + getResponseCode: () => 429, + getContentText: () => 'Testing HTTP Response Code 429', + }, + expectedErrorMessage: `[${ADDON_NAME}] Too Many Requests: Try again after some time.`, + }, + { + title: 'HTTP Response Code 456', + inputResponse: { + getResponseCode: () => 456, + getContentText: () => 'Testing HTTP Response Code 456', + }, + expectedErrorMessage: `[${ADDON_NAME}] Quota Exceeded: The translation limit of your account has been reached.`, + }, + { + title: 'HTTP Response Code 500', + inputResponse: { + getResponseCode: () => 500, + getContentText: () => 'Testing HTTP Response Code 500', + }, + expectedErrorMessage: `[${ADDON_NAME}] Temporary errors in the DeepL service. Please retry after waiting for a while.`, + }, + { + title: 'HTTP Response Code 501', + inputResponse: { + getResponseCode: () => 501, + getContentText: () => 'Testing HTTP Response Code 501', + }, + expectedErrorMessage: `[${ADDON_NAME}] Temporary errors in the DeepL service. Please retry after waiting for a while.`, + }, + { + title: 'HTTP Response Code 300', + inputResponse: { + getResponseCode: () => 300, + getContentText: () => 'Testing HTTP Response Code 300', + }, + expectedErrorMessage: `[${ADDON_NAME}] Error on Calling DeepL API: Testing HTTP Response Code 300`, + }, +] as any[]; + +describe('handleDeepLErrors Success', () => { + test(successPattern.title, () => { + expect(handleDeepLErrors(successPattern.inputResponse)).toBeUndefined(); + }); +}); + +describe.each(errorPatterns)( + 'handleDeepLErrors Errors', + ({ title, inputResponse, expectedErrorMessage }) => { + test(title, () => { + expect(() => { + handleDeepLErrors(inputResponse); + }).toThrowError(new Error(expectedErrorMessage)); + }); + } +); diff --git a/__tests__/onInstall.test.ts b/__tests__/onInstall.test.ts new file mode 100644 index 0000000..c6af9f4 --- /dev/null +++ b/__tests__/onInstall.test.ts @@ -0,0 +1,57 @@ +import { onInstall } from '../src/sheetsl'; + +const ADDON_NAME = 'SheetsL'; + +SpreadsheetApp.getUi = jest.fn(() => ({ + createAddonMenu: jest.fn(() => new MockMenu(true)), + createMenu: jest.fn((title: string) => new MockMenu(false, title)), +})) as any; + +class MockMenu { + mockMenu: MockMenuObj; + isAddonMenu: boolean; + constructor(isAddonMenu: boolean, title: string = '') { + this.mockMenu = { + title: isAddonMenu ? ADDON_NAME : title, + menu: [], + isAddonMenu: isAddonMenu, + }; + } + addItem(itemName: string, functionName: string): this { + this.mockMenu.menu.push({ itemName: itemName, functionName: functionName }); + return this; + } + addSeparator(): this { + this.mockMenu.menu.push('---'); + return this; + } + addSubMenu(menu: MockMenuObj): this { + this.mockMenu.menu.push(menu); + return this; + } + addToUi(): MockMenuObj { + return this.mockMenu; + } +} + +type MockMenuObj = { + title: string; + menu: any[]; + isAddonMenu: boolean; +}; + +describe('onOpen/onInstall', () => { + test('onOpen/onInstall test', () => { + expect(onInstall).toEqual({ + title: ADDON_NAME, + menu: [ + { + title: 'Settings', + menu: [{ itemName: 'Set Auth Key', functionName: 'setDeeplAuthKey' }], + isAddonMenu: false, + }, + ], + isAddonMenu: true, + }); + }); +}); diff --git a/src/sheetsl.ts b/src/sheetsl.ts index 0c04ff8..a65e9be 100644 --- a/src/sheetsl.ts +++ b/src/sheetsl.ts @@ -13,13 +13,13 @@ limitations under the License. */ -export const ADDON_NAME = 'SheetsL'; +const ADDON_NAME = 'SheetsL'; const UP_KEY_DEEPL_API_KEY = 'deeplApiKey'; // User property key for saving the DeepL API key const UP_KEY_SOURCE_LOCALE = 'sourceLocale'; // User property key for saving the source language for DeepL const UP_KEY_TARGET_LOCALE = 'targetLocale'; // User property key for saving the target language for DeepL const DEEPL_API_VERSION = 'v2'; // DeepL API version -export const DEEPL_API_BASE_URL_FREE = `https://api-free.deepl.com/${DEEPL_API_VERSION}/`; -export const DEEPL_API_BASE_URL_PRO = `https://api.deepl.com/${DEEPL_API_VERSION}/`; +const DEEPL_API_BASE_URL_FREE = `https://api-free.deepl.com/${DEEPL_API_VERSION}/`; +const DEEPL_API_BASE_URL_PRO = `https://api.deepl.com/${DEEPL_API_VERSION}/`; const ROW_SEPARATOR = '|||'; // Threshold value of the length of the text to translate, in bytes. See https://developers.google.com/apps-script/guides/services/quotas#current_limitations @@ -30,7 +30,7 @@ const THRESHOLD_BYTES = 1900; * GET request on /v2/languages returns an array of this object. * @see https://www.deepl.com/docs-api/general/get-languages/ */ -type DeepLSupportedLanguages = { +export type DeepLSupportedLanguages = { language: string; name: string; supports_formality: boolean; @@ -59,7 +59,7 @@ type DeepLTranslationObj = { * to the DeepL API to retrieve its supported languages. * @see https://www.deepl.com/docs-api/general/get-languages/ */ -type DeepLLanguageType = 'source' | 'target'; +export type DeepLLanguageType = 'source' | 'target'; /** * Create add-on menu on opening spreadsheet file. @@ -341,13 +341,7 @@ export function deepLTranslate( // console.log(`sourceTextCasted: ${sourceTextCasted}`); // API key - const apiKey = - PropertiesService.getUserProperties().getProperty(UP_KEY_DEEPL_API_KEY); - if (!apiKey) { - throw new Error( - `[${ADDON_NAME}] API Key Unavailable: Set the DeepL API Authentication Key from the Settings > Set Auth Key of the add-on menu.` - ); - } + const apiKey = getDeepLApiKey(); const baseUrl = getDeepLApiBaseUrl(apiKey); // Call the DeepL API let url = @@ -389,13 +383,7 @@ export function deepLGetLanguages( ): DeepLSupportedLanguages[] { const endpoint = 'languages'; // API key - const apiKey = - PropertiesService.getUserProperties().getProperty(UP_KEY_DEEPL_API_KEY); - if (!apiKey) { - throw new Error( - `[${ADDON_NAME}] API Key Unavailable: Set the DeepL API Authentication Key from the Settings > Set Auth Key of the add-on menu.` - ); - } + const apiKey = getDeepLApiKey(); const baseUrl = getDeepLApiBaseUrl(apiKey); // Call the DeepL API let url = baseUrl + endpoint + `?auth_key=${apiKey}&type=${type}`; @@ -407,19 +395,6 @@ export function deepLGetLanguages( return JSON.parse(response.getContentText()); } -/** - * Returns the DeepL API base URL. The URL depends on whether the user's account - * is DeepL API Free or Pro. Auth keys of DeepL API Free end with `:fx` - * @param apiKey The DeepL API Free/Pro Authentication Key - * @returns The relevant base URL for DeepL API - * @see https://support.deepl.com/hc/en-us/articles/360021183620-DeepL-API-Free-vs-DeepL-API-Pro - */ -export function getDeepLApiBaseUrl(apiKey: string): string { - return apiKey.endsWith(':fx') - ? DEEPL_API_BASE_URL_FREE - : DEEPL_API_BASE_URL_PRO; -} - /** * Get the length of a given string in bytes. * @param text The string of which to get the bytes. @@ -456,3 +431,33 @@ export function handleDeepLErrors( ); } } + +/** + * Get the string of DeepL API Authentication Key saved as a user property of the add-on. + * Throws an error if the key is not save in the user property. + * @returns The string of DeepL API Authentication Key saved as a user property of the add-on. + */ +export function getDeepLApiKey(): string { + const apiKey = + PropertiesService.getUserProperties().getProperty(UP_KEY_DEEPL_API_KEY); + if (!apiKey) { + throw new Error( + `[${ADDON_NAME}] API Key Unavailable: Set the DeepL API Authentication Key from the Settings > Set Auth Key of the add-on menu.` + ); + } else { + return apiKey; + } +} + +/** + * Returns the DeepL API base URL. The URL depends on whether the user's account + * is DeepL API Free or Pro. Auth keys of DeepL API Free end with `:fx` + * @param apiKey The DeepL API Free/Pro Authentication Key + * @returns The relevant base URL for DeepL API + * @see https://support.deepl.com/hc/en-us/articles/360021183620-DeepL-API-Free-vs-DeepL-API-Pro + */ +export function getDeepLApiBaseUrl(apiKey: string): string { + return apiKey.endsWith(':fx') + ? DEEPL_API_BASE_URL_FREE + : DEEPL_API_BASE_URL_PRO; +}