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