mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
feat(core,phrases): add GET /phrase route (#1959)
This commit is contained in:
parent
505985802b
commit
7ce55a8458
14 changed files with 619 additions and 36 deletions
|
@ -25,9 +25,11 @@
|
|||
"@logto/connector-kit": "^1.0.0-beta.13",
|
||||
"@logto/core-kit": "^1.0.0-beta.13",
|
||||
"@logto/phrases": "^1.0.0-beta.9",
|
||||
"@logto/phrases-ui": "^1.0.0-beta.9",
|
||||
"@logto/schemas": "^1.0.0-beta.9",
|
||||
"@silverhand/essentials": "^1.2.1",
|
||||
"chalk": "^4",
|
||||
"clean-deep": "^3.4.0",
|
||||
"dayjs": "^1.10.5",
|
||||
"debug": "^4.3.4",
|
||||
"decamelize": "^5.0.0",
|
||||
|
|
67
packages/core/src/__mocks__/custom-phrase.ts
Normal file
67
packages/core/src/__mocks__/custom-phrase.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import en from '@logto/phrases-ui/lib/locales/en';
|
||||
|
||||
export const enKey = 'en';
|
||||
export const koKrKey = 'ko-KR';
|
||||
export const trTrKey = 'tr-TR';
|
||||
export const zhCnKey = 'zh-CN';
|
||||
export const zhHkKey = 'zh-HK';
|
||||
|
||||
export const mockEnCustomPhrase = {
|
||||
languageKey: enKey,
|
||||
translation: {
|
||||
input: {
|
||||
username: 'Username 1',
|
||||
password: 'Password 2',
|
||||
email: 'Email 3',
|
||||
phone_number: 'Phone number 4',
|
||||
confirm_password: 'Confirm password 5',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEnPhrase = {
|
||||
languageKey: enKey,
|
||||
translation: {
|
||||
...en.translation,
|
||||
...mockEnCustomPhrase.translation,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockTrTrCustomPhrase = {
|
||||
languageKey: trTrKey,
|
||||
translation: {
|
||||
input: {
|
||||
username: 'Kullanıcı Adı 1',
|
||||
password: 'Şifre 2',
|
||||
email: 'E-posta Adresi 3',
|
||||
phone_number: 'Telefon Numarası 4',
|
||||
confirm_password: 'Şifreyi Doğrula 5',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockZhCnCustomPhrase = {
|
||||
languageKey: zhCnKey,
|
||||
translation: {
|
||||
input: {
|
||||
username: '用户名 1',
|
||||
password: '密码 2',
|
||||
email: '邮箱 3',
|
||||
phone_number: '手机号 4',
|
||||
confirm_password: '确认密码 5',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockZhHkCustomPhrase = {
|
||||
languageKey: zhHkKey,
|
||||
translation: {
|
||||
input: {
|
||||
email: '郵箱 1',
|
||||
password: '密碼 2',
|
||||
username: '用戶名 3',
|
||||
phone_number: '手機號 4',
|
||||
confirm_password: '確認密碼 5',
|
||||
},
|
||||
},
|
||||
};
|
112
packages/core/src/lib/phrase.test.ts
Normal file
112
packages/core/src/lib/phrase.test.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import resource from '@logto/phrases-ui';
|
||||
import { CustomPhrase } from '@logto/schemas';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import {
|
||||
enKey,
|
||||
mockEnCustomPhrase,
|
||||
mockZhCnCustomPhrase,
|
||||
mockZhHkCustomPhrase,
|
||||
trTrKey,
|
||||
zhCnKey,
|
||||
zhHkKey,
|
||||
} from '@/__mocks__/custom-phrase';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { getPhrase } from '@/lib/phrase';
|
||||
|
||||
const englishBuiltInPhrase = resource[enKey];
|
||||
|
||||
const customOnlyLanguage = zhHkKey;
|
||||
const customOnlyCustomPhrase = mockZhHkCustomPhrase;
|
||||
|
||||
const customizedLanguage = zhCnKey;
|
||||
const customizedBuiltInPhrase = resource[zhCnKey];
|
||||
const customizedCustomPhrase = mockZhCnCustomPhrase;
|
||||
|
||||
const mockCustomPhrases: Record<string, CustomPhrase> = {
|
||||
[enKey]: mockEnCustomPhrase,
|
||||
[customOnlyLanguage]: customOnlyCustomPhrase,
|
||||
[customizedLanguage]: customizedCustomPhrase,
|
||||
};
|
||||
|
||||
const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||
const mockCustomPhrase = mockCustomPhrases[languageKey];
|
||||
|
||||
if (!mockCustomPhrase) {
|
||||
throw new RequestError({ code: 'entity.not_found', status: 404 });
|
||||
}
|
||||
|
||||
return mockCustomPhrase;
|
||||
});
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should ignore empty string values from the custom phrase', async () => {
|
||||
const mockTranslationInput = {
|
||||
email: 'Email 3',
|
||||
phone_number: 'Phone number 4',
|
||||
confirm_password: 'Confirm password 5',
|
||||
};
|
||||
const mockEnCustomPhraseWithEmptyStringValues = {
|
||||
languageKey: enKey,
|
||||
translation: {
|
||||
input: {
|
||||
...resource.en.translation.input,
|
||||
...mockTranslationInput,
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
findCustomPhraseByLanguageKey.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues);
|
||||
await expect(getPhrase(enKey, [enKey])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, {
|
||||
languageKey: enKey,
|
||||
translation: {
|
||||
input: {
|
||||
...resource.en.translation.input,
|
||||
...mockTranslationInput,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(enKey, [enKey])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, mockEnCustomPhrase)
|
||||
);
|
||||
});
|
||||
|
||||
it('should be English built-in phrase when its custom phrase does not exist', async () => {
|
||||
await expect(getPhrase(enKey, [])).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(
|
||||
deepmerge(customizedBuiltInPhrase, customizedCustomPhrase)
|
||||
);
|
||||
});
|
||||
|
||||
it('should be built-in phrase when there is built-in phrase and no custom phrase', async () => {
|
||||
const builtInOnlyLanguage = trTrKey;
|
||||
const builtInOnlyPhrase = resource[trTrKey];
|
||||
await expect(getPhrase(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(
|
||||
deepmerge(englishBuiltInPhrase, customOnlyCustomPhrase)
|
||||
);
|
||||
});
|
||||
});
|
28
packages/core/src/lib/phrase.ts
Normal file
28
packages/core/src/lib/phrase.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { LanguageKey } from '@logto/core-kit';
|
||||
import resource, { LocalePhrase } from '@logto/phrases-ui';
|
||||
import { CustomPhrase } from '@logto/schemas';
|
||||
import cleanDeep from 'clean-deep';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { findCustomPhraseByLanguageKey } from '@/queries/custom-phrase';
|
||||
|
||||
export const isBuiltInLanguage = (key: string): key is LanguageKey =>
|
||||
Object.keys(resource).includes(key);
|
||||
|
||||
export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => {
|
||||
if (!isBuiltInLanguage(supportedLanguage)) {
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource.en,
|
||||
cleanDeep(await findCustomPhraseByLanguageKey(supportedLanguage))
|
||||
);
|
||||
}
|
||||
|
||||
if (!customLanguages.includes(supportedLanguage)) {
|
||||
return resource[supportedLanguage];
|
||||
}
|
||||
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource[supportedLanguage],
|
||||
cleanDeep(await findCustomPhraseByLanguageKey(supportedLanguage))
|
||||
);
|
||||
};
|
|
@ -4,6 +4,7 @@ import koaBody from 'koa-body';
|
|||
import { IMiddleware, IRouterParamContext } from 'koa-router';
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
import envSet from '@/env-set';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import ServerError from '@/errors/ServerError';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
@ -114,7 +115,14 @@ export default function koaGuard<
|
|||
}
|
||||
|
||||
if (response !== undefined) {
|
||||
assertThat(response.safeParse(ctx.body).success, new ServerError());
|
||||
const result = response.safeParse(ctx.body);
|
||||
|
||||
if (!result.success) {
|
||||
if (!envSet.values.isProduction) {
|
||||
console.error('Invalid response:', result.error);
|
||||
}
|
||||
throw new ServerError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -8,6 +8,18 @@ import { DeletionError } from '@/errors/SlonikError';
|
|||
|
||||
const { table, fields } = convertToIdentifiers(CustomPhrases);
|
||||
|
||||
export const findAllCustomLanguageKeys = async () => {
|
||||
const rows = await manyRows<{ languageKey: string }>(
|
||||
envSet.pool.query(sql`
|
||||
select ${fields.languageKey}
|
||||
from ${table}
|
||||
order by ${fields.languageKey}
|
||||
`)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.languageKey);
|
||||
};
|
||||
|
||||
export const findAllCustomPhrases = async () =>
|
||||
manyRows(
|
||||
envSet.pool.query<CustomPhrase>(sql`
|
||||
|
|
|
@ -1,25 +1,15 @@
|
|||
import { CustomPhrase } from '@logto/schemas';
|
||||
import { CustomPhrase, SignInExperience } from '@logto/schemas';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { mockZhCnCustomPhrase, trTrKey, zhCnKey } from '@/__mocks__/custom-phrase';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import customPhraseRoutes from '@/routes/custom-phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
const mockLanguageKey = 'en-US';
|
||||
|
||||
const mockLanguageKey = zhCnKey;
|
||||
const mockPhrase = mockZhCnCustomPhrase;
|
||||
const mockCustomPhrases: Record<string, CustomPhrase> = {
|
||||
[mockLanguageKey]: {
|
||||
languageKey: mockLanguageKey,
|
||||
translation: {
|
||||
input: {
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
email: 'Email',
|
||||
phone_number: 'Phone number',
|
||||
confirm_password: 'Confirm password',
|
||||
},
|
||||
},
|
||||
},
|
||||
[mockLanguageKey]: mockPhrase,
|
||||
};
|
||||
|
||||
const deleteCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||
|
@ -40,7 +30,7 @@ const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
|||
|
||||
const findAllCustomPhrases = jest.fn(async (): Promise<CustomPhrase[]> => []);
|
||||
|
||||
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => customPhrase);
|
||||
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => mockPhrase);
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
deleteCustomPhraseByLanguageKey: async (key: string) => deleteCustomPhraseByLanguageKey(key),
|
||||
|
@ -49,7 +39,18 @@ jest.mock('@/queries/custom-phrase', () => ({
|
|||
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
|
||||
}));
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
|
||||
const mockFallbackLanguage = trTrKey;
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: mockFallbackLanguage,
|
||||
fixedLanguage: mockFallbackLanguage,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock('@/queries/sign-in-experience', () => ({
|
||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
||||
|
@ -108,7 +109,7 @@ describe('customPhraseRoutes', () => {
|
|||
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageKey]);
|
||||
});
|
||||
|
||||
it('should return the custom phrase after upserting', async () => {
|
||||
it('should return custom phrase after upserting', async () => {
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||
.send(mockCustomPhrases[mockLanguageKey]?.translation);
|
||||
|
@ -118,23 +119,23 @@ describe('customPhraseRoutes', () => {
|
|||
});
|
||||
|
||||
describe('DELETE /custom-phrases/:languageKey', () => {
|
||||
it('should call deleteCustomPhraseByLanguageKey', async () => {
|
||||
it('should call deleteCustomPhraseByLanguageKey when custom phrase exists and is not fallback language in sign-in experience', async () => {
|
||||
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
||||
expect(deleteCustomPhraseByLanguageKey).toBeCalledWith(mockLanguageKey);
|
||||
});
|
||||
|
||||
it('should return 204 status code after deleting the specified custom phrase', async () => {
|
||||
it('should return 204 status code after deleting specified custom phrase', async () => {
|
||||
const response = await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
||||
it('should return 404 status code when the specified custom phrase does not exist before deleting', async () => {
|
||||
const response = await customPhraseRequest.delete('/custom-phrases/en-UK');
|
||||
it('should return 404 status code when specified custom phrase does not exist before deleting', async () => {
|
||||
const response = await customPhraseRequest.delete('/custom-phrases/en-GB');
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
|
||||
it('should return 400 status code when the specified custom phrase is used as default language in sign-in experience', async () => {
|
||||
const response = await customPhraseRequest.delete('/custom-phrases/en');
|
||||
it('should return 400 status code when specified custom phrase is used as fallback language in sign-in experience', async () => {
|
||||
const response = await customPhraseRequest.delete(`/custom-phrases/${mockFallbackLanguage}`);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import connectorRoutes from '@/routes/connector';
|
|||
import customPhraseRoutes from '@/routes/custom-phrase';
|
||||
import dashboardRoutes from '@/routes/dashboard';
|
||||
import logRoutes from '@/routes/log';
|
||||
import phraseRoutes from '@/routes/phrase';
|
||||
import resourceRoutes from '@/routes/resource';
|
||||
import roleRoutes from '@/routes/role';
|
||||
import sessionRoutes from '@/routes/session';
|
||||
|
@ -43,6 +44,7 @@ const createRouters = (provider: Provider) => {
|
|||
customPhraseRoutes(managementRouter);
|
||||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
phraseRoutes(anonymousRouter, provider);
|
||||
wellKnownRoutes(anonymousRouter, provider);
|
||||
statusRoutes(anonymousRouter);
|
||||
authnRoutes(anonymousRouter);
|
||||
|
|
152
packages/core/src/routes/phrase.content-language.test.ts
Normal file
152
packages/core/src/routes/phrase.content-language.test.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
import en from '@logto/phrases-ui/lib/locales/en';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { trTrKey, zhCnKey, zhHkKey } from '@/__mocks__/custom-phrase';
|
||||
import phraseRoutes from '@/routes/phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
const mockApplicationId = 'mockApplicationIdValue';
|
||||
|
||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
|
||||
params: { client_id: mockApplicationId },
|
||||
}));
|
||||
|
||||
jest.mock('oidc-provider', () => ({
|
||||
Provider: jest.fn(() => ({
|
||||
interactionDetails,
|
||||
})),
|
||||
}));
|
||||
|
||||
const fallbackLanguage = trTrKey;
|
||||
const unsupportedLanguageX = 'xx-XX';
|
||||
const unsupportedLanguageY = 'yy-YY';
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(async () => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage,
|
||||
fixedLanguage: fallbackLanguage,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/sign-in-experience', () => ({
|
||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => [trTrKey, zhCnKey],
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/phrase', () => ({
|
||||
...jest.requireActual('@/lib/phrase'),
|
||||
getPhrase: jest.fn().mockResolvedValue(en),
|
||||
}));
|
||||
|
||||
const phraseRequest = createRequester({
|
||||
anonymousRoutes: phraseRoutes,
|
||||
provider: new Provider(''),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when auto-detect is not enabled', () => {
|
||||
it('should be English when fallback language is unsupported', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: false,
|
||||
fallbackLanguage: unsupportedLanguageX,
|
||||
fixedLanguage: unsupportedLanguageX,
|
||||
},
|
||||
});
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${zhCnKey},${zhHkKey}`);
|
||||
expect(response.headers['content-language']).toEqual('en');
|
||||
});
|
||||
|
||||
describe('should be fallback language', () => {
|
||||
beforeEach(() => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: false,
|
||||
fallbackLanguage,
|
||||
fixedLanguage: fallbackLanguage,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('when there is no detected language', async () => {
|
||||
const response = await phraseRequest.get('/phrase');
|
||||
expect(response.headers['content-language']).toEqual(fallbackLanguage);
|
||||
});
|
||||
|
||||
it('when there are detected languages', async () => {
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${zhCnKey},${zhHkKey}`);
|
||||
expect(response.headers['content-language']).toEqual(fallbackLanguage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when auto-detect is enabled', () => {
|
||||
it('should be English when fallback language is unsupported', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: unsupportedLanguageX,
|
||||
fixedLanguage: unsupportedLanguageX,
|
||||
},
|
||||
});
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', unsupportedLanguageY);
|
||||
expect(response.headers['content-language']).toEqual('en');
|
||||
});
|
||||
|
||||
describe('when fallback language is supported', () => {
|
||||
beforeEach(() => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage,
|
||||
fixedLanguage: fallbackLanguage,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is no detected language', () => {
|
||||
it('should be fallback language from sign-in experience', async () => {
|
||||
const response = await phraseRequest.get('/phrase');
|
||||
expect(response.headers['content-language']).toEqual(fallbackLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are detected languages but all of them is unsupported', () => {
|
||||
it('should be first supported detected language', async () => {
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${unsupportedLanguageX},${unsupportedLanguageY}`);
|
||||
expect(response.headers['content-language']).toEqual(fallbackLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are detected languages but some of them is unsupported', () => {
|
||||
it('should be first supported detected language', async () => {
|
||||
const firstSupportedLanguage = zhCnKey;
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkKey}`);
|
||||
expect(response.headers['content-language']).toEqual(firstSupportedLanguage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
157
packages/core/src/routes/phrase.test.ts
Normal file
157
packages/core/src/routes/phrase.test.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import zhCN from '@logto/phrases-ui/lib/locales/zh-cn';
|
||||
import { SignInExperience } from '@logto/schemas';
|
||||
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { zhCnKey } from '@/__mocks__/custom-phrase';
|
||||
import * as detectLanguage from '@/i18n/detect-language';
|
||||
import phraseRoutes from '@/routes/phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
const mockApplicationId = 'mockApplicationIdValue';
|
||||
|
||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
|
||||
params: { client_id: mockApplicationId },
|
||||
}));
|
||||
|
||||
jest.mock('oidc-provider', () => ({
|
||||
Provider: jest.fn(() => ({
|
||||
interactionDetails,
|
||||
})),
|
||||
}));
|
||||
|
||||
const customizedLanguage = zhCnKey;
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: customizedLanguage,
|
||||
fixedLanguage: customizedLanguage,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock('@/queries/sign-in-experience', () => ({
|
||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
||||
}));
|
||||
|
||||
const detectLanguageSpy = jest.spyOn(detectLanguage, 'default');
|
||||
|
||||
const findAllCustomLanguageKeys = jest.fn(async () => [customizedLanguage]);
|
||||
const findCustomPhraseByLanguageKey = jest.fn(async (key: string) => ({}));
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => findAllCustomLanguageKeys(),
|
||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||
}));
|
||||
|
||||
const getPhrase = jest.fn(async (language: string, customLanguages: string[]) => zhCN);
|
||||
|
||||
jest.mock('@/lib/phrase', () => ({
|
||||
...jest.requireActual('@/lib/phrase'),
|
||||
getPhrase: async (language: string, customLanguages: string[]) =>
|
||||
getPhrase(language, customLanguages),
|
||||
}));
|
||||
|
||||
const phraseRequest = createRequester({
|
||||
anonymousRoutes: phraseRoutes,
|
||||
provider: new Provider(''),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when the application is admin-console', () => {
|
||||
beforeEach(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
params: { client_id: adminConsoleApplicationId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call interactionDetails', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(interactionDetails).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call findDefaultSignInExperience', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findDefaultSignInExperience).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call detectLanguage', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(detectLanguageSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call findAllCustomLanguageKeys', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase 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, [
|
||||
customizedLanguage,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the application is not admin-console', () => {
|
||||
it('should call interactionDetails', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(interactionDetails).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call findDefaultSignInExperience', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findDefaultSignInExperience).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call detectLanguage when auto-detect is enabled', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
...mockSignInExperience.languageInfo,
|
||||
autoDetect: true,
|
||||
},
|
||||
});
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(detectLanguageSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call detectLanguage when auto-detect is not enabled', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
...mockSignInExperience.languageInfo,
|
||||
autoDetect: false,
|
||||
},
|
||||
});
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(detectLanguageSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call findAllCustomLanguageKeys', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase with fallback language from default sign-in experience', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: false,
|
||||
fallbackLanguage: customizedLanguage,
|
||||
fixedLanguage: customizedLanguage,
|
||||
},
|
||||
});
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(getPhrase).toBeCalledTimes(1);
|
||||
expect(getPhrase).toBeCalledWith(customizedLanguage, [customizedLanguage]);
|
||||
});
|
||||
});
|
43
packages/core/src/routes/phrase.ts
Normal file
43
packages/core/src/routes/phrase.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import detectLanguage from '@/i18n/detect-language';
|
||||
import { isBuiltInLanguage, getPhrase } from '@/lib/phrase';
|
||||
import { findAllCustomLanguageKeys } from '@/queries/custom-phrase';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
|
||||
import { AnonymousRouter } from './types';
|
||||
|
||||
const getLanguageInfo = async (applicationId: unknown) => {
|
||||
if (applicationId === adminConsoleApplicationId) {
|
||||
return adminConsoleSignInExperience.languageInfo;
|
||||
}
|
||||
|
||||
const { languageInfo } = await findDefaultSignInExperience();
|
||||
|
||||
return languageInfo;
|
||||
};
|
||||
|
||||
export default function phraseRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
|
||||
router.get('/phrase', async (ctx, next) => {
|
||||
const interaction = await provider
|
||||
.interactionDetails(ctx.req, ctx.res)
|
||||
// Should not block when failed to get interaction
|
||||
.catch(() => null);
|
||||
|
||||
const applicationId = interaction?.params.client_id;
|
||||
const { autoDetect, fallbackLanguage } = await getLanguageInfo(applicationId);
|
||||
|
||||
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
|
||||
const acceptableLanguages = [...detectedLanguages, fallbackLanguage];
|
||||
const customLanguages = await findAllCustomLanguageKeys();
|
||||
const language =
|
||||
acceptableLanguages.find((key) => isBuiltInLanguage(key) || customLanguages.includes(key)) ??
|
||||
'en';
|
||||
|
||||
ctx.set('Content-Language', language);
|
||||
ctx.body = await getPhrase(language, customLanguages);
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
|
@ -6,11 +6,13 @@ import koKR from './locales/ko-kr';
|
|||
import ptPT from './locales/pt-pt';
|
||||
import trTR from './locales/tr-tr';
|
||||
import zhCN from './locales/zh-cn';
|
||||
import { Resource, Translation } from './types';
|
||||
import { Resource } from './types';
|
||||
|
||||
export * from './types';
|
||||
export { languageCodeAndDisplayNameMappings, languageOptions } from './types';
|
||||
|
||||
export type I18nKey = NormalizeKeyPaths<Translation>;
|
||||
export type { LocalePhrase } from './types';
|
||||
|
||||
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
|
||||
|
||||
const resource: Resource = {
|
||||
en,
|
||||
|
|
|
@ -2,10 +2,7 @@ import { LanguageKey, languageKeyGuard } from '@logto/core-kit';
|
|||
|
||||
import en from './locales/en';
|
||||
|
||||
export type Translation = typeof en.translation;
|
||||
export type LocalePhrase = { translation: Translation };
|
||||
|
||||
/* Copied from i18next/index.d.ts */
|
||||
export type LocalePhrase = typeof en;
|
||||
export type Resource = Record<LanguageKey, LocalePhrase>;
|
||||
|
||||
export const languageCodeAndDisplayNameMappings: Record<LanguageKey, string> = {
|
||||
|
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
@ -155,6 +155,7 @@ importers:
|
|||
'@logto/connector-kit': ^1.0.0-beta.13
|
||||
'@logto/core-kit': ^1.0.0-beta.13
|
||||
'@logto/phrases': ^1.0.0-beta.9
|
||||
'@logto/phrases-ui': ^1.0.0-beta.9
|
||||
'@logto/schemas': ^1.0.0-beta.9
|
||||
'@shopify/jest-koa-mocks': ^5.0.0
|
||||
'@silverhand/eslint-config': 1.0.0
|
||||
|
@ -179,6 +180,7 @@ importers:
|
|||
'@types/supertest': ^2.0.11
|
||||
'@types/tar': ^6.1.2
|
||||
chalk: ^4
|
||||
clean-deep: ^3.4.0
|
||||
copyfiles: ^2.4.1
|
||||
dayjs: ^1.10.5
|
||||
debug: ^4.3.4
|
||||
|
@ -231,9 +233,11 @@ importers:
|
|||
'@logto/connector-kit': 1.0.0-beta.13
|
||||
'@logto/core-kit': 1.0.0-beta.13
|
||||
'@logto/phrases': link:../phrases
|
||||
'@logto/phrases-ui': link:../phrases-ui
|
||||
'@logto/schemas': link:../schemas
|
||||
'@silverhand/essentials': 1.2.1
|
||||
chalk: 4.1.2
|
||||
clean-deep: 3.4.0
|
||||
dayjs: 1.10.7
|
||||
debug: 4.3.4
|
||||
decamelize: 5.0.1
|
||||
|
@ -5579,7 +5583,6 @@ packages:
|
|||
lodash.isempty: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.transform: 4.6.0
|
||||
dev: true
|
||||
|
||||
/clean-regexp/1.0.0:
|
||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||
|
@ -10225,7 +10228,6 @@ packages:
|
|||
|
||||
/lodash.isempty/4.4.0:
|
||||
resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==}
|
||||
dev: true
|
||||
|
||||
/lodash.ismatch/4.4.0:
|
||||
resolution: {integrity: sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=}
|
||||
|
@ -10233,7 +10235,6 @@ packages:
|
|||
|
||||
/lodash.isplainobject/4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
dev: true
|
||||
|
||||
/lodash.kebabcase/4.1.1:
|
||||
resolution: {integrity: sha1-hImxyw0p/4gZXM7KRI/21swpXDY=}
|
||||
|
@ -10279,7 +10280,6 @@ packages:
|
|||
|
||||
/lodash.transform/4.6.0:
|
||||
resolution: {integrity: sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==}
|
||||
dev: true
|
||||
|
||||
/lodash.truncate/4.4.2:
|
||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||
|
|
Loading…
Add table
Reference in a new issue