feat: Create ChainedTemplateEngine for combining engines

This commit is contained in:
Joachim Van Herwegen 2021-08-02 15:36:51 +02:00
parent 8c266f09c5
commit 18a71032c0
25 changed files with 423 additions and 517 deletions

View File

@ -23,7 +23,22 @@
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"templateHandler": { "templateHandler": {
"@type": "TemplateHandler", "@type": "TemplateHandler",
"templateEngine": { "@type": "EjsTemplateEngine" } "templateEngine": {
"comment": "Renders the specific page and embeds it into the main HTML body.",
"@type": "ChainedTemplateEngine",
"renderedName": "htmlBody",
"engines": [
{
"comment": "Will be called with specific interaction templates to generate HTML snippets.",
"@type": "EjsTemplateEngine"
},
{
"comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/main.html.ejs",
}
]
}
}, },
"interactionCompleter": { "interactionCompleter": {
"comment": "Responsible for finishing OIDC interactions.", "comment": "Responsible for finishing OIDC interactions.",

View File

@ -7,7 +7,7 @@
"@type": "InteractionRoute", "@type": "InteractionRoute",
"route": "^/forgotpassword/?$", "route": "^/forgotpassword/?$",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/forgot-password.html.ejs", "viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/forgot-password.html.ejs",
"responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/email-sent.html.ejs", "responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/forgot-password-response.html.ejs",
"handler": { "handler": {
"@type": "ForgotPasswordHandler", "@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },

View File

@ -8,7 +8,7 @@
"@type": "InteractionRoute", "@type": "InteractionRoute",
"route": "^/resetpassword(/[^/]*)?$", "route": "^/resetpassword(/[^/]*)?$",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/reset-password.html.ejs", "viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/reset-password.html.ejs",
"responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/message.html.ejs", "responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/reset-password-response.html.ejs",
"handler": { "handler": {
"@type": "ResetPasswordHandler", "@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }

View File

@ -11,8 +11,8 @@
"@id": "urn:solid-server:default:MarkdownToHtmlConverter", "@id": "urn:solid-server:default:MarkdownToHtmlConverter",
"@type": "MarkdownToHtmlConverter", "@type": "MarkdownToHtmlConverter",
"templateEngine": { "templateEngine": {
"@type": "HandlebarsTemplateEngine", "@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/main.html.hbs" "template": "$PACKAGE_ROOT/templates/main.html.ejs"
} }
}, },
{ {

View File

@ -182,9 +182,9 @@ export class IdentityProviderHttpHandler extends HttpHandler {
throw new BadRequestHttpError(`Unsupported request: ${request.method} ${request.url}`); throw new BadRequestHttpError(`Unsupported request: ${request.method} ${request.url}`);
} }
private async handleTemplateResponse(response: HttpResponse, templateFile: string, contents: NodeJS.Dict<any>): private async handleTemplateResponse(response: HttpResponse, templateFile: string, contents?: NodeJS.Dict<any>):
Promise<void> { Promise<void> {
await this.templateHandler.handleSafe({ response, templateFile, contents }); await this.templateHandler.handleSafe({ response, templateFile, contents: contents ?? {}});
} }
/** /**

View File

@ -6,7 +6,7 @@ export type InteractionHandlerResult = InteractionResponseResult | InteractionCo
export interface InteractionResponseResult<T = NodeJS.Dict<any>> { export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
type: 'response'; type: 'response';
details: T; details?: T;
} }
export interface InteractionCompleteResult { export interface InteractionCompleteResult {

View File

@ -34,7 +34,7 @@ export class ResetPasswordHandler extends InteractionHandler {
assertPassword(password, confirmPassword); assertPassword(password, confirmPassword);
await this.resetPassword(recordId, password); await this.resetPassword(recordId, password);
return { type: 'response', details: { message: 'Your password was successfully reset.' }}; return { type: 'response' };
} catch (error: unknown) { } catch (error: unknown) {
throwIdpInteractionError(error); throwIdpInteractionError(error);
} }

View File

@ -304,6 +304,7 @@ export * from './util/locking/SingleThreadedResourceLocker';
export * from './util/locking/WrappedExpiringReadWriteLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker';
// Util/Templates // Util/Templates
export * from './util/templates/ChainedTemplateEngine';
export * from './util/templates/EjsTemplateEngine'; export * from './util/templates/EjsTemplateEngine';
export * from './util/templates/HandlebarsTemplateEngine'; export * from './util/templates/HandlebarsTemplateEngine';
export * from './util/templates/TemplateEngine'; export * from './util/templates/TemplateEngine';

View File

@ -23,12 +23,8 @@ export class MarkdownToHtmlConverter extends TypedRepresentationConverter {
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const markdown = await readableToString(representation.data); const markdown = await readableToString(representation.data);
// Try to extract the main title for use in the <title> tag
const title = /^#+\s*([^\n]+)\n/u.exec(markdown)?.[1];
// Place the rendered Markdown into the HTML template
const htmlBody = marked(markdown); const htmlBody = marked(markdown);
const html = await this.templateEngine.render({ htmlBody, title }); const html = await this.templateEngine.render({ htmlBody });
return new BasicRepresentation(html, representation.metadata, TEXT_HTML); return new BasicRepresentation(html, representation.metadata, TEXT_HTML);
} }

View File

@ -0,0 +1,39 @@
import type { Template, TemplateEngine } from './TemplateEngine';
import Dict = NodeJS.Dict;
/**
* Calls the given array of {@link TemplateEngine}s in the order they appear,
* feeding the output of one into the input of the next.
*
* The first engine will be called with the provided contents and template parameters.
* All subsequent engines will be called with no template parameter.
* Contents will still be passed along and another entry will be added for the body of the previous output.
*/
export class ChainedTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> {
private readonly firstEngine: TemplateEngine<T>;
private readonly chainedEngines: TemplateEngine[];
private readonly renderedName: string;
/**
* @param engines - Engines will be executed in the same order as the array.
* @param renderedName - The name of the key used to pass the body of one engine to the next.
*/
public constructor(engines: TemplateEngine[], renderedName = 'body') {
if (engines.length === 0) {
throw new Error('At least 1 engine needs to be provided.');
}
this.firstEngine = engines[0];
this.chainedEngines = engines.slice(1);
this.renderedName = renderedName;
}
public async render(contents: T): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
let body = await this.firstEngine.render(contents, template!);
for (const engine of this.chainedEngines) {
body = await engine.render({ ...contents, [this.renderedName]: body });
}
return body;
}
}

View File

@ -1,27 +1,4 @@
<!DOCTYPE html> <h1>Authorize</h1>
<html lang="en"> <form action="/idp/confirm" method="post">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Authorize</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Authorize</h1>
<form action="/idp/confirm" method="post">
<p class="actions"><button autofocus type="submit" name="submit" class="ids-link-filled">Continue</button></p> <p class="actions"><button autofocus type="submit" name="submit" class="ids-link-filled">Continue</button></p>
</form> </form>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Email sent</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Email sent</h1>
<form action="/idp/forgotpassword" method="post">
<p>If your account exists, an email has been sent with a link to reset your password.</p>
<p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
<input type="hidden" name="email" value="<%= email %>" />
<p class="actions"><a href="/idp/login">Back to Log In</a></p>
<hr />
<p class="actions">
<button type="submit" name="submit" class="link">Send Another Email</button>
</p>
</form>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,13 @@
<h1>Email sent</h1>
<form action="/idp/forgotpassword" method="post">
<p>If your account exists, an email has been sent with a link to reset your password.</p>
<p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
<input type="hidden" name="email" value="<%= email %>" />
<p class="actions"><a href="/idp/login">Back to Log In</a></p>
<p class="actions">
<button type="submit" name="submit" class="link">Send Another Email</button>
</p>
</form>

View File

@ -1,19 +1,5 @@
<!DOCTYPE html> <h1>Forgot password</h1>
<html lang="en"> <form action="/idp/forgotpassword" method="post">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Forgot password</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Forgot password</h1>
<form action="/idp/forgotpassword" method="post">
<%if (errorMessage) { %> <%if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>
@ -30,13 +16,4 @@
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p> <p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
<p class="actions"><a href="/idp/login" class="link">Log in</a></p> <p class="actions"><a href="/idp/login" class="link">Log in</a></p>
</form> </form>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -1,19 +1,5 @@
<!DOCTYPE html> <h1>Log in</h1>
<html lang="en"> <form action="/idp/login" method="post">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Log in</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Log in</h1>
<form action="/idp/login" method="post">
<%if (errorMessage) { %> <%if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>
@ -41,13 +27,4 @@
<li><a href="/idp/register" class="link">Sign up</a></li> <li><a href="/idp/register" class="link">Sign up</a></li>
<li><a href="/idp/forgotpassword" class="link">Forgot password</a></li> <li><a href="/idp/forgotpassword" class="link">Forgot password</a></li>
</ul> </ul>
</form> </form>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -1,42 +1,28 @@
<!DOCTYPE html> <h1>You've been signed up</h1>
<html lang="en"> <p>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>You are signed up</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>You are signed up</h1>
<p>
<strong>Welcome to Solid.</strong> <strong>Welcome to Solid.</strong>
We wish you an exciting experience! We wish you an exciting experience!
</p> </p>
<% if (createPod) { %> <% if (createPod) { %>
<h2>Your new Pod</h2> <h2>Your new Pod</h2>
<p> <p>
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>. Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
<br> <br>
You can store your documents and data there. You can store your documents and data there.
</p> </p>
<% } %> <% } %>
<% if (createWebId) { %> <% if (createWebId) { %>
<h2>Your new WebID</h2> <h2>Your new WebID</h2>
<p> <p>
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>. Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
<br> <br>
You can use this identifier to interact with Solid pods and apps. You can use this identifier to interact with Solid pods and apps.
</p> </p>
<% } %> <% } %>
<% if (register) { %> <% if (register) { %>
<h2>Your new account</h2> <h2>Your new account</h2>
<p> <p>
Via your email address <em><%= email %></em>, Via your email address <em><%= email %></em>,
@ -51,13 +37,4 @@
to indicate that you trust this server as a login provider. to indicate that you trust this server as a login provider.
</p> </p>
<% } %> <% } %>
<% } %> <% } %>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -1,19 +1,5 @@
<!DOCTYPE html> <h1>Sign up</h1>
<html lang="en"> <form action="/idp/register" method="post" id="mainForm">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Register</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Sign up</h1>
<form action="/idp/register" method="post" id="mainForm">
<% const isBlankForm = !('email' in prefilled); %> <% const isBlankForm = !('email' in prefilled); %>
<% if (errorMessage) { %> <% if (errorMessage) { %>
@ -122,11 +108,11 @@
</fieldset> </fieldset>
<p class="actions"><button type="submit" name="submit">Sign up</button></p> <p class="actions"><button type="submit" name="submit">Sign up</button></p>
</form> </form>
<script> <script>
// Assist the user with filling out the form by hiding irrelevant fields // Assist the user with filling out the form by hiding irrelevant fields
(() => { (() => {
// Wire up the UI elements // Wire up the UI elements
const elements = {}; const elements = {};
[ [
@ -199,14 +185,5 @@
child.disabled = false; child.disabled = false;
}); });
elements.mainForm.addEventListener('formdata', updateUI); elements.mainForm.addEventListener('formdata', updateUI);
})(); })();
</script> </script>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,2 @@
<h1>Password reset</h1>
<p>Your password was successfully reset.</p>

View File

@ -1,19 +1,5 @@
<!DOCTYPE html> <h1>Reset password</h1>
<html lang="en"> <form method="post">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Reset password</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Reset password</h1>
<form method="post">
<%if (errorMessage) { %> <%if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>
@ -32,13 +18,4 @@
</fieldset> </fieldset>
<p class="actions"><button type="submit" name="submit">Reset password</button></p> <p class="actions"><button type="submit" name="submit">Reset password</button></p>
</form> </form>
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><%= message %></title> <title><%= extractTitle(htmlBody) %></title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css"> <link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head> </head>
<body> <body>
@ -12,7 +12,7 @@
<h1>Community Solid Server</h1> <h1>Community Solid Server</h1>
</header> </header>
<main> <main>
<p><%= message %></p> <%- htmlBody %>
</main> </main>
<footer> <footer>
<p> <p>
@ -22,3 +22,10 @@
</footer> </footer>
</body> </body>
</html> </html>
<%
function extractTitle(body) {
const match = /^<h1[^>]*>([^<]*)<\/h1>/u.exec(body);
return match ? match[1] : 'Solid';
}
%>

View File

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{{#if title}}
<title>{{ title }}</title>
{{/if}}
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
{{{ htmlBody }}}
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>
</body>
</html>

View File

@ -100,6 +100,19 @@ describe('An IdentityProviderHttpHandler', (): void => {
); );
}); });
it('supports InteractionResponseResults without details.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(routes.response.handler as jest.Mocked<InteractionHandler>).handleSafe.mockResolvedValueOnce({ type: 'response' });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, response });
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
{ response, templateFile: routes.response.responseTemplate, contents: {}},
);
});
it('calls the interactionCompleter for InteractionCompleteResults.', async(): Promise<void> => { it('calls the interactionCompleter for InteractionCompleteResults.', async(): Promise<void> => {
request.url = '/idp/routeComplete'; request.url = '/idp/routeComplete';
request.method = 'POST'; request.method = 'POST';

View File

@ -48,10 +48,7 @@ describe('A ResetPasswordHandler', (): void => {
it('renders a message on success.', async(): Promise<void> => { it('renders a message on success.', async(): Promise<void> => {
request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request, response })).resolves.toEqual({ type: 'response' });
details: { message: 'Your password was successfully reset.' },
type: 'response',
});
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);

View File

@ -35,19 +35,4 @@ describe('A MarkdownToHtmlConverter', (): void => {
{ htmlBody: '<p>Text <code>code</code> more text.</p>\n' }, { htmlBody: '<p>Text <code>code</code> more text.</p>\n' },
); );
}); });
it('uses the main markdown header as title if there is one.', async(): Promise<void> => {
const markdown = '# title text\nmore text';
const representation = new BasicRepresentation(markdown, 'text/markdown', true);
const prom = converter.handle({ identifier, representation, preferences });
await expect(prom).resolves.toBeDefined();
const result = await prom;
expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenLastCalledWith(
{ htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' },
);
});
}); });

View File

@ -0,0 +1,39 @@
import { ChainedTemplateEngine } from '../../../../src/util/templates/ChainedTemplateEngine';
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
describe('A ChainedTemplateEngine', (): void => {
const contents = { title: 'myTitle' };
const template = { templateFile: '/template.tmpl' };
let engines: jest.Mocked<TemplateEngine>[];
let engine: ChainedTemplateEngine;
beforeEach(async(): Promise<void> => {
engines = [
{ render: jest.fn().mockResolvedValue('body1') },
{ render: jest.fn().mockResolvedValue('body2') },
];
engine = new ChainedTemplateEngine(engines);
});
it('errors if no engines are provided.', async(): Promise<void> => {
expect((): any => new ChainedTemplateEngine([])).toThrow('At least 1 engine needs to be provided.');
});
it('chains the engines.', async(): Promise<void> => {
await expect(engine.render(contents, template)).resolves.toEqual('body2');
expect(engines[0].render).toHaveBeenCalledTimes(1);
expect(engines[0].render).toHaveBeenLastCalledWith(contents, template);
expect(engines[1].render).toHaveBeenCalledTimes(1);
expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, body: 'body1' });
});
it('can use a different field to pass along the body.', async(): Promise<void> => {
engine = new ChainedTemplateEngine(engines, 'different');
await expect(engine.render(contents, template)).resolves.toEqual('body2');
expect(engines[0].render).toHaveBeenCalledTimes(1);
expect(engines[0].render).toHaveBeenLastCalledWith(contents, template);
expect(engines[1].render).toHaveBeenCalledTimes(1);
expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, different: 'body1' });
});
});