diff --git a/packages/core/src/libraries/phrase.test.ts b/packages/core/src/libraries/phrase.test.ts index 49e070769..8bed35bd2 100644 --- a/packages/core/src/libraries/phrase.test.ts +++ b/packages/core/src/libraries/phrase.test.ts @@ -13,6 +13,7 @@ import { zhHkTag, } from '#src/__mocks__/custom-phrase.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { MockQueries } from '#src/test-utils/tenant.js'; const { jest } = import.meta; @@ -43,11 +44,10 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => { return mockCustomPhrase; }); -mockEsm('#src/queries/custom-phrase.js', () => ({ - findCustomPhraseByLanguageTag, -})); - -const { getPhrase } = await import('#src/libraries/phrase.js'); +const { createPhraseLibrary } = await import('#src/libraries/phrase.js'); +const { getPhrases } = createPhraseLibrary( + new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } }) +); afterEach(() => { jest.clearAllMocks(); @@ -72,7 +72,7 @@ it('should ignore empty string values from the custom phrase', async () => { }; findCustomPhraseByLanguageTag.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues); - await expect(getPhrase(enTag, [enTag])).resolves.toEqual( + await expect(getPhrases(enTag, [enTag])).resolves.toEqual( deepmerge(englishBuiltInPhrase, { languageTag: enTag, translation: { @@ -87,19 +87,19 @@ it('should ignore empty string values from the custom phrase', async () => { describe('when the language is English', () => { it('should be English custom phrase merged with its built-in phrase when its custom phrase exists', async () => { - await expect(getPhrase(enTag, [enTag])).resolves.toEqual( + await expect(getPhrases(enTag, [enTag])).resolves.toEqual( deepmerge(englishBuiltInPhrase, mockEnCustomPhrase) ); }); it('should be English built-in phrase when its custom phrase does not exist', async () => { - await expect(getPhrase(enTag, [])).resolves.toEqual(englishBuiltInPhrase); + await expect(getPhrases(enTag, [])).resolves.toEqual(englishBuiltInPhrase); }); }); describe('when the language is not English', () => { it('should be custom phrase merged with built-in phrase when both of them exist', async () => { - await expect(getPhrase(customizedLanguage, [customizedLanguage])).resolves.toEqual( + await expect(getPhrases(customizedLanguage, [customizedLanguage])).resolves.toEqual( deepmerge(customizedBuiltInPhrase, customizedCustomPhrase) ); }); @@ -107,11 +107,11 @@ describe('when the language is not English', () => { it('should be built-in phrase when there is built-in phrase and no custom phrase', async () => { const builtInOnlyLanguage = trTrTag; const builtInOnlyPhrase = resource[trTrTag]; - await expect(getPhrase(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase); + await expect(getPhrases(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase); }); it('should be built-in phrase when there is custom phrase and no built-in phrase', async () => { - await expect(getPhrase(customOnlyLanguage, [customOnlyLanguage])).resolves.toEqual( + await expect(getPhrases(customOnlyLanguage, [customOnlyLanguage])).resolves.toEqual( deepmerge(englishBuiltInPhrase, customOnlyCustomPhrase) ); }); diff --git a/packages/core/src/libraries/phrase.ts b/packages/core/src/libraries/phrase.ts index 093ff4b97..bb6f163c5 100644 --- a/packages/core/src/libraries/phrase.ts +++ b/packages/core/src/libraries/phrase.ts @@ -4,22 +4,28 @@ import type { CustomPhrase } from '@logto/schemas'; import cleanDeep from 'clean-deep'; import deepmerge from 'deepmerge'; -import { findCustomPhraseByLanguageTag } from '#src/queries/custom-phrase.js'; +import type Queries from '#src/tenants/Queries.js'; + +export const createPhraseLibrary = (queries: Queries) => { + const { findCustomPhraseByLanguageTag } = queries.customPhrases; + + const getPhrases = async (supportedLanguage: string, customLanguages: string[]) => { + if (!isBuiltInLanguageTag(supportedLanguage)) { + return deepmerge( + resource.en, + cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage)) + ); + } + + if (!customLanguages.includes(supportedLanguage)) { + return resource[supportedLanguage]; + } -export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => { - if (!isBuiltInLanguageTag(supportedLanguage)) { return deepmerge( - resource.en, + resource[supportedLanguage], cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage)) ); - } + }; - if (!customLanguages.includes(supportedLanguage)) { - return resource[supportedLanguage]; - } - - return deepmerge( - resource[supportedLanguage], - cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage)) - ); + return { getPhrases }; }; diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 677f5296d..b25ae3e1e 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -26,7 +26,7 @@ import type { LogtoConnector } from '#src/utils/connectors/types.js'; import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; -const { mockEsm, mockEsmWithActual } = createMockUtils(jest); +const { mockEsm } = createMockUtils(jest); mockEsm('#src/utils/connectors/platform.js', () => ({ checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(), diff --git a/packages/core/src/routes/phrase.content-language.test.ts b/packages/core/src/routes/phrase.content-language.test.ts index 3368f93cd..078eef7b3 100644 --- a/packages/core/src/routes/phrase.content-language.test.ts +++ b/packages/core/src/routes/phrase.content-language.test.ts @@ -1,41 +1,42 @@ import en from '@logto/phrases-ui/lib/locales/en.js'; -import { pickDefault, createMockUtils } from '@logto/shared/esm'; +import type { SignInExperience } from '@logto/schemas'; +import { pickDefault } from '@logto/shared/esm'; import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js'; import { mockSignInExperience } from '#src/__mocks__/index.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; -const { mockEsmWithActual } = createMockUtils(jest); const fallbackLanguage = trTrTag; const unsupportedLanguageX = 'xx-XX'; const unsupportedLanguageY = 'yy-YY'; -const { findDefaultSignInExperience } = await mockEsmWithActual( - '#src/queries/sign-in-experience.js', - () => ({ - findDefaultSignInExperience: jest.fn(async () => ({ - ...mockSignInExperience, - languageInfo: { - autoDetect: true, - fallbackLanguage, - }, - })), +const findDefaultSignInExperience = jest.fn( + async (): Promise => ({ + ...mockSignInExperience, + languageInfo: { + autoDetect: true, + fallbackLanguage, + }, }) ); -await mockEsmWithActual('#src/queries/custom-phrase.js', () => ({ - findAllCustomLanguageTags: async () => [trTrTag, zhCnTag], -})); +const tenantContext = new MockTenant( + undefined, + { + customPhrases: { findAllCustomLanguageTags: async () => [trTrTag, zhCnTag] }, + signInExperiences: { findDefaultSignInExperience }, + }, + { phrases: { getPhrases: jest.fn().mockResolvedValue(en) } } +); -await mockEsmWithActual('#src/libraries/phrase.js', () => ({ - getPhrase: jest.fn().mockResolvedValue(en), -})); const phraseRoutes = await pickDefault(import('./phrase.js')); const phraseRequest = createRequester({ anonymousRoutes: phraseRoutes, + tenantContext, }); afterEach(() => { @@ -48,6 +49,7 @@ describe('when auto-detect is not enabled', () => { ...mockSignInExperience, languageInfo: { autoDetect: false, + // @ts-expect-error fallbackLanguage: unsupportedLanguageX, }, }); @@ -88,6 +90,7 @@ describe('when auto-detect is enabled', () => { ...mockSignInExperience, languageInfo: { autoDetect: true, + // @ts-expect-error fallbackLanguage: unsupportedLanguageX, }, }); diff --git a/packages/core/src/routes/phrase.test.ts b/packages/core/src/routes/phrase.test.ts index 802724b1e..2c78b3c73 100644 --- a/packages/core/src/routes/phrase.test.ts +++ b/packages/core/src/routes/phrase.test.ts @@ -1,50 +1,57 @@ import zhCN from '@logto/phrases-ui/lib/locales/zh-cn.js'; -import type { SignInExperience } from '@logto/schemas'; +import type { CustomPhrase, SignInExperience } from '@logto/schemas'; import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; import { zhCnTag } from '#src/__mocks__/custom-phrase.js'; import { mockSignInExperience } from '#src/__mocks__/index.js'; -import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js'; +import Queries from '#src/tenants/Queries.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; const { jest } = import.meta; -const { mockEsm, mockEsmWithActual } = createMockUtils(jest); +const { mockEsm } = createMockUtils(jest); const customizedLanguage = zhCnTag; -const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: jest.fn( - async (): Promise => ({ - ...mockSignInExperience, - languageInfo: { - autoDetect: true, - fallbackLanguage: customizedLanguage, - }, - }) - ), -})); +const findDefaultSignInExperience = jest.fn( + async (): Promise => ({ + ...mockSignInExperience, + languageInfo: { + autoDetect: true, + fallbackLanguage: customizedLanguage, + }, + }) +); const { default: detectLanguageSpy } = mockEsm('#src/i18n/detect-language.js', () => ({ default: jest.fn().mockReturnValue([]), })); -const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({ +const customPhrases = { findAllCustomLanguageTags: jest.fn(async () => [customizedLanguage]), - findCustomPhraseByLanguageTag: jest.fn(async (tag: string) => ({})), -})); + findCustomPhraseByLanguageTag: jest.fn( + async (tag: string): Promise => ({ languageTag: tag, translation: {} }) + ), +} satisfies Partial; +const { findAllCustomLanguageTags } = customPhrases; -const { getPhrase } = await mockEsmWithActual('#src/libraries/phrase.js', () => ({ - getPhrase: jest.fn(async () => zhCN), -})); +const getPhrases = jest.fn(async () => zhCN); const interactionDetails = jest.fn(); +const tenantContext = new MockTenant( + createMockProvider(interactionDetails), + { customPhrases, signInExperiences: { findDefaultSignInExperience } }, + { phrases: { getPhrases } } +); + const phraseRoutes = await pickDefault(import('./phrase.js')); const { createRequester } = await import('#src/utils/test-utils.js'); const phraseRequest = createRequester({ anonymousRoutes: phraseRoutes, - tenantContext: createMockTenantWithInteraction(interactionDetails), + tenantContext, }); describe('when the application is admin-console', () => { @@ -78,10 +85,10 @@ describe('when the application is admin-console', () => { expect(findAllCustomLanguageTags).toBeCalledTimes(1); }); - it('should call getPhrase with fallback language from Admin Console sign-in experience', async () => { + it('should call getPhrases with fallback language from Admin Console sign-in experience', async () => { await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(getPhrase).toBeCalledTimes(1); - expect(getPhrase).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [ + expect(getPhrases).toBeCalledTimes(1); + expect(getPhrases).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [ customizedLanguage, ]); }); @@ -139,7 +146,7 @@ describe('when the application is not admin-console', () => { expect(findAllCustomLanguageTags).toBeCalledTimes(1); }); - it('should call getPhrase with fallback language from default sign-in experience', async () => { + it('should call getPhrases with fallback language from default sign-in experience', async () => { findDefaultSignInExperience.mockResolvedValueOnce({ ...mockSignInExperience, languageInfo: { @@ -148,7 +155,7 @@ describe('when the application is not admin-console', () => { }, }); await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(getPhrase).toBeCalledTimes(1); - expect(getPhrase).toBeCalledWith(customizedLanguage, [customizedLanguage]); + expect(getPhrases).toBeCalledTimes(1); + expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]); }); }); diff --git a/packages/core/src/routes/phrase.ts b/packages/core/src/routes/phrase.ts index 93294b9e7..5d8e9e143 100644 --- a/packages/core/src/routes/phrase.ts +++ b/packages/core/src/routes/phrase.ts @@ -2,25 +2,28 @@ import { isBuiltInLanguageTag } from '@logto/phrases-ui'; import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas'; import detectLanguage from '#src/i18n/detect-language.js'; -import { getPhrase } from '#src/libraries/phrase.js'; -import { findAllCustomLanguageTags } from '#src/queries/custom-phrase.js'; -import { findDefaultSignInExperience } from '#src/queries/sign-in-experience.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; -const getLanguageInfo = async (applicationId: unknown) => { - if (applicationId === adminConsoleApplicationId) { - return adminConsoleSignInExperience.languageInfo; - } - - const { languageInfo } = await findDefaultSignInExperience(); - - return languageInfo; -}; - export default function phraseRoutes( - ...[router, { provider }]: RouterInitArgs + ...[router, { provider, queries, libraries }]: RouterInitArgs ) { + const { + customPhrases: { findAllCustomLanguageTags }, + signInExperiences: { findDefaultSignInExperience }, + } = queries; + const { getPhrases } = libraries.phrases; + + const getLanguageInfo = async (applicationId: unknown) => { + if (applicationId === adminConsoleApplicationId) { + return adminConsoleSignInExperience.languageInfo; + } + + const { languageInfo } = await findDefaultSignInExperience(); + + return languageInfo; + }; + router.get('/phrase', async (ctx, next) => { const interaction = await provider .interactionDetails(ctx.req, ctx.res) @@ -39,7 +42,7 @@ export default function phraseRoutes( ) ?? 'en'; ctx.set('Content-Language', language); - ctx.body = await getPhrase(language, customLanguages); + ctx.body = await getPhrases(language, customLanguages); return next(); }); diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 4e8c9ee40..839cd29f0 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -1,4 +1,5 @@ import { createConnectorLibrary } from '#src/libraries/connector.js'; +import { createPhraseLibrary } from '#src/libraries/phrase.js'; import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; import { createUserLibrary } from '#src/libraries/user.js'; @@ -8,6 +9,7 @@ export default class Libraries { connectors = createConnectorLibrary(this.queries); users = createUserLibrary(this.queries); signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors); + phrases = createPhraseLibrary(this.queries); constructor(public readonly queries: Queries) {} }