0
Fork 0
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:
IceHe 2022-09-29 17:14:20 +08:00 committed by GitHub
parent a4cb138c12
commit 1271cd162e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 146 additions and 7 deletions

View file

@ -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]);
});

View file

@ -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();
}

View 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();
});
});

View 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;
};

View file

@ -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.',

View file

@ -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.',

View file

@ -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 설정을 확인해주세요.',

View file

@ -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.',

View file

@ -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:

View file

@ -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 配置。',