import { promises as fsp } from 'node:fs'; import path from 'node:path'; import { cloneDeep } from 'lodash'; import { CLIProductModuleDefinition, CLIProductModuleDefinitionOutput, productModuleDefinitionFromNetwork, productModuleDefinitionToNetwork, } from '../../domain/product-module-definition'; import { PolicyDocumentFileName, TopLevelDirectoryName, TopLevelFileName, WorkflowsDirectoryName, } from '../../domain/file-names'; import { existsAsync } from '../fs-helpers'; import { readProductModuleDefinition } from '../read-product-module-definition'; import { writeProductModuleDefinition } from '../write-product-module-definition'; import { expect } from '../../../test-utils'; import { cliDefinitionExample } from '../../../test-utils/test-root-funeral-pm'; import { CLIError, ExitCodes } from '../../errors/platform-error'; describe('writeProductModuleDefinition and readProductModuleDefinition', function () { let exampleDefinition: CLIProductModuleDefinitionOutput; let transformedDefinition: CLIProductModuleDefinition; let directory: string; before('Write and then read the same product module definition', async function () { exampleDefinition = productModuleDefinitionFromNetwork(cliDefinitionExample); directory = path.join('./', 'tmp'); await fsp.mkdir(directory); await writeProductModuleDefinition({ productModuleDefinition: exampleDefinition, host: 'test', directory, targetOrderDefinition: exampleDefinition, }); transformedDefinition = await readProductModuleDefinition(directory); }); it('the root config object should remain unchanged ', function () { expect(exampleDefinition.rootConfig).to.eql(transformedDefinition.rootConfig); }); it('the readme markdown should remain unchanged', function () { expect(exampleDefinition.readmeMarkdown).to.eql(transformedDefinition.readmeMarkdown); }); it('the policy document templates should remain unchanged', function () { const { termsPdfBase64: exampleTerms, supplementaryTerms: exampleSupplementaryTerms, ...exampleHtmlDocuments } = exampleDefinition.documents; const { termsPdfBase64: transformedTerms, supplementaryTerms: transformedSupplementaryTerms, ...transformedHtmlDocuments } = transformedDefinition.documents; expect(exampleHtmlDocuments).to.eql(transformedHtmlDocuments); expect(exampleTerms).to.eql(transformedTerms.split(';base64,').pop()); // Test each supplementary terms file for (const exampleFile of exampleSupplementaryTerms || []) { const transformedFile = (transformedSupplementaryTerms || []).find((f) => f.type === exampleFile.type); expect(exampleFile.pdfBase64).to.eql(transformedFile?.pdfBase64.split(';base64,').pop()); } }); it('the workflow items should remain unchanged', function () { expect(exampleDefinition.workflows).to.eql(transformedDefinition.workflows); }); it('the api docs should remain unchanged', function () { expect(exampleDefinition.apiDocsSchema).to.to.eql(transformedDefinition.apiDocsSchema); }); it('the code files should remain unchanged', function () { expect(exampleDefinition.codeFiles).to.to.eql(transformedDefinition.codeFiles); expect(exampleDefinition.unitTestCodeFiles).to.to.eql(transformedDefinition.unitTestCodeFiles); }); after('Remove the product module directory', async function () { await fsp.rm(directory, { recursive: true }); }); }); describe('member alteration hooks round trip', function () { let directory: string; const pullThenPush = async (network: typeof cliDefinitionExample) => { const definition = productModuleDefinitionFromNetwork(network); await fsp.mkdir(directory); await writeProductModuleDefinition({ productModuleDefinition: definition, host: 'test', directory, targetOrderDefinition: definition, }); const localDefinition = await readProductModuleDefinition(directory); return { definition, localDefinition, payload: productModuleDefinitionToNetwork(localDefinition) }; }; const readRootConfig = async () => JSON.parse(await fsp.readFile(path.join(directory, TopLevelFileName.RootConfig), { encoding: 'utf8' })); afterEach('Remove the product module directory', async function () { await fsp.rm(directory, { recursive: true, force: true }); }); it('writes each hook to .root-config.json and workflows/member-alteration-hooks on pull', async function () { directory = path.join('./', 'tmp-member-hooks-present'); await pullThenPush(cloneDeep(cliDefinitionExample)); expect((await readRootConfig()).memberAlterationHooks).to.eql([ { key: 'member_alteration_hook_1', name: 'Member Alteration hook 1' }, ]); const schemaPath = path.join( directory, TopLevelDirectoryName.Workflows, WorkflowsDirectoryName.MemberAlterationHooks, 'member_alteration_hook_1.json', ); expect(JSON.parse(await fsp.readFile(schemaPath, { encoding: 'utf8' }))).to.eql( cliDefinitionExample.workflows.member_alteration_hooks?.[0].schema, ); }); it('sends the hooks back under workflows.member_alteration_hooks on push', async function () { directory = path.join('./', 'tmp-member-hooks-push'); const { payload } = await pullThenPush(cloneDeep(cliDefinitionExample)); expect(payload.workflows.member_alteration_hooks).to.eql(cliDefinitionExample.workflows.member_alteration_hooks); }); it('does not leak memberAlterationHooks into the root_config sent on push', async function () { directory = path.join('./', 'tmp-member-hooks-root-config'); const { payload } = await pullThenPush(cloneDeep(cliDefinitionExample)); expect(payload.root_config).to.not.have.property('memberAlterationHooks'); expect(Object.keys(payload.root_config).sort()).to.eql([ 'fulfillmentTypes', 'scheduledFunctions', 'settings', 'stack', ]); }); it('leaves member_alteration_hooks out of the push when there are none', async function () { const network = cloneDeep(cliDefinitionExample); delete network.workflows.member_alteration_hooks; directory = path.join('./', 'tmp-member-hooks-absent'); const { localDefinition, payload } = await pullThenPush(network); expect((await readRootConfig()).memberAlterationHooks).to.eql([]); expect(localDefinition.workflows.memberAlterationHooks).to.eql([]); expect(payload.workflows).to.not.have.property('member_alteration_hooks'); }); it('reads a .root-config.json that has no memberAlterationHooks key at all', async function () { directory = path.join('./', 'tmp-member-hooks-legacy-config'); await pullThenPush(cloneDeep(cliDefinitionExample)); const rootConfigPath = path.join(directory, TopLevelFileName.RootConfig); const rootConfig = JSON.parse(await fsp.readFile(rootConfigPath, { encoding: 'utf8' })); delete rootConfig.memberAlterationHooks; await fsp.writeFile(rootConfigPath, JSON.stringify(rootConfig, null, 2)); await fsp.rm( path.join( directory, TopLevelDirectoryName.Workflows, WorkflowsDirectoryName.MemberAlterationHooks, 'member_alteration_hook_1.json', ), ); const localDefinition = await readProductModuleDefinition(directory); expect(localDefinition.workflows.memberAlterationHooks).to.eql([]); expect(productModuleDefinitionToNetwork(localDefinition).workflows).to.not.have.property('member_alteration_hooks'); }); }); describe('invoice template document handling', function () { let directory: string; afterEach('Remove the product module directory', async function () { await fsp.rm(directory, { recursive: true, force: true }); }); it('writes documents/invoice.html on pull and reads it back on push when the template is present', async function () { const definition = productModuleDefinitionFromNetwork(cloneDeep(cliDefinitionExample)); directory = path.join('./', 'tmp-invoice-present'); await fsp.mkdir(directory); await writeProductModuleDefinition({ productModuleDefinition: definition, host: 'test', directory, targetOrderDefinition: definition, }); const invoicePath = path.join(directory, TopLevelDirectoryName.Documents, PolicyDocumentFileName.Invoice); expect(await existsAsync(invoicePath)).to.equal(true); const transformedDefinition = await readProductModuleDefinition(directory); expect(transformedDefinition.documents.invoiceTemplateHtml).to.eql( cliDefinitionExample.documents.invoice_template_html, ); // The field is sent on push when the template exists locally. const networkDocuments = productModuleDefinitionToNetwork(transformedDefinition).documents; const serializedDocuments = JSON.parse(JSON.stringify(networkDocuments)); expect(serializedDocuments.invoice_template_html).to.eql(cliDefinitionExample.documents.invoice_template_html); }); it('does not write documents/invoice.html on pull and omits the field on push when the template is absent', async function () { const network = cloneDeep(cliDefinitionExample); delete network.documents.invoice_template_html; const definition = productModuleDefinitionFromNetwork(network); directory = path.join('./', 'tmp-invoice-absent'); await fsp.mkdir(directory); await writeProductModuleDefinition({ productModuleDefinition: definition, host: 'test', directory, targetOrderDefinition: definition, }); const invoicePath = path.join(directory, TopLevelDirectoryName.Documents, PolicyDocumentFileName.Invoice); expect(await existsAsync(invoicePath)).to.equal(false); const transformedDefinition = await readProductModuleDefinition(directory); expect(transformedDefinition.documents.invoiceTemplateHtml).to.equal(undefined); // The field is omitted from the push payload entirely (not sent as null/empty). const networkDocuments = productModuleDefinitionToNetwork(transformedDefinition).documents; const serializedDocuments = JSON.parse(JSON.stringify(networkDocuments)); expect(serializedDocuments).to.not.have.property('invoice_template_html'); }); it('rejects with a CLIError when documents/terms.pdf is missing (previously exited 0)', async function () { const definition = productModuleDefinitionFromNetwork(cloneDeep(cliDefinitionExample)); directory = path.join('./', 'tmp-missing-terms'); await fsp.mkdir(directory); await writeProductModuleDefinition({ productModuleDefinition: definition, host: 'test', directory, targetOrderDefinition: definition, }); await fsp.rm(path.join(directory, TopLevelDirectoryName.Documents, PolicyDocumentFileName.Terms)); let thrown: unknown; try { await readProductModuleDefinition(directory); } catch (error) { thrown = error; } expect(thrown).to.be.instanceOf(CLIError); expect((thrown as CLIError).exitCode).to.equal(ExitCodes.GENERAL_ERROR); expect((thrown as CLIError).message).to.contain('Required document is missing'); }); });