mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
7 changed files with 577 additions and 47 deletions
@ -0,0 +1,144 @@ |
|||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|||
|
|||
import { Logger } from '@nestjs/common'; |
|||
import { NextFunction, Request, Response } from 'express'; |
|||
import { readFileSync, readdirSync } from 'node:fs'; |
|||
|
|||
import { HtmlTemplateMiddleware } from './html-template.middleware'; |
|||
|
|||
jest.mock('@ghostfolio/api/environments/environment', () => { |
|||
return { environment: { production: true, rootUrl: 'https://ghostfol.io' } }; |
|||
}); |
|||
|
|||
jest.mock('node:fs', () => { |
|||
return { |
|||
...jest.requireActual('node:fs'), |
|||
readFileSync: jest.fn(), |
|||
readdirSync: jest.fn() |
|||
}; |
|||
}); |
|||
|
|||
const genericDescription = 'Generic description'; |
|||
const indexHtml = '<meta content="${description}" name="description" />'; |
|||
const productPath = |
|||
'/en/resources/personal-finance-tools/open-source-alternative-to-8figures'; |
|||
|
|||
function createLocale({ |
|||
pricingDescription = 'Pricing description', |
|||
productDescription |
|||
}: { |
|||
pricingDescription?: string; |
|||
productDescription: string; |
|||
}) { |
|||
return ` |
|||
<xliff> |
|||
<trans-unit id="metaDescription"> |
|||
<source>${genericDescription}</source> |
|||
</trans-unit> |
|||
<trans-unit id="metaDescription.pricing"> |
|||
<source>${pricingDescription}</source> |
|||
</trans-unit> |
|||
<trans-unit id="metaDescription.resources.personalFinanceTools.product"> |
|||
<source>${productDescription}</source> |
|||
</trans-unit> |
|||
<trans-unit id="routes.pricing"> |
|||
<source>pricing</source> |
|||
</trans-unit> |
|||
<trans-unit id="routes.resources"> |
|||
<source>resources</source> |
|||
</trans-unit> |
|||
<trans-unit id="routes.resources.personalFinanceTools"> |
|||
<source>personal-finance-tools</source> |
|||
</trans-unit> |
|||
<trans-unit id="routes.resources.personalFinanceTools.openSourceAlternativeTo"> |
|||
<source>open-source-alternative-to</source> |
|||
</trans-unit> |
|||
</xliff> |
|||
`;
|
|||
} |
|||
|
|||
function getResponseBody({ |
|||
locale, |
|||
originalUrl |
|||
}: { |
|||
locale: string; |
|||
originalUrl: string; |
|||
}) { |
|||
(readdirSync as jest.Mock).mockReturnValue(['messages.xlf']); |
|||
(readFileSync as jest.Mock).mockImplementation((path: string) => { |
|||
return path.endsWith('index.html') ? indexHtml : locale; |
|||
}); |
|||
|
|||
const i18nService = new I18nService(); |
|||
i18nService.onModuleInit(); |
|||
|
|||
const send = jest.fn(); |
|||
|
|||
new HtmlTemplateMiddleware(i18nService).use( |
|||
{ originalUrl } as Request, |
|||
{ send } as unknown as Response, |
|||
jest.fn() as NextFunction |
|||
); |
|||
|
|||
return send.mock.calls[0][0]; |
|||
} |
|||
|
|||
describe('HtmlTemplateMiddleware', () => { |
|||
beforeEach(() => { |
|||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
it('serves the description of a public route', () => { |
|||
expect( |
|||
getResponseBody({ |
|||
locale: createLocale({ productDescription: 'Compare ${name}' }), |
|||
originalUrl: '/en/pricing' |
|||
}) |
|||
).toContain('content="Pricing description"'); |
|||
}); |
|||
|
|||
it('interpolates the name into the description of a personal finance tool', () => { |
|||
expect( |
|||
getResponseBody({ |
|||
locale: createLocale({ productDescription: 'Compare ${name}' }), |
|||
originalUrl: productPath |
|||
}) |
|||
).toContain('content="Compare 8FIGURES"'); |
|||
}); |
|||
|
|||
it('falls back to the generic description if the name token is not in the description of a personal finance tool', () => { |
|||
expect( |
|||
getResponseBody({ |
|||
locale: createLocale({ productDescription: 'Compare ${product}' }), |
|||
originalUrl: productPath |
|||
}) |
|||
).toContain(`content="${genericDescription}"`); |
|||
}); |
|||
|
|||
it('falls back to the generic description if the description of a personal finance tool contains another token than the name token', () => { |
|||
expect( |
|||
getResponseBody({ |
|||
locale: createLocale({ |
|||
productDescription: 'Compare ${name} (${product})' |
|||
}), |
|||
originalUrl: productPath |
|||
}) |
|||
).toContain(`content="${genericDescription}"`); |
|||
}); |
|||
|
|||
it('escapes the description for the use in an attribute', () => { |
|||
expect( |
|||
getResponseBody({ |
|||
locale: createLocale({ |
|||
pricingDescription: 'Compare "plans" & prices', |
|||
productDescription: 'Compare ${name}' |
|||
}), |
|||
originalUrl: '/en/pricing' |
|||
}) |
|||
).toContain('content="Compare "plans" & prices"'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,109 @@ |
|||
import { Logger } from '@nestjs/common'; |
|||
import { readFileSync, readdirSync } from 'node:fs'; |
|||
|
|||
import { I18nService } from './i18n.service'; |
|||
|
|||
jest.mock('node:fs', () => { |
|||
return { |
|||
...jest.requireActual('node:fs'), |
|||
readFileSync: jest.fn(), |
|||
readdirSync: jest.fn() |
|||
}; |
|||
}); |
|||
|
|||
const germanLocale = ` |
|||
<xliff> |
|||
<trans-unit id="metaDescription.new"> |
|||
<source>New</source> |
|||
<target state="new">New</target> |
|||
</trans-unit> |
|||
<trans-unit id="metaDescription.translated"> |
|||
<source>Translated</source> |
|||
<target state="translated">Übersetzt</target> |
|||
</trans-unit> |
|||
<trans-unit id="routes.faq"> |
|||
<source>faq</source> |
|||
<target state="translated">haeufig-gestellte-fragen</target> |
|||
</trans-unit> |
|||
<trans-unit id="routes.faq.selfHosting"> |
|||
<source>self-hosting</source> |
|||
<target state="translated">selbst-hosting</target> |
|||
</trans-unit> |
|||
</xliff> |
|||
`;
|
|||
|
|||
describe('I18nService', () => { |
|||
let i18nService: I18nService; |
|||
let warnSpy: jest.SpyInstance; |
|||
|
|||
beforeEach(() => { |
|||
(readdirSync as jest.Mock).mockReturnValue(['messages.de.xlf']); |
|||
(readFileSync as jest.Mock).mockReturnValue(germanLocale); |
|||
|
|||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); |
|||
|
|||
i18nService = new I18nService(); |
|||
i18nService.onModuleInit(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
describe('getTranslation', () => { |
|||
it('returns a target with the state "new" by default', () => { |
|||
expect( |
|||
i18nService.getTranslation({ |
|||
id: 'metaDescription.new', |
|||
languageCode: 'de' |
|||
}) |
|||
).toBe('New'); |
|||
}); |
|||
|
|||
it('skips a target with the state "new" without a warning if skipUntranslated is true', () => { |
|||
expect( |
|||
i18nService.getTranslation({ |
|||
id: 'metaDescription.new', |
|||
languageCode: 'de', |
|||
skipUntranslated: true |
|||
}) |
|||
).toBe(''); |
|||
expect(warnSpy).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('returns a translated target if skipUntranslated is true', () => { |
|||
expect( |
|||
i18nService.getTranslation({ |
|||
id: 'metaDescription.translated', |
|||
languageCode: 'de', |
|||
skipUntranslated: true |
|||
}) |
|||
).toBe('Übersetzt'); |
|||
}); |
|||
|
|||
it('logs a warning for an unknown id by default', () => { |
|||
expect( |
|||
i18nService.getTranslation({ |
|||
id: 'metaDescription.unknown', |
|||
languageCode: 'de' |
|||
}) |
|||
).toBe(''); |
|||
expect(warnSpy).toHaveBeenCalledTimes(1); |
|||
}); |
|||
}); |
|||
|
|||
describe('getLocalizedRouterLink', () => { |
|||
it('localizes the tagged segments and keeps the other segments', () => { |
|||
expect( |
|||
i18nService.getLocalizedRouterLink({ |
|||
languageCode: 'de', |
|||
routerLink: [ |
|||
'/:kebab-case@@routes.faq:faq', |
|||
':kebab-case@@routes.faq.selfHosting:self-hosting', |
|||
'saas' |
|||
] |
|||
}) |
|||
).toEqual(['haeufig-gestellte-fragen', 'selbst-hosting', 'saas']); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue