mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
refactor(core,phrases): rm empty strings and check structure in PUT /custom-phrase (#2022)
This commit is contained in:
parent
a4cb138c12
commit
1271cd162e
10 changed files with 146 additions and 7 deletions
|
@ -1,4 +1,5 @@
|
|||
import { CustomPhrase, SignInExperience } from '@logto/schemas';
|
||||
import en from '@logto/phrases-ui/lib/locales/en';
|
||||
import { CustomPhrase, SignInExperience, Translation } from '@logto/schemas';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { mockZhCnCustomPhrase, trTrKey, zhCnKey } from '@/__mocks__/custom-phrase';
|
||||
|
@ -39,6 +40,15 @@ jest.mock('@/queries/custom-phrase', () => ({
|
|||
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
|
||||
}));
|
||||
|
||||
const isValidStructure = jest.fn(
|
||||
(fullTranslation: Translation, partialTranslation: Partial<Translation>) => true
|
||||
);
|
||||
|
||||
jest.mock('@/utils/translation', () => ({
|
||||
isValidStructure: (fullTranslation: Translation, partialTranslation: Translation) =>
|
||||
isValidStructure(fullTranslation, partialTranslation),
|
||||
}));
|
||||
|
||||
const mockFallbackLanguage = trTrKey;
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
|
@ -102,17 +112,41 @@ describe('customPhraseRoutes', () => {
|
|||
});
|
||||
|
||||
describe('PUT /custom-phrases/:languageKey', () => {
|
||||
it('should call upsertCustomPhrase with specified language key', async () => {
|
||||
await customPhraseRequest
|
||||
const translation = mockCustomPhrases[mockLanguageKey]?.translation;
|
||||
|
||||
it('should remove empty strings', async () => {
|
||||
const inputTranslation = { username: '用户名 1' };
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send({
|
||||
input: { ...inputTranslation, password: '' },
|
||||
});
|
||||
expect(upsertCustomPhrase).toBeCalledWith({
|
||||
languageKey: mockLanguageKey,
|
||||
translation: { input: inputTranslation },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isValidStructure', async () => {
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send(translation);
|
||||
expect(isValidStructure).toBeCalledWith(en.translation, translation);
|
||||
});
|
||||
|
||||
it('should fail when the input translation structure is invalid', async () => {
|
||||
isValidStructure.mockReturnValueOnce(false);
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||
.send(mockCustomPhrases[mockLanguageKey]?.translation);
|
||||
.send(translation);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should call upsertCustomPhrase with specified language key', async () => {
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send(translation);
|
||||
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageKey]);
|
||||
});
|
||||
|
||||
it('should return custom phrase after upserting', async () => {
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||
.send(mockCustomPhrases[mockLanguageKey]?.translation);
|
||||
.send(translation);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { CustomPhrases, translationGuard } from '@logto/schemas';
|
||||
import resource from '@logto/phrases-ui';
|
||||
import { CustomPhrases, Translation, translationGuard } from '@logto/schemas';
|
||||
import cleanDeep from 'clean-deep';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
|
@ -9,9 +11,16 @@ import {
|
|||
upsertCustomPhrase,
|
||||
} from '@/queries/custom-phrase';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
import { isValidStructure } from '@/utils/translation';
|
||||
|
||||
import { AuthedRouter } from './types';
|
||||
|
||||
const cleanDeepTranslation = (translation: Translation) =>
|
||||
// Since `Translation` type actually equals `Partial<Translation>`, force to cast it back to `Translation`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
cleanDeep(translation) as Translation;
|
||||
|
||||
export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
|
||||
router.get(
|
||||
'/custom-phrases',
|
||||
|
@ -56,7 +65,14 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
|
|||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.body = await upsertCustomPhrase({ languageKey, translation: body });
|
||||
const translation = cleanDeepTranslation(body);
|
||||
|
||||
assertThat(
|
||||
isValidStructure(resource.en.translation, translation),
|
||||
new RequestError('localization.invalid_translation_structure')
|
||||
);
|
||||
|
||||
ctx.body = await upsertCustomPhrase({ languageKey, translation });
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
42
packages/core/src/utils/translation.test.ts
Normal file
42
packages/core/src/utils/translation.test.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import en from '@logto/phrases-ui/lib/locales/en';
|
||||
import fr from '@logto/phrases-ui/lib/locales/fr';
|
||||
|
||||
import { isValidStructure } from '@/utils/translation';
|
||||
|
||||
const customizedFrTranslation = {
|
||||
secondary: {
|
||||
sign_in_with: 'Customized value A',
|
||||
social_bind_with: 'Customized value B',
|
||||
},
|
||||
};
|
||||
|
||||
describe('isValidStructure', () => {
|
||||
it('should be true when its structure is valid', () => {
|
||||
expect(isValidStructure(en.translation, fr.translation)).toBeTruthy();
|
||||
expect(isValidStructure(en.translation, customizedFrTranslation)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be true when the structure is partial and the existing key-value pairs are correct', () => {
|
||||
expect(
|
||||
isValidStructure(en.translation, {
|
||||
secondary: {
|
||||
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
|
||||
// Missing 'secondary.social_bind_with' key-value pair
|
||||
},
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be false when there is an unexpected key-value pair', () => {
|
||||
expect(
|
||||
isValidStructure(en.translation, {
|
||||
secondary: {
|
||||
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
|
||||
social_bind_with:
|
||||
'Vous avez déjà un compte ? Connectez-vous pour lier {{methods, list(type: disjunction;)}} avec votre identité sociale.',
|
||||
foo: 'bar', // Unexpected key-value pair
|
||||
},
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
36
packages/core/src/utils/translation.ts
Normal file
36
packages/core/src/utils/translation.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Translation } from '@logto/schemas';
|
||||
|
||||
export const isValidStructure = (fullTranslation: Translation, partialTranslation: Translation) => {
|
||||
const fullKeys = new Set(Object.keys(fullTranslation));
|
||||
const partialKeys = Object.keys(partialTranslation);
|
||||
|
||||
if (fullKeys.size === 0 || partialKeys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (partialKeys.some((key) => !fullKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(fullTranslation)) {
|
||||
const targetValue = partialTranslation[key];
|
||||
|
||||
if (targetValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (typeof targetValue === 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof targetValue === 'string' || !isValidStructure(value, targetValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
|
@ -110,6 +110,8 @@ const errors = {
|
|||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: 'Invalid Zod type. Please check route guard config.',
|
||||
|
|
|
@ -118,6 +118,8 @@ const errors = {
|
|||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: 'Type Zod non valide. Veuillez vérifier la configuration du garde-route.',
|
||||
|
|
|
@ -107,6 +107,8 @@ const errors = {
|
|||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: '유요하지 않은 Zod 종류에요. Route Guard 설정을 확인해주세요.',
|
||||
|
|
|
@ -113,6 +113,8 @@ const errors = {
|
|||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: 'Tipo de Zod inválido. Verifique a configuração do protetor de rota.',
|
||||
|
|
|
@ -111,6 +111,8 @@ const errors = {
|
|||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type:
|
||||
|
|
|
@ -102,6 +102,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageKey}}。', // UNTRANSLATED
|
||||
invalid_translation_structure: '无效的 translation 结构。请检查输入的 translation。', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',
|
||||
|
|
Loading…
Add table
Reference in a new issue