From 66db3e156ca6400ac315df3eaabc849dd447a4b6 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Sun, 15 Mar 2026 00:56:26 +0530 Subject: [PATCH] fix: Audit fix progress bar and overwrite confirmation UX --- packages/contentstack-audit/package.json | 4 +- .../contentstack-audit/src/modules/assets.ts | 22 +- .../src/modules/content-types.ts | 1 + .../src/modules/custom-roles.ts | 1 + .../contentstack-audit/src/modules/entries.ts | 16 +- .../src/modules/extensions.ts | 45 +- .../src/modules/field_rules.ts | 1 + .../src/modules/workflows.ts | 47 +- .../test/unit/base-command.test.ts | 49 +- .../test/unit/logger-config.js | 12 + .../empty_title_ct/en-us/empty-entries.json | 4 + .../entries/empty_title_ct/en-us/index.json | 1 + .../test/unit/modules/assets.test.ts | 23 + .../unit/modules/composable-studio.test.ts | 302 ++++++- .../test/unit/modules/content-types.test.ts | 329 +++++++ .../test/unit/modules/custom-roles.test.ts | 242 +++++- .../test/unit/modules/entries.test.ts | 805 +++++++++++++++++- .../test/unit/modules/extensions.test.ts | 56 ++ .../test/unit/modules/field-rules.test.ts | 205 ++++- .../test/unit/modules/workflow.test.ts | 266 +++++- 20 files changed, 2360 insertions(+), 71 deletions(-) create mode 100644 packages/contentstack-audit/test/unit/logger-config.js create mode 100644 packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/empty-entries.json create mode 100644 packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/index.json diff --git a/packages/contentstack-audit/package.json b/packages/contentstack-audit/package.json index 36724da19..98bb445c1 100644 --- a/packages/contentstack-audit/package.json +++ b/packages/contentstack-audit/package.json @@ -73,8 +73,8 @@ "test": "mocha --forbid-only \"test/**/*.test.ts\"", "version": "oclif readme && git add README.md", "clean": "rm -rf ./lib ./node_modules tsconfig.tsbuildinfo oclif.manifest.json", - "test:unit:report": "nyc --extension .ts mocha --forbid-only \"test/unit/**/*.test.ts\"", - "test:unit": "mocha --timeout 10000 --forbid-only \"test/unit/**/*.test.ts\"" + "test:unit:report": "nyc --extension .ts mocha --forbid-only --file test/unit/logger-config.js \"test/unit/**/*.test.ts\"", + "test:unit": "mocha --timeout 10000 --forbid-only --file test/unit/logger-config.js \"test/unit/**/*.test.ts\"" }, "engines": { "node": ">=16" diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index 85dbf38cf..6e84abc78 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -27,6 +27,7 @@ export default class Assets extends BaseClass { protected schema: ContentTypeStruct[] = []; protected missingEnvLocales: Record = {}; public moduleName: keyof typeof auditConfig.moduleConfig; + private fixOverwriteConfirmed: boolean | null = null; constructor({ fix, config, moduleName }: ModuleConstructorParam & CtConstructorParam) { super({ config }); @@ -161,11 +162,17 @@ export default class Assets extends BaseClass { if (this.fix) { log.debug('Fix mode enabled, checking write permissions', this.config.auditContext); - if (!this.config.flags['copy-dir'] && !this.config.flags['external-config']?.skipConfirm) { - log.debug(`Asking user for confirmation to write fix content (--yes flag: ${this.config.flags.yes})`, this.config.auditContext); - canWrite = this.config.flags.yes || (await cliux.confirm(commonMsg.FIX_CONFIRMATION)); + if (this.config.flags['copy-dir'] || this.config.flags['external-config']?.skipConfirm || this.config.flags.yes) { + this.fixOverwriteConfirmed = true; + log.debug('Skipping confirmation due to copy-dir, external-config, or yes flags', this.config.auditContext); + } else if (this.fixOverwriteConfirmed !== null) { + canWrite = this.fixOverwriteConfirmed; + log.debug(`Using cached overwrite confirmation: ${canWrite}`, this.config.auditContext); } else { - log.debug('Skipping confirmation due to copy-dir or external-config flags', this.config.auditContext); + log.debug(`Asking user for confirmation to write fix content (--yes flag: ${this.config.flags.yes})`, this.config.auditContext); + this.completeProgress(true); + canWrite = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + this.fixOverwriteConfirmed = canWrite; } if (canWrite) { @@ -248,13 +255,16 @@ export default class Assets extends BaseClass { if (this.progressManager) { this.progressManager.tick(true, `asset: ${assetUid}`, null); } - + if (this.fix) { log.debug(`Fixing asset ${assetUid}`, this.config.auditContext); log.info($t(auditFixMsg.ASSET_FIX, { uid: assetUid }), this.config.auditContext); - await this.writeFixContent(`${basePath}/${indexer[fileIndex]}`, this.assets); } } + + if (this.fix) { + await this.writeFixContent(`${basePath}/${indexer[fileIndex]}`, this.assets); + } } log.debug(`Asset reference validation completed. Processed ${Object.keys(this.missingEnvLocales).length} assets with issues`, this.config.auditContext); diff --git a/packages/contentstack-audit/src/modules/content-types.ts b/packages/contentstack-audit/src/modules/content-types.ts index f6fc23bc4..79f23dfdd 100644 --- a/packages/contentstack-audit/src/modules/content-types.ts +++ b/packages/contentstack-audit/src/modules/content-types.ts @@ -225,6 +225,7 @@ export default class ContentType extends BaseClass { log.debug('Fix mode enabled, checking write permissions', this.config.auditContext); if (!this.config.flags['copy-dir'] && !this.config.flags['external-config']?.skipConfirm) { log.debug('Asking user for confirmation to write fix content', this.config.auditContext); + this.completeProgress(true); canWrite = this.config.flags.yes ?? (await cliux.confirm(commonMsg.FIX_CONFIRMATION)); } else { log.debug('Skipping confirmation due to copy-dir or external-config flags', this.config.auditContext); diff --git a/packages/contentstack-audit/src/modules/custom-roles.ts b/packages/contentstack-audit/src/modules/custom-roles.ts index 8ae7a3cbf..88485181e 100644 --- a/packages/contentstack-audit/src/modules/custom-roles.ts +++ b/packages/contentstack-audit/src/modules/custom-roles.ts @@ -249,6 +249,7 @@ export default class CustomRoles extends BaseClass { log.debug('Skipping confirmation due to copy-dir, external-config, or yes flags', this.config.auditContext); } else { log.debug('Asking user for confirmation to write fix content', this.config.auditContext); + this.completeProgress(true); } const canWrite = skipConfirm || (await cliux.confirm(commonMsg.FIX_CONFIRMATION)); diff --git a/packages/contentstack-audit/src/modules/entries.ts b/packages/contentstack-audit/src/modules/entries.ts index 71b4ceefd..5e17a9f83 100644 --- a/packages/contentstack-audit/src/modules/entries.ts +++ b/packages/contentstack-audit/src/modules/entries.ts @@ -60,6 +60,7 @@ export default class Entries extends BaseClass { public environments: string[] = []; public entryMetaData: Record[] = []; public moduleName: keyof typeof auditConfig.moduleConfig = 'entries'; + private fixOverwriteConfirmed: boolean | null = null; constructor({ fix, config, moduleName, ctSchema, gfSchema }: ModuleConstructorParam & CtConstructorParam) { super({ config }); @@ -541,14 +542,21 @@ export default class Entries extends BaseClass { const skipConfirm = this.config.flags['copy-dir'] || this.config.flags['external-config']?.skipConfirm; - if (skipConfirm) { - log.debug('Skipping confirmation due to copy-dir or external-config flags', this.config.auditContext); + let canWrite: boolean; + if (skipConfirm || this.config.flags.yes) { + canWrite = true; + this.fixOverwriteConfirmed = true; + log.debug('Skipping confirmation due to copy-dir, external-config, or yes flags', this.config.auditContext); + } else if (this.fixOverwriteConfirmed !== null) { + canWrite = this.fixOverwriteConfirmed; + log.debug(`Using cached overwrite confirmation: ${canWrite}`, this.config.auditContext); } else { log.debug('Asking user for confirmation to write fix content', this.config.auditContext); + this.completeProgress(true); + canWrite = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + this.fixOverwriteConfirmed = canWrite; } - const canWrite = skipConfirm || this.config.flags.yes || (await cliux.confirm(commonMsg.FIX_CONFIRMATION)); - if (canWrite) { log.debug(`Writing fixed entries to: ${filePath}`, this.config.auditContext); writeFileSync(filePath, JSON.stringify(schema)); diff --git a/packages/contentstack-audit/src/modules/extensions.ts b/packages/contentstack-audit/src/modules/extensions.ts index 3d25e8581..7651eaeaa 100644 --- a/packages/contentstack-audit/src/modules/extensions.ts +++ b/packages/contentstack-audit/src/modules/extensions.ts @@ -172,7 +172,19 @@ export default class Extensions extends BaseClass { ? JSON.parse(readFileSync(this.extensionsPath, 'utf8')) : {}; log.debug(`Loaded ${Object.keys(newExtensionSchema).length} existing extensions`, this.config.auditContext); - + + let userConfirm: boolean; + if ( + this.config.flags['copy-dir'] || + this.config.flags['external-config']?.skipConfirm || + this.config.flags.yes + ) { + userConfirm = true; + } else { + this.completeProgress(true); + userConfirm = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + } + for (const ext of missingCtInExtensions) { const { uid, title } = ext; log.debug(`Fixing extension: ${title} (${uid})`, this.config.auditContext); @@ -187,8 +199,7 @@ export default class Extensions extends BaseClass { } else { log.debug(`Extension ${title} has no valid content types or scope not found`, this.config.auditContext); cliux.print($t(commonMsg.EXTENSION_FIX_WARN, { title: title, uid }), { color: 'yellow' }); - const shouldDelete = this.config.flags.yes || (await cliux.confirm(commonMsg.EXTENSION_FIX_CONFIRMATION)); - if (shouldDelete) { + if (userConfirm) { log.debug(`Deleting extension: ${title} (${uid})`, this.config.auditContext); delete newExtensionSchema[uid]; } else { @@ -198,23 +209,35 @@ export default class Extensions extends BaseClass { } log.debug(`Extensions scope fix completed, writing updated schema`, this.config.auditContext); - await this.writeFixContent(newExtensionSchema); + await this.writeFixContent(newExtensionSchema, userConfirm); } - async writeFixContent(fixedExtensions: Record) { + async writeFixContent(fixedExtensions: Record, preConfirmed?: boolean) { log.debug(`Writing fix content for ${Object.keys(fixedExtensions).length} extensions`, this.config.auditContext); log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); log.debug(`Copy directory flag: ${this.config.flags['copy-dir']}`, this.config.auditContext); log.debug(`External config skip confirm: ${this.config.flags['external-config']?.skipConfirm}`, this.config.auditContext); log.debug(`Yes flag: ${this.config.flags.yes}`, this.config.auditContext); - if ( - this.fix && - (this.config.flags['copy-dir'] || - this.config.flags['external-config']?.skipConfirm || - this.config.flags.yes || - (await cliux.confirm(commonMsg.FIX_CONFIRMATION))) + let shouldWrite: boolean; + if (!this.fix) { + shouldWrite = false; + } else if (preConfirmed === true) { + shouldWrite = true; + } else if (preConfirmed === false) { + shouldWrite = false; + } else if ( + this.config.flags['copy-dir'] || + this.config.flags['external-config']?.skipConfirm || + this.config.flags.yes ) { + shouldWrite = true; + } else { + this.completeProgress(true); + shouldWrite = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + } + + if (shouldWrite) { const outputPath = join(this.folderPath, this.config.moduleConfig[this.moduleName].fileName); log.debug(`Writing fixed extensions to: ${outputPath}`, this.config.auditContext); log.debug(`Extensions to write: ${Object.keys(fixedExtensions).join(', ')}`, this.config.auditContext); diff --git a/packages/contentstack-audit/src/modules/field_rules.ts b/packages/contentstack-audit/src/modules/field_rules.ts index 7e2dc935e..6c788a71e 100644 --- a/packages/contentstack-audit/src/modules/field_rules.ts +++ b/packages/contentstack-audit/src/modules/field_rules.ts @@ -474,6 +474,7 @@ export default class FieldRule extends BaseClass { if (this.fix) { if (!this.config.flags['copy-dir'] && !this.config.flags['external-config']?.skipConfirm) { log.debug(`Asking user for confirmation to write fix content`, this.config.auditContext); + this.completeProgress(true); canWrite = this.config.flags.yes ?? (await cliux.confirm(commonMsg.FIX_CONFIRMATION)); log.debug(`User confirmation: ${canWrite}`, this.config.auditContext); } else { diff --git a/packages/contentstack-audit/src/modules/workflows.ts b/packages/contentstack-audit/src/modules/workflows.ts index 69ebda0af..4cc1fcef9 100644 --- a/packages/contentstack-audit/src/modules/workflows.ts +++ b/packages/contentstack-audit/src/modules/workflows.ts @@ -194,7 +194,22 @@ export default class Workflows extends BaseClass { log.debug(`Loaded ${Object.keys(newWorkflowSchema).length} workflows for fixing`, this.config.auditContext); - if (Object.keys(newWorkflowSchema).length !== 0) { + const hasWorkflowsToFix = Object.keys(newWorkflowSchema).length !== 0; + let userConfirm: boolean; + if (!hasWorkflowsToFix) { + userConfirm = true; + } else if ( + this.config.flags['copy-dir'] || + this.config.flags['external-config']?.skipConfirm || + this.config.flags.yes + ) { + userConfirm = true; + } else { + this.completeProgress(true); + userConfirm = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + } + + if (hasWorkflowsToFix) { log.debug(`Processing ${this.workflowSchema.length} workflows for fixes`, this.config.auditContext); for (const workflow of this.workflowSchema) { @@ -237,7 +252,7 @@ export default class Workflows extends BaseClass { cliux.print(warningMessage, { color: 'yellow' }); - if (this.config.flags.yes || (await cliux.confirm(commonMsg.WORKFLOW_FIX_CONFIRMATION))) { + if (userConfirm) { log.debug(`Deleting workflow ${name} (${uid})`, this.config.auditContext); delete newWorkflowSchema[workflow.uid]; } else { @@ -250,10 +265,10 @@ export default class Workflows extends BaseClass { } log.debug(`Workflow schema fix completed`, this.config.auditContext); - await this.writeFixContent(newWorkflowSchema); + await this.writeFixContent(newWorkflowSchema, userConfirm); } - async writeFixContent(newWorkflowSchema: Record) { + async writeFixContent(newWorkflowSchema: Record, preConfirmed?: boolean) { log.debug(`Writing fix content`, this.config.auditContext); log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); log.debug(`Copy directory flag: ${this.config.flags['copy-dir']}`, this.config.auditContext); @@ -261,13 +276,25 @@ export default class Workflows extends BaseClass { log.debug(`Yes flag: ${this.config.flags.yes}`, this.config.auditContext); log.debug(`Workflows to write: ${Object.keys(newWorkflowSchema).length}`, this.config.auditContext); - if ( - this.fix && - (this.config.flags['copy-dir'] || - this.config.flags['external-config']?.skipConfirm || - this.config.flags.yes || - (await cliux.confirm(commonMsg.FIX_CONFIRMATION))) + let shouldWrite: boolean; + if (!this.fix) { + shouldWrite = false; + } else if (preConfirmed === true) { + shouldWrite = true; + } else if (preConfirmed === false) { + shouldWrite = false; + } else if ( + this.config.flags['copy-dir'] || + this.config.flags['external-config']?.skipConfirm || + this.config.flags.yes ) { + shouldWrite = true; + } else { + this.completeProgress(true); + shouldWrite = await cliux.confirm(commonMsg.FIX_CONFIRMATION); + } + + if (shouldWrite) { const outputPath = join(this.folderPath, this.config.moduleConfig[this.moduleName].fileName); log.debug(`Writing fixed workflows to: ${outputPath}`, this.config.auditContext); diff --git a/packages/contentstack-audit/test/unit/base-command.test.ts b/packages/contentstack-audit/test/unit/base-command.test.ts index 20cf7b8ea..2aa639eaf 100644 --- a/packages/contentstack-audit/test/unit/base-command.test.ts +++ b/packages/contentstack-audit/test/unit/base-command.test.ts @@ -3,7 +3,6 @@ import { resolve } from 'path'; import { fancy } from 'fancy-test'; import { expect } from 'chai'; import { FileTransportInstance } from 'winston/lib/winston/transports'; - import { BaseCommand } from '../../src/base-command'; import { mockLogger } from './mock-logger'; @@ -168,4 +167,52 @@ describe('BaseCommand class', () => { } }); }); + + describe('init with external-config', () => { + class CMDCheckConfig extends BaseCommand { + async run() { + const sc = this.sharedConfig as Record; + if (sc.testMergeKey !== undefined) this.log(String(sc.testMergeKey)); + if (this.flags['external-config']?.noLog) this.log('noLog'); + } + } + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(BaseCommand.prototype, 'parse', () => + Promise.resolve({ + args: {}, + flags: { 'external-config': { config: { testMergeKey: 'merged' } } }, + } as any) + ) + .do(() => CMDCheckConfig.run([])) + .do((output: { stdout: string }) => expect(output.stdout).to.include('merged')) + .it('merges external-config.config into sharedConfig when present'); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(BaseCommand.prototype, 'parse', () => + Promise.resolve({ + args: {}, + flags: { 'external-config': { noLog: true } }, + } as any) + ) + .do(() => CMDCheckConfig.run([])) + .do((output: { stdout: string }) => expect(output.stdout).to.include('noLog')) + .it('hits noLog branch when external-config.noLog is true'); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(BaseCommand.prototype, 'parse', () => + Promise.resolve({ args: {}, flags: { 'external-config': {} } } as any) + ) + .do(() => CMDCheckConfig.run([])) + .it('completes when external-config is empty (no merge, no noLog)'); + }); }); diff --git a/packages/contentstack-audit/test/unit/logger-config.js b/packages/contentstack-audit/test/unit/logger-config.js new file mode 100644 index 000000000..4e434c48b --- /dev/null +++ b/packages/contentstack-audit/test/unit/logger-config.js @@ -0,0 +1,12 @@ +/** + * Loaded by Mocha via --file before any test. Forces log config to non-debug + * so the real Logger never enables the debug path and unit tests don't throw + * when user has run: csdx config:set:log --level debug + */ +const cliUtils = require('@contentstack/cli-utilities'); +const configHandler = cliUtils.configHandler; +const originalGet = configHandler.get.bind(configHandler); +configHandler.get = function (key) { + if (key === 'log') return { level: 'info' }; + return originalGet(key); +}; diff --git a/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/empty-entries.json b/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/empty-entries.json new file mode 100644 index 000000000..c4c2b64bf --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/empty-entries.json @@ -0,0 +1,4 @@ +{ + "entry-empty-title": { "title": "" }, + "entry-no-title": {} +} diff --git a/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/index.json b/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/index.json new file mode 100644 index 000000000..f0fcf8606 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/contents/entries/empty_title_ct/en-us/index.json @@ -0,0 +1 @@ +{"1":"empty-entries.json"} diff --git a/packages/contentstack-audit/test/unit/modules/assets.test.ts b/packages/contentstack-audit/test/unit/modules/assets.test.ts index 31c09c98a..9f83806ac 100644 --- a/packages/contentstack-audit/test/unit/modules/assets.test.ts +++ b/packages/contentstack-audit/test/unit/modules/assets.test.ts @@ -330,6 +330,29 @@ describe('Assets module', () => { } }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('when fix true and multiple assets in chunk, confirm is called only once', async () => { + const instance = new Assets({ ...constructorParam, fix: true }); + await instance.prerequisiteData(); + const confirmStub = Sinon.stub(cliux, 'confirm').resolves(true); + const writeStub = Sinon.stub(fs, 'writeFileSync'); + await instance.lookForReference(); + expect(confirmStub.callCount).to.equal(1); + confirmStub.restore(); + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('calls writeFixContent once per chunk file when fix is true (not per asset)', async () => { + const instance = new Assets({ ...constructorParam, fix: true }); + await instance.prerequisiteData(); + const writeFixSpy = Sinon.stub(Assets.prototype, 'writeFixContent').resolves(); + await instance.lookForReference(); + expect(writeFixSpy.callCount).to.equal(1); + }); + fancy .stdout({ print: process.env.PRINT === 'true' || false }) .it('should log scan success message exactly once per asset', async () => { diff --git a/packages/contentstack-audit/test/unit/modules/composable-studio.test.ts b/packages/contentstack-audit/test/unit/modules/composable-studio.test.ts index a5830a3dd..7131fa408 100644 --- a/packages/contentstack-audit/test/unit/modules/composable-studio.test.ts +++ b/packages/contentstack-audit/test/unit/modules/composable-studio.test.ts @@ -1,8 +1,9 @@ +import fs from 'fs'; import { resolve } from 'path'; import { fancy } from 'fancy-test'; import { expect } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; -import { ux } from '@contentstack/cli-utilities'; +import { ux, cliux } from '@contentstack/cli-utilities'; import sinon from 'sinon'; import config from '../../../src/config'; @@ -135,6 +136,21 @@ describe('ComposableStudio', () => { expect(cs.environmentUidSet.has('blt_env_dev')).to.be.true; expect(cs.environmentUidSet.has('blt_env_prod')).to.be.true; }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not load when environments file does not exist', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'invalid_path'), + flags: {}, + }), + }); + await cs.loadEnvironments(); + expect(cs.environmentUidSet.size).to.equal(0); + }); }); describe('loadLocales method', () => { @@ -155,6 +171,42 @@ describe('ComposableStudio', () => { expect(cs.localeCodeSet.has('fr-fr')).to.be.true; expect(cs.localeCodeSet.has('de-de')).to.be.true; }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not load when master locale file does not exist', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'invalid_path'), + flags: {}, + }), + }); + await cs.loadLocales(); + expect(cs.localeCodeSet.size).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads only master locales when additional locales file does not exist', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(`./test/unit/mock/contents/composable_studio`), + flags: {}, + }), + }); + const localesPath = resolve(cs.config.basePath, 'locales', 'locales.json'); + const origExists = fs.existsSync; + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => { + if (String(p) === localesPath) return false; + return origExists.call(fs, p); + }); + await cs.loadLocales(); + expect(cs.localeCodeSet.size).to.be.greaterThan(0); + }); }); describe('run method with audit fix for composable-studio', () => { @@ -295,6 +347,38 @@ describe('ComposableStudio', () => { expect(projectWithCTIssue.issues).to.include('contentTypeUid'); } }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('reportEntry uses undefined for missing issue types (branch coverage)', async () => { + const onlyInvalidEnv = [ + { uid: 'e1', name: 'EnvOnly', contentTypeUid: 'page_1', settings: { configuration: { environment: 'bad_env', locale: 'en-us' } } }, + ]; + const origRead = fs.readFileSync; + const origExists = fs.existsSync; + sinon.stub(fs, 'readFileSync').callsFake((p: fs.PathOrFileDescriptor) => { + if (String(p).includes('composable_studio.json')) return JSON.stringify(onlyInvalidEnv); + if (String(p).includes('environments.json')) return JSON.stringify([{ uid: 'blt_env_dev' }]); + if (String(p).includes('master-locale') || String(p).includes('locales.json')) return JSON.stringify({ 'en-us': { code: 'en-us' } }); + return origRead.call(fs, p); + }); + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => { + const s = String(p); + if (s.includes('composable_studio') || s.includes('environments') || s.includes('locales') || s.includes('master-locale')) return true; + return origExists.call(fs, p); + }); + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: cloneDeep(require('./../mock/contents/composable_studio/ctSchema.json')), + config: Object.assign(config, { basePath: resolve(`./test/unit/mock/contents/`), flags: {} }), + }); + const result: any = await cs.run(); + const envOnly = result.find((r: any) => r.uid === 'e1'); + expect(envOnly).to.exist; + expect(envOnly.content_types).to.be.undefined; + expect(envOnly.environment).to.deep.equal(['bad_env']); + expect(envOnly.locale).to.be.undefined; + }); }); describe('Empty and edge cases', () => { @@ -326,5 +410,221 @@ describe('ComposableStudio', () => { // When the file exists and has projects with validation issues, it returns an array expect(result).to.exist; }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns {} when composable studio file does not exist', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: cloneDeep(require('./../mock/contents/composable_studio/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents', 'content_types'), + flags: {}, + }), + }); + const result = await cs.run(); + expect(result).to.eql({}); + }); + }); + + describe('run with valid project (no issues)', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('logs when project has no validation issues', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: cloneDeep(require('./../mock/contents/composable_studio/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(`./test/unit/mock/contents/`), + flags: {}, + }), + }); + const result = await cs.run(); + expect(Array.isArray(result)).to.be.true; + expect(cs.composableStudioProjects.length).to.be.greaterThan(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads single project object (non-array) and normalizes to array', async () => { + const singleProject = { uid: 'only', name: 'Only', contentTypeUid: 'page_1', settings: { configuration: { environment: 'blt_env_dev', locale: 'en-us' } } }; + const origRead = fs.readFileSync; + sinon.stub(fs, 'readFileSync').callsFake((p: fs.PathOrFileDescriptor) => { + if (String(p).includes('composable_studio.json')) return JSON.stringify(singleProject); + return origRead.call(fs, p); + }); + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: cloneDeep(require('./../mock/contents/composable_studio/ctSchema.json')), + config: Object.assign(config, { basePath: resolve(`./test/unit/mock/contents/`), flags: {} }), + }); + await cs.run(); + expect(cs.composableStudioProjects).to.have.lengthOf(1); + expect(cs.composableStudioProjects[0].uid).to.equal('only'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('when fix true but no issues returns empty array', async () => { + const validProject = { uid: 'v1', name: 'Valid', contentTypeUid: 'page_1', settings: { configuration: { environment: 'blt_env_dev', locale: 'en-us' } } }; + const origRead = fs.readFileSync; + const origExists = fs.existsSync; + sinon.stub(fs, 'readFileSync').callsFake((p: fs.PathOrFileDescriptor) => { + const pathStr = String(p); + if (pathStr.includes('composable_studio.json')) return JSON.stringify([validProject]); + if (pathStr.includes('environments.json')) return JSON.stringify([{ uid: 'blt_env_dev' }, { uid: 'blt_env_prod' }]); + if (pathStr.includes('master-locale') || pathStr.includes('locales.json')) return JSON.stringify({ 'en-us': { code: 'en-us' } }); + return origRead.call(fs, p); + }); + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('composable_studio') || pathStr.includes('environments') || pathStr.includes('locales') || pathStr.includes('master-locale')) return true; + return origExists.call(fs, p); + }); + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: cloneDeep(require('./../mock/contents/composable_studio/ctSchema.json')), + config: Object.assign(config, { basePath: resolve(`./test/unit/mock/contents/`), flags: {} }), + fix: true, + }); + const result: any = await cs.run(); + expect(Array.isArray(result)).to.be.true; + expect(result).to.have.lengthOf(0); + }); + }); + + describe('fixComposableStudioProjects', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns early when readFileSync throws', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(`./test/unit/mock/contents/`), + flags: {}, + }), + fix: true, + }); + cs.composableStudioPath = resolve(__dirname, '..', 'mock', 'contents', 'composable_studio', 'composable_studio.json'); + cs.projectsWithIssues = [{ uid: 'p1', name: 'P1' }]; + sinon.stub(fs, 'readFileSync').callsFake(() => { + throw new Error('read failed'); + }); + await cs.fixComposableStudioProjects(); + expect(cs.projectsWithIssues).to.have.lengthOf(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .stub(fs, 'writeFileSync', () => {}) + .it('hits needsFix true and logs project was fixed when project has invalid env', async () => { + const projectWithInvalidEnv = { + uid: 'inv_env', + name: 'Invalid Env', + contentTypeUid: 'page_1', + settings: { configuration: { environment: 'bad_env', locale: 'en-us' } }, + }; + sinon.stub(fs, 'readFileSync').callsFake(() => JSON.stringify([projectWithInvalidEnv])); + const writeSpy = sinon.stub(fs, 'writeFileSync'); + sinon.stub(cliux, 'confirm').resolves(true); + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [{ uid: 'page_1', title: 'P1' }] as any, + config: Object.assign(config, { basePath: resolve(`./test/unit/mock/contents/`), flags: {} }), + fix: true, + }); + cs.ctUidSet = new Set(['page_1']); + cs.environmentUidSet = new Set(['blt_env_dev']); + cs.localeCodeSet = new Set(['en-us']); + cs.composableStudioPath = resolve(__dirname, '..', 'mock', 'contents', 'composable_studio', 'composable_studio.json'); + await cs.fixComposableStudioProjects(); + const written = JSON.parse(String(writeSpy.firstCall.args[1])); + expect(written[0].settings.configuration.environment).to.be.undefined; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .stub(fs, 'writeFileSync', () => {}) + .it('logs when project did not need fixing', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [{ uid: 'page_1', title: 'Page 1' }] as any, + config: Object.assign(config, { + basePath: resolve(`./test/unit/mock/contents/`), + flags: {}, + }), + fix: true, + }); + cs.ctUidSet = new Set(['page_1']); + cs.environmentUidSet = new Set(['blt_env_dev']); + cs.localeCodeSet = new Set(['en-us']); + cs.composableStudioPath = resolve(__dirname, '..', 'mock', 'contents', 'composable_studio', 'composable_studio.json'); + cs.projectsWithIssues = [ + { + uid: 'test_project_uid_1', + name: 'Test Project 1', + contentTypeUid: 'page_1', + settings: { configuration: { environment: 'blt_env_dev', locale: 'en-us' } }, + }, + ]; + await cs.fixComposableStudioProjects(); + expect(cs.projectsWithIssues.length).to.equal(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .stub(fs, 'writeFileSync', () => {}) + .it('handles single project object (non-array) from file', async () => { + const singleProject = { uid: 's1', name: 'Single', contentTypeUid: 'page_1', settings: { configuration: { environment: 'blt_env_dev', locale: 'en-us' } } }; + sinon.stub(fs, 'readFileSync').callsFake(() => JSON.stringify(singleProject)); + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [{ uid: 'page_1', title: 'P1' }] as any, + config: Object.assign(config, { basePath: resolve(`./test/unit/mock/contents/`), flags: {} }), + fix: true, + }); + cs.ctUidSet = new Set(['page_1']); + cs.environmentUidSet = new Set(['blt_env_dev']); + cs.localeCodeSet = new Set(['en-us']); + cs.composableStudioPath = resolve(__dirname, '..', 'mock', 'contents', 'composable_studio', 'composable_studio.json'); + cs.projectsWithIssues = [singleProject]; + await cs.fixComposableStudioProjects(); + expect(cs.projectsWithIssues).to.have.lengthOf(1); + }); + }); + + describe('writeFixContent', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => false) + .it('skips write when user declines confirmation', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { basePath: resolve(__dirname, '..', 'mock', 'contents'), flags: {} }), + fix: true, + }); + const writeSpy = sinon.stub(fs, 'writeFileSync'); + await cs.writeFixContent([{ uid: 'p1', name: 'P1' }]); + expect(writeSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips write when fix mode disabled', async () => { + const cs = new ComposableStudio({ + moduleName: 'composable-studio', + ctSchema: [], + config: Object.assign(config, { basePath: resolve(__dirname, '..', 'mock', 'contents'), flags: {} }), + fix: false, + }); + const writeSpy = sinon.stub(fs, 'writeFileSync'); + await cs.writeFixContent([{ uid: 'p1', name: 'P1' }]); + expect(writeSpy.callCount).to.equal(0); + }); }); }); diff --git a/packages/contentstack-audit/test/unit/modules/content-types.test.ts b/packages/contentstack-audit/test/unit/modules/content-types.test.ts index 5dfff7259..b85dae300 100644 --- a/packages/contentstack-audit/test/unit/modules/content-types.test.ts +++ b/packages/contentstack-audit/test/unit/modules/content-types.test.ts @@ -209,6 +209,57 @@ describe('Content types', () => { expect(validateGroupFieldSpy.callCount).to.be.equals(1); }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(ContentType.prototype, 'runFixOnSchema', () => []) + .it('skips json extension field when not in fix types', async () => { + const ctInstance = new (class TempClass extends ContentType { + constructor() { + super({ + ...constructorParam, + config: { ...constructorParam.config, 'fix-fields': ['reference'], flags: {} } as any, + }); + this.currentUid = 'test'; + (this as any).missingRefs['test'] = []; + } + })(); + sinon.stub(ContentType.prototype, 'validateReferenceField').returns([]); + sinon.stub(ContentType.prototype, 'validateGlobalField').resolves(); + sinon.stub(ContentType.prototype, 'validateJsonRTEFields').returns([]); + sinon.stub(ContentType.prototype, 'validateGroupField').resolves(); + sinon.stub(ContentType.prototype, 'validateModularBlocksField').resolves(); + const schema = [ + { data_type: 'json', uid: 'j1', display_name: 'Json', field_metadata: { extension: true } }, + ]; + await ctInstance.lookForReference([], { schema } as unknown as CtType); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips json RTE field when not in fix types', async () => { + const ctInstance = new (class TempClass extends ContentType { + constructor() { + super({ + ...constructorParam, + config: { ...constructorParam.config, 'fix-fields': ['reference'], flags: {} } as any, + }); + this.currentUid = 'test'; + (this as any).missingRefs['test'] = []; + } + })(); + sinon.stub(ContentType.prototype, 'validateReferenceField').returns([]); + sinon.stub(ContentType.prototype, 'validateGlobalField').resolves(); + sinon.stub(ContentType.prototype, 'validateJsonRTEFields').returns([]); + sinon.stub(ContentType.prototype, 'validateGroupField').resolves(); + sinon.stub(ContentType.prototype, 'validateModularBlocksField').resolves(); + const schema = [ + { data_type: 'json', uid: 'j1', display_name: 'RTE', field_metadata: { allow_json_rte: true } }, + ]; + await ctInstance.lookForReference([], { schema } as unknown as CtType); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(0); + }); + fancy .stdout({ print: process.env.PRINT === 'true' || false }) .stub(ContentType.prototype, 'runFixOnSchema', () => []) @@ -221,6 +272,82 @@ describe('Content types', () => { }); }); + describe('validateExtensionAndAppField method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns [] in fix mode', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + const field = { + uid: 'ext_f', + extension_uid: 'ext_123', + display_name: 'Ext Field', + data_type: 'json', + } as any; + const result = ctInstance.validateExtensionAndAppField([], field); + expect(result).to.deep.equal([]); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns [] when extension found in loaded extensions', () => { + const ctInstance = new ContentType(constructorParam); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).extensions = ['ext_123']; + const field = { + uid: 'ext_f', + extension_uid: 'ext_123', + display_name: 'Ext Field', + data_type: 'json', + } as any; + const result = ctInstance.validateExtensionAndAppField([{ uid: 'ext_f', name: 'Ext Field' }], field); + expect(result).to.deep.equal([]); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns issue when extension not in loaded extensions', () => { + const ctInstance = new ContentType(constructorParam); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).extensions = []; + const field = { + uid: 'ext_f', + extension_uid: 'missing_ext', + display_name: 'Ext Field', + data_type: 'json', + } as any; + const result = ctInstance.validateExtensionAndAppField([{ uid: 'ext_f', name: 'Ext Field' }], field); + expect(result).to.have.lengthOf(1); + expect(result[0].missingRefs).to.deep.include({ uid: 'ext_f', extension_uid: 'missing_ext', type: 'Extension or Apps' }); + }); + }); + + describe('validateReferenceToValues method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns empty when single reference exists in ctSchema', () => { + const ctInstance = new ContentType(constructorParam); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + const field = { uid: 'ref_f', reference_to: 'page_1', display_name: 'Ref', data_type: 'reference' } as any; + const result = ctInstance.validateReferenceToValues([], field); + expect(result).to.have.lengthOf(0); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('skips ref in skipRefs in array path', () => { + const ctInstance = new ContentType(constructorParam); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + const field = { uid: 'ref_f', reference_to: ['page_1', 'sys_assets'], display_name: 'Ref', data_type: 'reference' } as any; + const result = ctInstance.validateReferenceToValues([], field); + expect(result).to.have.lengthOf(0); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns empty when array references all exist in ctSchema', () => { + const ctInstance = new ContentType(constructorParam); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + const field = { uid: 'ref_f', reference_to: ['page_1', 'page_2'], display_name: 'Ref', data_type: 'reference' } as any; + const result = ctInstance.validateReferenceToValues([], field); + expect(result).to.have.lengthOf(0); + }); + }); + describe('validateReferenceField method', () => { fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('should return missing reference', async () => { const ctInstance = new ContentType(constructorParam); @@ -286,6 +413,158 @@ describe('Content types', () => { }); }); + describe('fixMissingExtensionOrApp method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns field when extension found in loaded extensions', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).missingRefs['test'] = []; + (ctInstance as any).extensions = ['ext_123']; + const field = { + uid: 'ext_f', + extension_uid: 'ext_123', + display_name: 'Ext Field', + data_type: 'json', + } as any; + const result = ctInstance.fixMissingExtensionOrApp([], field); + expect(result).to.equal(field); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns null and pushes to missingRefs when extension missing and fix mode', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).missingRefs['test'] = []; + (ctInstance as any).extensions = []; + const field = { + uid: 'ext_f', + extension_uid: 'missing_ext', + display_name: 'Ext Field', + data_type: 'json', + } as any; + const result = ctInstance.fixMissingExtensionOrApp([{ uid: 'ext_f', name: 'Ext' }], field); + expect(result).to.be.null; + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(1); + expect((ctInstance as any).missingRefs['test'][0].fixStatus).to.equal('Fixed'); + }); + }); + + describe('runFixOnSchema method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('filters out field with empty schema when in schema-fields-data-type', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const schema = [ + { data_type: 'blocks', uid: 'b1', display_name: 'Blocks', schema: [], blocks: [] }, + { data_type: 'text', uid: 't1', display_name: 'Title' }, + ] as any; + const result = ctInstance.runFixOnSchema([], schema); + expect(result.some((f: any) => f?.uid === 't1')).to.be.true; + expect(result.filter((f: any) => f?.uid === 'b1')).to.have.lengthOf(0); + }); + }); + + describe('fixModularBlocksReferences method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns false for block with no schema in content-types', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const blocks = [ + { uid: 'blk1', title: 'Block1', reference_to: 'gf_0', schema: undefined }, + ] as any; + const result = ctInstance.fixModularBlocksReferences([], blocks); + expect(result).to.have.lengthOf(0); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(1); + }); + }); + + describe('fixMissingReferences method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('skips reference in skipRefs (single reference)', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + uid: 'ref_f', + reference_to: 'sys_assets', + display_name: 'Ref', + data_type: 'reference', + field_metadata: {}, + } as any; + const result = ctInstance.fixMissingReferences([], field); + expect(result).to.equal(field); + expect(field.reference_to).to.deep.equal(['sys_assets']); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('keeps single reference when it exists in ctSchema (branch: found)', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + uid: 'ref_f', + reference_to: 'page_1', + display_name: 'Ref', + data_type: 'reference', + field_metadata: {}, + } as any; + const result = ctInstance.fixMissingReferences([], field); + expect(result).to.equal(field); + expect(field.reference_to).to.deep.equal(['page_1']); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(0); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('removes missing refs from array and pushes to missingRefs when fix mode', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + uid: 'ref_f', + reference_to: ['page_1', 'nonexistent_ct'], + display_name: 'Ref', + data_type: 'reference', + field_metadata: {}, + } as any; + const result = ctInstance.fixMissingReferences([], field); + expect(result).to.equal(field); + expect(field.reference_to).to.deep.equal(['page_1']); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(1); + expect((ctInstance as any).missingRefs['test'][0].fixStatus).to.equal('Fixed'); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('skips references in skipRefs when processing array', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + uid: 'ref_f', + reference_to: ['page_1', 'sys_assets', 'page_2'], + display_name: 'Ref', + data_type: 'reference', + field_metadata: {}, + } as any; + ctInstance.fixMissingReferences([], field); + expect(field.reference_to).to.include('page_1'); + expect(field.reference_to).to.include('page_2'); + expect(field.reference_to).to.include('sys_assets'); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('keeps refs when all references exist in ctSchema (array path)', () => { + const ctInstance = new ContentType({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + uid: 'ref_f', + reference_to: ['page_1', 'page_2'], + display_name: 'Ref', + data_type: 'reference', + field_metadata: {}, + } as any; + const result = ctInstance.fixMissingReferences([], field); + expect(result).to.equal(field); + expect(field.reference_to).to.deep.equal(['page_1', 'page_2']); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(0); + }); + }); + describe('fixGlobalFieldReferences method', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false }) @@ -309,6 +588,56 @@ describe('Content types', () => { const actual = ctInstance.missingRefs; expect(actual).to.deep.equals({'audit-fix': []}); expect(fixField?.schema).is.undefined; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('pushes missingRefs when global-fields module and referred GF has no schema', () => { + const ctInstance = new ContentType({ + ...constructorParam, + moduleName: 'global-fields', + gfSchema: [{ uid: 'ref_gf', title: 'Ref GF', schema: undefined }] as any, + ctSchema: [], + }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).missingRefs['test'] = []; + const field = { + data_type: 'global_field', + display_name: 'Global', + reference_to: 'ref_gf', + uid: 'global_field', + schema: undefined, + } as any; + const result = ctInstance.fixGlobalFieldReferences([], field); + expect(result).to.equal(field); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(1); + expect((ctInstance as any).missingRefs['test'][0].missingRefs).to.equal('Referred Global Field Does not exist'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('pushes Empty schema found when content-types module and GF has no schema', () => { + const ctInstance = new ContentType({ + ...constructorParam, + moduleName: 'content-types', + fix: true, + }); + (ctInstance as any).currentUid = 'test'; + (ctInstance as any).currentTitle = 'Test'; + (ctInstance as any).missingRefs['test'] = []; + (ctInstance as any).gfSchema = [{ uid: 'gf_empty', title: 'GF', schema: undefined }] as any; + const field = { + data_type: 'global_field', + display_name: 'Global', + reference_to: 'gf_empty', + uid: 'global_field', + schema: undefined, + } as any; + const result = ctInstance.fixGlobalFieldReferences([], field); + expect(result).to.equal(field); + expect((ctInstance as any).missingRefs['test']).to.have.lengthOf(1); + expect((ctInstance as any).missingRefs['test'][0].missingRefs).to.equal('Empty schema found'); // NOTE: TO DO // expect(actual).to.deep.equals(expected); // expect(fixField?.schema).is.not.empty; diff --git a/packages/contentstack-audit/test/unit/modules/custom-roles.test.ts b/packages/contentstack-audit/test/unit/modules/custom-roles.test.ts index a41fb4af7..3046dc071 100644 --- a/packages/contentstack-audit/test/unit/modules/custom-roles.test.ts +++ b/packages/contentstack-audit/test/unit/modules/custom-roles.test.ts @@ -1,8 +1,10 @@ -import { resolve } from 'path'; +import { join, resolve } from 'path'; import { expect } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; import fancy from 'fancy-test'; import Sinon from 'sinon'; +import fs from 'fs'; +import { cliux } from '@contentstack/cli-utilities'; import config from '../../../src/config'; import { CustomRoles } from '../../../src/modules'; import { CtConstructorParam, ModuleConstructorParam } from '../../../src/types'; @@ -22,6 +24,14 @@ describe('Custom roles module', () => { Sinon.stub(require('@contentstack/cli-utilities'), 'log').value(mockLogger); }); + describe('validateModules', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns custom-roles when module not in config', () => { + const cr = new CustomRoles(constructorParam); + const result = (cr as any).validateModules('invalid-module', config.moduleConfig); + expect(result).to.equal('custom-roles'); + }); + }); + describe('run method', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false }) @@ -31,10 +41,87 @@ describe('Custom roles module', () => { config: { ...constructorParam.config, branch: 'test' }, }); await customRoleInstance.run(); - expect(customRoleInstance.missingFieldsInCustomRoles).length(2); + expect(customRoleInstance.missingFieldsInCustomRoles).to.have.lengthOf(2); expect(JSON.stringify(customRoleInstance.missingFieldsInCustomRoles)).includes('"branches":["main"]'); }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('creates progress when totalCount > 0', async () => { + const cr = new CustomRoles({ ...constructorParam, config: { ...constructorParam.config, branch: 'test' } }); + (cr as any).createSimpleProgress = Sinon.stub().callsFake(function (this: any) { + const progress = { updateStatus: Sinon.stub(), tick: Sinon.stub(), complete: Sinon.stub() }; + this.progressManager = progress; + return progress; + }); + await cr.run(5); + expect((cr as any).createSimpleProgress.calledWith('custom-roles', 5)).to.be.true; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips branch validation when config.branch is not set', async () => { + const cr = new CustomRoles({ + ...constructorParam, + config: { ...constructorParam.config, branch: undefined }, + }); + await cr.run(); + expect(cr.missingFieldsInCustomRoles).to.be.an('array'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not create progressManager when totalCount is 0 or undefined', async () => { + const cr = new CustomRoles({ ...constructorParam, config: { ...constructorParam.config, branch: 'main' } }); + await cr.run(); + expect((cr as any).progressManager).to.be.null; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('hits no-fix branch when fix false and no issues', async () => { + const cr = new CustomRoles({ + ...constructorParam, + config: { ...constructorParam.config, branch: 'main' }, + fix: false, + }); + await cr.run(); + expect(cr.missingFieldsInCustomRoles).to.be.an('array'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('hits has no branch issues when role has no branch rules', async () => { + Sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => String(p).includes('custom-roles')); + Sinon.stub(fs, 'readFileSync').callsFake(() => + JSON.stringify({ + noBranchRule: { + uid: 'noBranchRule', + name: 'No Branch Rule', + rules: [{ module: 'environment', environments: [] }], + }, + }) + ); + const cr = new CustomRoles({ + ...constructorParam, + config: { ...constructorParam.config, branch: 'test' }, + }); + await cr.run(); + expect(cr.missingFieldsInCustomRoles.some((r: any) => r.uid === 'noBranchRule')).to.be.false; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('logs no fixes needed when fix disabled or no issues', async () => { + const cr = new CustomRoles({ + ...constructorParam, + config: { ...constructorParam.config, branch: 'main' }, + fix: false, + }); + await cr.run(); + expect(cr.missingFieldsInCustomRoles).to.be.an('array'); + }); + fancy .stdout({ print: process.env.PRINT === 'true' || false }) .stub(CustomRoles.prototype, 'fixCustomRoleSchema', async () => {}) @@ -49,6 +136,17 @@ describe('Custom roles module', () => { expect(logSpy.callCount).to.be.equals(1); }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns {} when folder path does not exist', async () => { + const cr = new CustomRoles({ + ...constructorParam, + config: { ...constructorParam.config, basePath: resolve(__dirname, '..', 'mock', 'invalid_path') }, + }); + const result = await cr.run(); + expect(result).to.eql({}); + }); + fancy .stdout({ print: process.env.PRINT === 'true' || false }) .stub(CustomRoles.prototype, 'writeFixContent', async () => {}) @@ -64,6 +162,146 @@ describe('Custom roles module', () => { }); }); + describe('fixCustomRoleSchema', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(CustomRoles.prototype, 'writeFixContent', async () => {}) + .stub(fs, 'existsSync', () => true) + .stub(fs, 'readFileSync', () => JSON.stringify({ uid1: { uid: 'uid1', name: 'R1', rules: [{ module: 'branch', branches: ['main'] }] } })) + .it('skips fix for each role when config.branch is not set', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: undefined }, + }); + cr.customRolePath = join(cr.folderPath, cr.fileName); + cr.customRoleSchema = [{ uid: 'uid1', name: 'R1', rules: [{ module: 'branch', branches: ['main'] }] }] as any; + await cr.fixCustomRoleSchema(); + expect(cr.customRoleSchema.length).to.equal(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'existsSync', () => false) + .it('loads empty schema when custom role path does not exist', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test' }, + }); + cr.customRolePath = join(cr.folderPath, cr.fileName); + cr.customRoleSchema = [{ uid: 'u1', name: 'R1', rules: [] }] as any; + const writeSpy = Sinon.stub(CustomRoles.prototype, 'writeFixContent').resolves(); + await cr.fixCustomRoleSchema(); + expect(writeSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'existsSync', () => true) + .stub(fs, 'readFileSync', () => JSON.stringify({})) + .it('returns early when no custom roles to fix', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test' }, + }); + cr.customRolePath = join(cr.folderPath, cr.fileName); + cr.customRoleSchema = []; + const writeSpy = Sinon.stub(CustomRoles.prototype, 'writeFixContent').resolves(); + await cr.fixCustomRoleSchema(); + expect(writeSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'existsSync', () => true) + .stub(fs, 'readFileSync', () => + JSON.stringify({ + uid1: { + uid: 'uid1', + name: 'R1', + rules: [{ module: 'branch', branches: ['main', 'test'] }], + }, + }) + ) + .stub(cliux, 'confirm', async () => true) + .it('keeps config branch and removes others in branch rules', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test' }, + }); + cr.customRolePath = join(cr.folderPath, cr.fileName); + cr.customRoleSchema = [ + { uid: 'uid1', name: 'R1', rules: [{ module: 'branch', branches: ['main', 'test'] }] }, + ] as any; + const writeFixStub = Sinon.stub(CustomRoles.prototype, 'writeFixContent').callsFake(async (schema: Record) => { + const rule = schema?.uid1?.rules?.find((r: any) => r.module === 'branch'); + expect(rule).to.exist; + expect(rule.branches).to.eql(['test']); + }); + await cr.fixCustomRoleSchema(); + expect(writeFixStub.called).to.be.true; + }); + }); + + describe('writeFixContent', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', Sinon.stub()) + .it('writes file when fix is true and skipConfirm (copy-dir)', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test', flags: { 'copy-dir': true } }, + }); + await cr.writeFixContent({ uid123: {} } as any); + expect((fs.writeFileSync as Sinon.SinonStub).called).to.be.true; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', Sinon.stub()) + .stub(cliux, 'confirm', async () => true) + .it('writes file when fix is true and user confirms', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test', flags: {} }, + }); + await cr.writeFixContent({ uid123: {} } as any); + expect((fs.writeFileSync as Sinon.SinonStub).called).to.be.true; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', Sinon.stub()) + .stub(cliux, 'confirm', async () => false) + .it('does not write file when user declines confirmation', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, branch: 'test', flags: {} }, + }); + await cr.writeFixContent({ uid123: {} } as any); + expect((fs.writeFileSync as Sinon.SinonStub).called).to.be.false; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips write when not in fix mode', async () => { + const cr = new CustomRoles({ + ...constructorParam, + fix: false, + config: { ...constructorParam.config, branch: 'test', flags: {} }, + }); + const writeSpy = Sinon.stub(fs, 'writeFileSync'); + await cr.writeFixContent({ uid123: {} } as any); + expect(writeSpy.called).to.be.false; + }); + }); + afterEach(() => { Sinon.restore(); // Clears Sinon spies/stubs/mocks }); diff --git a/packages/contentstack-audit/test/unit/modules/entries.test.ts b/packages/contentstack-audit/test/unit/modules/entries.test.ts index 911ba3316..c10f8c824 100644 --- a/packages/contentstack-audit/test/unit/modules/entries.test.ts +++ b/packages/contentstack-audit/test/unit/modules/entries.test.ts @@ -104,6 +104,30 @@ describe('Entries module', () => { expect(fixPrerequisiteData.callCount).to.be.equals(1); expect(prepareEntryMetaData.callCount).to.be.equals(1); }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('run with real folder runs main loop and removeEmptyVal', async () => { + const realCtSchema = cloneDeep(require('../mock/contents/content_types/schema.json')); + const realGfSchema = cloneDeep(require('../mock/contents/global_fields/globalfields.json')); + ctStub.resolves(realCtSchema); + gfStub.resolves(realGfSchema); + try { + const ctInstance = new Entries(constructorParam); + const result = (await ctInstance.run()) as any; + expect(result).to.have.property('missingEntryRefs'); + expect(result).to.have.property('missingSelectFeild'); + expect(result).to.have.property('missingMandatoryFields'); + expect(result).to.have.property('missingTitleFields'); + expect(result).to.have.property('missingEnvLocale'); + expect(result).to.have.property('missingMultipleFields'); + } finally { + ctStub.resetHistory(); + gfStub.resetHistory(); + ctStub.resolves({ ct1: [{}] }); + gfStub.resolves({ gf1: [{}] }); + } + }); }); describe('fixPrerequisiteData method', () => { @@ -121,6 +145,44 @@ describe('Entries module', () => { expect(ctInstance.ctSchema).deep.contain({ ct1: [{}] }); expect(ctInstance.gfSchema).deep.contain({ gf1: [{}] }); }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads extensions when extensions.json exists', async () => { + Sinon.stub(fs, 'existsSync').callsFake((path: any) => String(path).includes('extensions.json')); + Sinon.stub(fs, 'readFileSync').callsFake(() => JSON.stringify({ ext_uid_1: {}, ext_uid_2: {} })); + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).missingSelectFeild = { 'test-entry': [] }; + (ctInstance as any).missingMandatoryFields = { 'test-entry': [] }; + await ctInstance.fixPrerequisiteData(); + expect(ctInstance.extensions).to.include('ext_uid_1'); + expect(ctInstance.extensions).to.include('ext_uid_2'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads extension UIDs from marketplace apps when file exists', async () => { + if ((fs.existsSync as any).restore) (fs.existsSync as any).restore(); + if ((fs.readFileSync as any).restore) (fs.readFileSync as any).restore(); + Sinon.stub(fs, 'existsSync').callsFake((path: any) => String(path).includes('marketplace_apps.json')); + Sinon.stub(fs, 'readFileSync').callsFake((path: any) => { + if (String(path).includes('marketplace')) { + return JSON.stringify([ + { uid: 'app1', manifest: { name: 'App1' }, ui_location: { locations: [{ meta: { extension_uid: 'market_ext_1' } }] } }, + ]); + } + return '{}'; + }); + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).missingSelectFeild = { 'test-entry': [] }; + (ctInstance as any).missingMandatoryFields = { 'test-entry': [] }; + await ctInstance.fixPrerequisiteData(); + expect(ctInstance.extensions).to.include('market_ext_1'); + }); }); describe('writeFixContent method', () => { @@ -149,6 +211,39 @@ describe('Entries module', () => { expect(writeFileSync.calledWithExactly(resolve(__dirname, '..', 'mock', 'contents'), JSON.stringify({}))).to.be .true; }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .it("should skip confirmation when copy-dir flag passed", async () => { + const writeFileSync = Sinon.spy(fs, 'writeFileSync'); + const ctInstance = new Entries({ ...constructorParam, fix: true }); + ctInstance.config.flags['copy-dir'] = true; + await ctInstance.writeFixContent(resolve(__dirname, '..', 'mock', 'contents'), { e1: {} as EntryStruct }); + expect(writeFileSync.callCount).to.be.equals(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'confirm', async () => false) + .it('should not write when user declines confirmation', async () => { + const writeFileSync = Sinon.spy(fs, 'writeFileSync'); + const ctInstance = new Entries({ ...constructorParam, fix: true }); + await ctInstance.writeFixContent(resolve(__dirname, '..', 'mock', 'contents'), {}); + expect(writeFileSync.callCount).to.be.equals(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .it('when fix true and writeFixContent called multiple times, confirm is called only once', async () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + const confirmStub = Sinon.stub(cliux, 'confirm').resolves(true); + await ctInstance.writeFixContent(resolve(__dirname, '..', 'mock', 'contents', 'chunk1.json'), { e1: {} as EntryStruct }); + await ctInstance.writeFixContent(resolve(__dirname, '..', 'mock', 'contents', 'chunk2.json'), { e2: {} as EntryStruct }); + expect(confirmStub.callCount).to.equal(1); + }); }); describe('lookForReference method', () => { @@ -219,10 +314,12 @@ describe('Entries module', () => { fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('should return missing reference', async () => { const ctInstance = new Class(); + // Reference that is missing from entryMetaData so it appears in missingRefs + const entryData = [{ uid: 'test-uid-1', _content_type_uid: 'page_0' }]; const missingRefs = await ctInstance.validateReferenceField( [{ uid: 'test-uid', name: 'reference', field: 'reference' }], ctInstance.ctSchema[3].schema as any, - ctInstance.entries['reference'] as any, + entryData as any, ); expect(missingRefs).deep.equal([ @@ -250,34 +347,24 @@ describe('Entries module', () => { }); }); - // describe('validateGlobalField method', () => { - // let lookForReferenceSpy; - // let ctInstance; - - // beforeEach(() => { - // // Restore original methods before each test - // Sinon.restore(); - - // // Spy on the lookForReference method - // lookForReferenceSpy = Sinon.spy(Entries.prototype, 'lookForReference'); - - // // Create a new instance of Entries for each test - // ctInstance = new (class extends Entries { - // public entries: Record = ( - // require('../mock/contents/entries/page_1/en-us/e7f6e3cc-64ca-4226-afb3-7794242ae5f5-entries.json') as any - // )['test-uid-2']; - // })(constructorParam); - // }); - - // it('should call lookForReference method', async () => { - // // Call the method under test - // await ctInstance.validateGlobalField([], ctInstance.ctSchema as any, ctInstance.entries); - - // // Assertions - // expect(lookForReferenceSpy.callCount).to.be.equals(1); - // expect(lookForReferenceSpy.calledWithExactly([], ctInstance.ctSchema, ctInstance.entries)).to.be.true; - // }); - // }); + describe('validateGlobalField method', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('calls lookForReference and completes validation', () => { + const lookForReference = Sinon.spy(Entries.prototype, 'lookForReference'); + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).missingSelectFeild = { 'test-entry': [] }; + (ctInstance as any).missingMandatoryFields = { 'test-entry': [] }; + const tree: Record[] = []; + const fieldStructure = { uid: 'gf_1', display_name: 'Global Field 1', schema: [{ uid: 'ref', data_type: 'reference' }] }; + const field = { ref: [] }; + ctInstance.validateGlobalField(tree, fieldStructure as any, field as any); + expect(lookForReference.callCount).to.equal(1); + expect(lookForReference.calledWith(tree, fieldStructure, field)).to.be.true; + }); + }); describe('validateJsonRTEFields method', () => { fancy @@ -406,6 +493,154 @@ describe('Entries module', () => { }); }); + describe('removeMissingKeysOnEntry', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('removes entry keys not in schema and not in systemKeys', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'e1'; + const schema = [{ uid: 'title' }, { uid: 'body' }]; + const entry: Record = { title: 'T', body: 'B', invalid_key: 'remove me', uid: 'keep-uid' }; + (ctInstance as any).removeMissingKeysOnEntry(schema, entry); + expect(entry.invalid_key).to.be.undefined; + expect(entry.title).to.equal('T'); + expect(entry.uid).to.equal('keep-uid'); + }); + }); + + describe('runFixOnSchema', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips field when not present in entry', () => { + if ((Entries.prototype.fixGlobalFieldReferences as any).restore) (Entries.prototype.fixGlobalFieldReferences as any).restore(); + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).missingMultipleField = { e1: [] }; + const schema = [{ uid: 'only_in_schema', data_type: 'text', display_name: 'Only' }]; + const entry = { other_key: 'v' }; + const result = (ctInstance as any).runFixOnSchema([], schema, entry); + expect((entry as any).only_in_schema).to.be.undefined; + expect(result).to.equal(entry); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('converts non-array to array when field is multiple', () => { + if ((Entries.prototype.fixGlobalFieldReferences as any).restore) (Entries.prototype.fixGlobalFieldReferences as any).restore(); + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).missingMultipleField = { e1: [] }; + Sinon.stub(Entries.prototype, 'fixGlobalFieldReferences').callsFake((_t: any, _f: any, e: any) => e); + const schema = [{ uid: 'multi', data_type: 'global_field', multiple: true, display_name: 'M', schema: [] }]; + const entry = { multi: 'single value' as any }; + (ctInstance as any).runFixOnSchema([], schema, entry); + expect(entry.multi).to.eql(['single value']); + (Entries.prototype.fixGlobalFieldReferences as any).restore?.(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('deletes reference field when fixMissingReferences returns falsy', () => { + if ((Entries.prototype.fixMissingReferences as any).restore) (Entries.prototype.fixMissingReferences as any).restore(); + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).entryMetaData = []; + Sinon.stub(Entries.prototype, 'fixMissingReferences').returns(undefined as any); + const schema = [{ uid: 'ref', data_type: 'reference', display_name: 'Ref', reference_to: ['ct1'] }]; + const entry = { ref: [{ uid: 'missing' }] }; + (ctInstance as any).runFixOnSchema([], schema, entry); + expect(entry.ref).to.be.undefined; + (Entries.prototype.fixMissingReferences as any).restore?.(); + }); + }); + + describe('validateMandatoryFields', () => { + const initInstance = (ctInstance: Entries) => { + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).currentTitle = 'Test Entry'; + (ctInstance as any).missingMandatoryFields = { 'test-entry': [] }; + }; + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns missing field when mandatory JSON RTE is empty', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { + uid: 'body', + display_name: 'Body', + data_type: 'json', + mandatory: true, + multiple: false, + field_metadata: { allow_json_rte: true }, + }; + const entry = { + body: { + children: [{ children: [{ text: '' }] }], + }, + }; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.have.length(1); + expect(result[0]).to.include({ display_name: 'Body', missingFieldUid: 'body' }); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns missing field when mandatory number is empty', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { uid: 'num', display_name: 'Number', data_type: 'number', mandatory: true, multiple: false, field_metadata: {} }; + const entry = {}; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.have.length(1); + expect(result[0].missingFieldUid).to.equal('num'); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns missing field when mandatory text is empty', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { uid: 'title', display_name: 'Title', data_type: 'text', mandatory: true, multiple: false, field_metadata: {} }; + const entry = { title: '' }; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.have.length(1); + expect(result[0].missingFieldUid).to.equal('title'); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns missing field when mandatory reference array is empty', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { uid: 'ref', display_name: 'Reference', data_type: 'reference', mandatory: true, multiple: false, field_metadata: {} }; + const entry = { ref: [] }; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.have.length(1); + expect(result[0].missingFieldUid).to.equal('ref'); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns empty array when mandatory field has value', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { uid: 'title', display_name: 'Title', data_type: 'text', mandatory: true, multiple: false, field_metadata: {} }; + const entry = { title: 'Has value' }; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.eql([]); + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns empty array when field is not mandatory', () => { + const ctInstance = new Entries(constructorParam); + initInstance(ctInstance); + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const fieldStructure = { uid: 'opt', display_name: 'Optional', data_type: 'text', mandatory: false, multiple: false, field_metadata: {} }; + const entry = {}; + const result = (ctInstance as any).validateMandatoryFields(tree, fieldStructure, entry); + expect(result).to.eql([]); + }); + }); + describe('validateSelectField method', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false }) @@ -1090,6 +1325,31 @@ describe('Entries module', () => { expect(result).to.have.length(0); }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns empty array when fix mode is enabled', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).entryMetaData = []; + const referenceFieldSchema = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entryData = [{ uid: 'blt1', _content_type_uid: 'ct1' }]; + const result = ctInstance.validateReferenceValues([], referenceFieldSchema as any, entryData); + expect(result).to.eql([]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not flag blt reference when found in entryMetaData and content type allowed', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).entryMetaData = [{ uid: 'blt999', ctUid: 'ct1' }]; + const referenceFieldSchema = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entryData = ['blt999']; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + const result = ctInstance.validateReferenceValues(tree, referenceFieldSchema as any, entryData as any); + expect(result).to.have.length(0); + }); }); describe('validateModularBlocksField method', () => { @@ -1309,6 +1569,61 @@ describe('Entries module', () => { expect(result).to.be.an('array'); // Should return an array of missing references }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('reports extension as valid when extension_uid is in extensions list', async ({}) => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + ctInstance.extensions = ['valid-ext-uid']; + const field = { uid: 'ext_f', display_name: 'Ext Field', data_type: 'json', field_metadata: { extension: true } }; + const entry = { ext_f: { metadata: { extension_uid: 'valid-ext-uid' } } }; + const tree: Record[] = []; + const result = ctInstance.validateExtensionAndAppField(tree, field as any, entry as any); + expect(result).to.be.an('array'); + expect(result).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns empty array when fix mode is enabled', async ({}) => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + ctInstance.extensions = []; + const field = { uid: 'ext_f', display_name: 'Ext', data_type: 'json' }; + const entry = { ext_f: { metadata: { extension_uid: 'any' } } }; + const result = ctInstance.validateExtensionAndAppField([], field as any, entry as any); + expect(result).to.eql([]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns empty array when field has no extension data (no entry[uid])', async ({}) => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + ctInstance.extensions = []; + const field = { uid: 'ext_f', display_name: 'Ext Field', data_type: 'json' }; + const entry = {} as any; + const result = ctInstance.validateExtensionAndAppField([], field as any, entry); + expect(result).to.eql([]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns result with treeStr when extension UID is missing', async ({}) => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + ctInstance.extensions = ['other-ext']; + const field = { uid: 'ext_f', display_name: 'Ext Field', data_type: 'json' }; + const entry = { ext_f: { metadata: { extension_uid: 'missing-ext' } } }; + const tree = [{ uid: 'e1', name: 'Entry 1' }]; + const result = ctInstance.validateExtensionAndAppField(tree, field as any, entry as any); + expect(result).to.have.length(1); + expect(result[0]).to.have.property('treeStr'); + expect(result[0].missingRefs).to.deep.include({ uid: 'ext_f', extension_uid: 'missing-ext', type: 'Extension or Apps' }); + }); + fancy .stdout({ print: process.env.PRINT === 'true' || false }) .it('should flag file field with invalid asset UID', async ({}) => { @@ -1540,4 +1855,436 @@ describe('Entries module', () => { expect(callHelper('sys_assets', ['ct1'])).to.be.true; }); }); + + describe('jsonRefCheck entry ref and no-entry-uid branches', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('pushes to missingRefs and returns null when entry UID is not in entryMetaData', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).currentTitle = 'Test Entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).entryMetaData = []; // entry not present + + const schema = { + uid: 'json_rte', + display_name: 'JSON RTE', + data_type: 'richtext', + reference_to: ['ct1'], + }; + const child = { + type: 'embed', + uid: 'child-uid', + attrs: { 'entry-uid': 'missing-uid', 'content-type-uid': 'ct1' }, + children: [], + }; + const tree: Record[] = []; + + const result = (ctInstance as any).jsonRefCheck(tree, schema, child); + + expect(result).to.be.null; + expect((ctInstance as any).missingRefs['test-entry']).to.have.length(1); + expect((ctInstance as any).missingRefs['test-entry'][0].missingRefs).to.deep.include({ + uid: 'missing-uid', + 'content-type-uid': 'ct1', + }); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns true when entry UID is in entryMetaData and isRefContentTypeAllowed (valid ref)', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).entryMetaData = [{ uid: 'blt123', ctUid: 'ct1' }]; + + const schema = { + uid: 'json_rte', + display_name: 'JSON RTE', + data_type: 'richtext', + reference_to: ['ct1'], + }; + const child = { + type: 'embed', + uid: 'child-uid', + attrs: { 'entry-uid': 'blt123', 'content-type-uid': 'ct1' }, + children: [], + }; + const tree: Record[] = []; + + const result = (ctInstance as any).jsonRefCheck(tree, schema, child); + + expect(result).to.be.true; + expect((ctInstance as any).missingRefs['test-entry']).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns true when child has no entry-uid (no entry UID in JSON child)', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingRefs = { 'test-entry': [] }; + (ctInstance as any).entryMetaData = []; + + const schema = { + uid: 'json_rte', + display_name: 'JSON RTE', + data_type: 'richtext', + }; + const child = { + type: 'embed', + uid: 'child-uid', + attrs: {}, // no entry-uid + children: [], + }; + const tree: Record[] = []; + + const result = (ctInstance as any).jsonRefCheck(tree, schema, child); + + expect(result).to.be.true; + expect((ctInstance as any).missingRefs['test-entry']).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns true and does not push when entry ref is valid (covers Entry reference is valid log)', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = [{ uid: 'valid-uid', ctUid: 'page_0' }]; + const schema = { uid: 'rte', display_name: 'RTE', data_type: 'richtext', reference_to: ['page_0'] }; + const child = { + type: 'reference', + uid: 'c1', + attrs: { 'entry-uid': 'valid-uid', 'content-type-uid': 'page_0' }, + children: [], + }; + const result = (ctInstance as any).jsonRefCheck([], schema, child); + expect(result).to.be.true; + expect((ctInstance as any).missingRefs.e1).to.have.length(0); + }); + }); + + describe('prepareEntryMetaData', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads locales, environments, and entry metadata from mock contents', async () => { + const ctInstance = new Entries(constructorParam); + await ctInstance.prepareEntryMetaData(); + + expect(ctInstance.entryMetaData).to.be.an('array'); + expect(ctInstance.environments).to.be.an('array'); + expect(ctInstance.locales).to.be.an('array'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads only master locales when additional locales file is missing', async () => { + if ((fs.existsSync as any).restore) (fs.existsSync as any).restore(); + const realExists = fs.existsSync.bind(fs); + Sinon.stub(fs, 'existsSync').callsFake((path: fs.PathLike) => { + const p = String(path); + if (p.includes('locales.json') && !p.includes('master-locale')) return false; + return realExists(path); + }); + try { + const ctInstance = new Entries(constructorParam); + await ctInstance.prepareEntryMetaData(); + expect(ctInstance.locales).to.be.an('array'); + expect(ctInstance.entryMetaData).to.be.an('array'); + } finally { + (fs.existsSync as any).restore?.(); + } + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('records empty title and no-title entries and pushes to entryMetaData', async () => { + if ((fs.existsSync as any).restore) (fs.existsSync as any).restore(); + if ((fs.readFileSync as any).restore) (fs.readFileSync as any).restore(); + const fullSchema = cloneDeep(require('../mock/contents/content_types/schema.json')); + const page1 = fullSchema.find((c: any) => c.uid === 'page_1'); + const emptyTitleCt = page1 ? { ...page1, uid: 'empty_title_ct' } : fullSchema[0]; + const param = { + ...constructorParam, + ctSchema: [emptyTitleCt], + config: { ...constructorParam.config }, + }; + const ctInstance = new Entries(param); + await ctInstance.prepareEntryMetaData(); + const missingTitleFields = (ctInstance as any).missingTitleFields; + expect(missingTitleFields).to.be.an('object'); + expect(missingTitleFields['entry-empty-title']).to.deep.include({ + 'Entry UID': 'entry-empty-title', + 'Content Type UID': 'empty_title_ct', + Locale: 'en-us', + }); + const metaNoTitle = ctInstance.entryMetaData.find((m: any) => m.uid === 'entry-no-title'); + expect(metaNoTitle).to.be.ok; + expect(metaNoTitle!.title).to.be.undefined; + const metaEmpty = ctInstance.entryMetaData.find((m: any) => m.uid === 'entry-empty-title'); + expect(metaEmpty).to.be.ok; + expect(ctInstance.entryMetaData.length).to.be.at.least(2); + }); + }); + + describe('findNotPresentSelectField', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('initializes field as empty array when field is null or undefined', () => { + const ctInstance = new Entries(constructorParam); + const choices = { choices: [{ value: 'a' }, { value: 'b' }] }; + const result = (ctInstance as any).findNotPresentSelectField(null, choices); + expect(result.filteredFeild).to.eql([]); + expect(result.notPresent).to.eql([]); + }); + }); + + describe('fixMissingReferences uncovered branches', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('parses entry when entry is string (JSON)', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'Entry 1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = [{ uid: 'blt123', ctUid: 'ct1' }]; + const field = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entry = '[{"uid":"blt123","_content_type_uid":"ct1"}]'; + const tree: Record[] = []; + const result = ctInstance.fixMissingReferences(tree, field as any, entry as any); + expect(result).to.be.an('array'); + expect(result.length).to.equal(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('handles blt reference when ref missing and reference_to single', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = []; + const field = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entry = ['blt999']; + const tree: Record[] = []; + const result = ctInstance.fixMissingReferences(tree, field as any, entry as any); + expect((ctInstance as any).missingRefs.e1).to.have.length(1); + expect((ctInstance as any).missingRefs.e1[0].missingRefs).to.deep.include({ uid: 'blt999', _content_type_uid: 'ct1' }); + expect(result.filter((r: any) => r != null)).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('records no missing references when all refs valid', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = [{ uid: 'blt1', ctUid: 'ct1' }]; + const field = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entry = [{ uid: 'blt1', _content_type_uid: 'ct1' }]; + const tree: Record[] = []; + const result = ctInstance.fixMissingReferences(tree, field as any, entry); + expect(result).to.have.length(1); + expect((ctInstance as any).missingRefs.e1).to.have.length(0); + expect(result[0].uid).to.equal('blt1'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('keeps blt reference when found in entryMetaData and content type allowed', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = [{ uid: 'blt1', ctUid: 'ct1' }]; + const field = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1'] }; + const entry = ['blt1']; + const tree: Record[] = []; + const result = ctInstance.fixMissingReferences(tree, field as any, entry as any); + expect(result).to.have.length(1); + expect(result[0]).to.deep.include({ uid: 'blt1', _content_type_uid: 'ct1' }); + expect((ctInstance as any).missingRefs.e1).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('pushes fullRef when reference_to has multiple and ref has wrong content type', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + (ctInstance as any).entryMetaData = [{ uid: 'ref-uid', ctUid: 'ct3' }]; + const field = { uid: 'ref', display_name: 'Ref', data_type: 'reference', reference_to: ['ct1', 'ct2'] }; + const fullRef = { uid: 'ref-uid', _content_type_uid: 'ct3' }; + const entry = [fullRef]; + const tree: Record[] = []; + ctInstance.fixMissingReferences(tree, field as any, entry); + expect((ctInstance as any).missingRefs.e1).to.have.length(1); + expect((ctInstance as any).missingRefs.e1[0].missingRefs).to.have.length(1); + expect((ctInstance as any).missingRefs.e1[0].missingRefs[0]).to.deep.equal(fullRef); + }); + }); + + describe('modularBlockRefCheck invalid keys with fix', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('deletes invalid block key when fix is true', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + const blocks = [{ uid: 'block_1', title: 'Block 1', schema: [] }]; + const entryBlock = { block_1: {}, invalid_key: {} }; + const tree: Record[] = []; + const result = (ctInstance as any).modularBlockRefCheck(tree, blocks, entryBlock, 0); + expect(result.invalid_key).to.be.undefined; + expect((ctInstance as any).missingRefs.e1).to.have.length(1); + }); + }); + + describe('fixGroupField', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('processes array group field entry when entry is array', () => { + if ((Entries.prototype.runFixOnSchema as any).restore) (Entries.prototype.runFixOnSchema as any).restore(); + Sinon.stub(Entries.prototype, 'runFixOnSchema').callsFake((_t: any, _s: any, e: any) => e); + const ctInstance = new Entries(constructorParam); + const field = { uid: 'gf', display_name: 'GF', schema: [{ uid: 'f1', display_name: 'F1' }] }; + const entry = [{ f1: 'v1' }]; + const result = (ctInstance as any).fixGroupField([], field, entry); + expect(result).to.eql(entry); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('processes single group field entry when entry is not array', () => { + if ((Entries.prototype.runFixOnSchema as any).restore) (Entries.prototype.runFixOnSchema as any).restore(); + Sinon.stub(Entries.prototype, 'runFixOnSchema').callsFake((_t: any, _s: any, e: any) => e); + const ctInstance = new Entries(constructorParam); + const field = { uid: 'gf', display_name: 'GF', schema: [{ uid: 'f1', display_name: 'F1' }] }; + const entry = { f1: 'v1' }; + const result = (ctInstance as any).fixGroupField([], field, entry); + expect(result).to.eql(entry); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips fixes when group field has no schema', () => { + const ctInstance = new Entries(constructorParam); + const field = { uid: 'gf', display_name: 'GF', schema: [] }; + const entry = { f1: 'v1' }; + const result = (ctInstance as any).fixGroupField([], field, entry); + expect(result).to.eql(entry); + }); + }); + + describe('fixMissingExtensionOrApp', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('deletes entry field when extension missing and fix true', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + ctInstance.extensions = []; + const field = { uid: 'ext_f', display_name: 'Ext', data_type: 'extension' }; + const entry: Record = { ext_f: { metadata: { extension_uid: 'missing_ext' } } }; + (ctInstance as any).fixMissingExtensionOrApp([], field, entry); + expect(entry.ext_f).to.be.undefined; + expect((ctInstance as any).missingRefs.e1).to.have.length(1); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('logs when no extension data for field', () => { + const ctInstance = new Entries(constructorParam); + ctInstance.extensions = ['ext1']; + const field = { uid: 'ext_f', display_name: 'Ext', data_type: 'extension' }; + const entry: Record = {}; + (ctInstance as any).fixMissingExtensionOrApp([], field, entry); + expect(entry.ext_f).to.be.undefined; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('keeps field when extension is valid', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).currentTitle = 'E1'; + (ctInstance as any).missingRefs = { e1: [] }; + ctInstance.extensions = ['valid-ext']; + const field = { uid: 'ext_f', display_name: 'Ext', data_type: 'extension' }; + const entry: Record = { ext_f: { metadata: { extension_uid: 'valid-ext' } } }; + (ctInstance as any).fixMissingExtensionOrApp([], field, entry); + expect(entry.ext_f).to.be.ok; + expect((ctInstance as any).missingRefs.e1).to.have.length(0); + }); + }); + + describe('fixModularBlocksReferences', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('fixes modular blocks and filters empty', () => { + if ((Entries.prototype.modularBlockRefCheck as any).restore) (Entries.prototype.modularBlockRefCheck as any).restore(); + if ((Entries.prototype.runFixOnSchema as any).restore) (Entries.prototype.runFixOnSchema as any).restore(); + Sinon.stub(Entries.prototype, 'modularBlockRefCheck').callsFake((_t: any, blocks: any, entryBlock: any) => { + const key = blocks?.[0]?.uid || 'b1'; + return { [key]: entryBlock?.[key] || {} }; + }); + Sinon.stub(Entries.prototype, 'runFixOnSchema').callsFake((_t: any, _s: any, e: any) => e); + const ctInstance = new Entries(constructorParam); + const blocks = [{ uid: 'b1', title: 'B1', schema: [{ uid: 'f1' }] }]; + const entry = [{ b1: { f1: 'v1' } }]; + const result = (ctInstance as any).fixModularBlocksReferences([], blocks, entry); + expect(result).to.be.an('array'); + }); + }); + + describe('fixJsonRteMissingReferences', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns entry when entry has no children', () => { + const ctInstance = new Entries(constructorParam); + const field = { uid: 'rte', display_name: 'RTE', data_type: 'richtext' }; + const entry = { type: 'doc', children: [] }; + const result = (ctInstance as any).fixJsonRteMissingReferences([], field, entry); + expect(result).to.eql(entry); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('processes array entry by mapping over each child', () => { + const ctInstance = new Entries(constructorParam); + const field = { uid: 'rte', display_name: 'RTE', data_type: 'richtext' }; + const child1 = { type: 'p', uid: 'c1', children: [] }; + const child2 = { type: 'reference', uid: 'c2', children: [] }; + const entry = [child1, child2]; + const result = (ctInstance as any).fixJsonRteMissingReferences([], field, entry); + expect(result).to.be.an('array'); + expect(result).to.have.length(2); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('filters out invalid refs and recursively fixes children with children', () => { + if ((Entries.prototype.jsonRefCheck as any).restore) (Entries.prototype.jsonRefCheck as any).restore(); + Sinon.stub(Entries.prototype, 'jsonRefCheck').callsFake(function (_tree: any, _field: any, child: any) { + return (child as any).uid !== 'invalid' ? true : null; + }); + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'e1'; + (ctInstance as any).entryMetaData = [{ uid: 'valid', ctUid: 'ct1' }]; + const field = { uid: 'rte', display_name: 'RTE', data_type: 'richtext', reference_to: ['ct1'] }; + const validChild = { type: 'reference', uid: 'valid', attrs: { 'entry-uid': 'valid' }, children: [] }; + const invalidChild = { type: 'reference', uid: 'invalid', attrs: {}, children: [] }; + const nestedChild = { type: 'p', uid: 'nested', children: [{ type: 'text', text: 'x' }] }; + const entry = { type: 'doc', children: [validChild, invalidChild, nestedChild] }; + const result = (ctInstance as any).fixJsonRteMissingReferences([], field, entry); + expect((result as any).children).to.have.length(2); + expect((result as any).children.filter((c: any) => c?.uid === 'invalid')).to.have.length(0); + (Entries.prototype.jsonRefCheck as any).restore(); + }); + }); }); diff --git a/packages/contentstack-audit/test/unit/modules/extensions.test.ts b/packages/contentstack-audit/test/unit/modules/extensions.test.ts index bb07376a4..ac6a443a8 100644 --- a/packages/contentstack-audit/test/unit/modules/extensions.test.ts +++ b/packages/contentstack-audit/test/unit/modules/extensions.test.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import { resolve } from 'path'; import { fancy } from 'fancy-test'; import { expect } from 'chai'; @@ -387,4 +388,59 @@ describe('Extensions scope containing content_types uids', () => { }, ); }); + + describe('fixExtensionsScope single confirmation', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .it('fixExtensionsScope asks for confirmation once when multiple extensions would be deleted', async () => { + const ext = new Extensions({ + moduleName: 'extensions', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + (ext as any).extensionsPath = resolve(__dirname, '..', 'mock', 'contents', 'extensions', 'extensions.json'); + const twoOrphanExtensions: Extension[] = [ + { uid: 'orph1', title: 'Orphan 1', scope: { content_types: [] }, type: 'widget' } as any, + { uid: 'orph2', title: 'Orphan 2', scope: { content_types: [] }, type: 'widget' } as any, + ]; + ext.missingCts = new Set(['ct-missing']); + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns( + JSON.stringify({ orph1: { uid: 'orph1', title: 'Orphan 1', scope: { content_types: [] } }, orph2: { uid: 'orph2', title: 'Orphan 2', scope: { content_types: [] } } }), + ); + const confirmStub = sinon.stub(cliux, 'confirm').resolves(true); + sinon.stub(ext, 'writeFixContent').resolves(); + await (ext as any).fixExtensionsScope(twoOrphanExtensions); + expect(confirmStub.callCount).to.equal(1); + (fs.existsSync as any).restore?.(); + (fs.readFileSync as any).restore?.(); + }); + }); + + describe('writeFixContent with preConfirmed', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('writeFixContent does not prompt when preConfirmed is true', async () => { + const ext = new Extensions({ + moduleName: 'extensions', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + const writeStub = sinon.stub(fs, 'writeFileSync'); + const confirmStub = sinon.stub(cliux, 'confirm'); + await (ext as any).writeFixContent({ ext1: {} as Extension }, true); + expect(writeStub.called).to.be.true; + expect(confirmStub.called).to.be.false; + writeStub.restore(); + }); + }); }); diff --git a/packages/contentstack-audit/test/unit/modules/field-rules.test.ts b/packages/contentstack-audit/test/unit/modules/field-rules.test.ts index 6999ab5a3..b66cb1d32 100644 --- a/packages/contentstack-audit/test/unit/modules/field-rules.test.ts +++ b/packages/contentstack-audit/test/unit/modules/field-rules.test.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import sinon from 'sinon'; -import { resolve } from 'path'; +import { resolve, join } from 'path'; import { fancy } from 'fancy-test'; import { expect } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; @@ -116,6 +116,45 @@ describe('Field Rules', () => { expect(logSpy.callCount).to.be.equals(1); }); + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(FieldRule.prototype, 'prepareEntryMetaData', async () => {}) + .stub(FieldRule.prototype, 'prerequisiteData', async () => {}) + .stub(FieldRule.prototype, 'lookForReference', async () => {}) + .stub(FieldRule.prototype, 'fixFieldRules', () => {}) + .stub(FieldRule.prototype, 'validateFieldRules', () => {}) + .it('should create progress and call progressManager.tick when totalCount > 0', async () => { + const frInstance = new FieldRule(constructorParam); + (frInstance as any).createSimpleProgress = sinon.stub().callsFake(function (this: any) { + const progress = { updateStatus: sinon.stub(), tick: sinon.stub(), complete: sinon.stub() }; + this.progressManager = progress; + return progress; + }); + await frInstance.run(5); + expect((frInstance as any).createSimpleProgress.calledWith('field-rules', 5)).to.be.true; + const progress = (frInstance as any).createSimpleProgress.firstCall.returnValue; + expect(progress.updateStatus.calledWith('Validating field rules...')).to.be.true; + expect(progress.tick.callCount).to.equal(frInstance.ctSchema!.length); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(FieldRule.prototype, 'prerequisiteData', async () => {}) + .stub(FieldRule.prototype, 'lookForReference', async () => {}) + .stub(FieldRule.prototype, 'fixFieldRules', () => {}) + .stub(FieldRule.prototype, 'validateFieldRules', () => {}) + .it('should call completeProgress(false) and rethrow when run() throws', async () => { + const frInstance = new FieldRule(constructorParam); + sinon.stub(frInstance, 'prepareEntryMetaData').rejects(new Error('prepare failed')); + const completeSpy = sinon.spy(frInstance as any, 'completeProgress'); + try { + await frInstance.run(); + } catch (e: any) { + expect(e.message).to.equal('prepare failed'); + } + expect(completeSpy.calledWith(false, 'prepare failed')).to.be.true; + }); + fancy .stub(fs, 'rmSync', () => {}) .stdout({ print: process.env.PRINT === 'true' || false }) @@ -140,6 +179,98 @@ describe('Field Rules', () => { }); }); + describe('validateFieldRules', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('logs valid when target_field is in schemaMap', () => { + const frInstance = new FieldRule(constructorParam); + frInstance.schemaMap = ['title', 'desc']; + const schema = { + uid: 'ct_1', + title: 'CT One', + field_rules: [ + { + conditions: [], + actions: [{ action: 'show', target_field: 'title' }], + rule_type: 'entry', + }, + ], + } as any; + frInstance.validateFieldRules(schema); + expect((frInstance as any).missingRefs['ct_1'] || []).to.have.length(0); + }); + }); + + describe('prerequisiteData', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('handles error when loading extensions file throws', async () => { + const frInstance = new FieldRule(constructorParam); + const extPath = resolve(constructorParam.config.basePath, 'extensions', 'extensions.json'); + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => String(p) === extPath); + sinon.stub(fs, 'readFileSync').callsFake(() => { + throw new Error('read error'); + }); + await frInstance.prerequisiteData(); + expect(frInstance.extensions).to.deep.equal([]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('loads marketplace apps and pushes extension UIDs', async () => { + const frInstance = new FieldRule(constructorParam); + const marketPath = resolve(constructorParam.config.basePath, 'marketplace_apps', 'marketplace_apps.json'); + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => String(p) === marketPath); + const marketplaceData = [ + { + uid: 'app1', + ui_location: { + locations: [{ meta: { extension_uid: 'ext_1' } }, { meta: { extension_uid: 'ext_2' } }], + }, + }, + ]; + sinon.stub(fs, 'readFileSync').callsFake((p: fs.PathOrFileDescriptor) => { + if (String(p) === marketPath) return JSON.stringify(marketplaceData); + return '{}'; + }); + await frInstance.prerequisiteData(); + expect(frInstance.extensions).to.include('ext_1'); + expect(frInstance.extensions).to.include('ext_2'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('handles error when loading marketplace apps file throws', async () => { + const frInstance = new FieldRule(constructorParam); + const marketPath = resolve(constructorParam.config.basePath, 'marketplace_apps', 'marketplace_apps.json'); + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => String(p) === marketPath); + sinon.stub(fs, 'readFileSync').callsFake(() => { + throw new Error('marketplace read error'); + }); + await frInstance.prerequisiteData(); + expect(frInstance.extensions).to.deep.equal([]); + }); + }); + + describe('fixFieldRules', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('keeps valid action and logs info when target_field in schemaMap', () => { + const frInstance = new FieldRule({ ...constructorParam, fix: true }); + frInstance.schemaMap = ['title']; + const schema = { + uid: 'ct_1', + title: 'CT One', + field_rules: [ + { + conditions: [{ operand_field: 'title', operator: 'equals', value: 'x' }], + actions: [{ action: 'show', target_field: 'title' }], + rule_type: 'entry', + }, + ], + } as any; + frInstance.fixFieldRules(schema); + expect(schema.field_rules).to.have.length(1); + expect(schema.field_rules[0].actions).to.have.length(1); + }); + }); + describe('writeFixContent method', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false }) @@ -163,6 +294,78 @@ describe('Field Rules', () => { await ctInstance.writeFixContent(); expect(spy.callCount).to.be.equals(1); }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips file write when user declines confirmation', async () => { + sinon.replace(cliux, 'confirm', async () => false); + const ctInstance = new FieldRule({ ...constructorParam, fix: true }); + (ctInstance as any).schema = [{ uid: 'ct_1', title: 'CT' }]; + const writeSpy = sinon.stub(fs, 'writeFileSync'); + await ctInstance.writeFixContent(); + expect(writeSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .it('skips confirmation when copy-dir or external-config skipConfirm is set', async () => { + const ctInstance = new FieldRule({ + ...constructorParam, + fix: true, + config: { + ...constructorParam.config, + flags: { 'copy-dir': true } as any, + }, + }); + (ctInstance as any).schema = [{ uid: 'ct_1', title: 'CT' }]; + const confirmSpy = sinon.stub(cliux, 'confirm').resolves(false); + await ctInstance.writeFixContent(); + expect(confirmSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips file write when fix mode is disabled', async () => { + const ctInstance = new FieldRule({ ...constructorParam, fix: false }); + (ctInstance as any).schema = [{ uid: 'ct_1', title: 'CT' }]; + const writeSpy = sinon.stub(fs, 'writeFileSync'); + await ctInstance.writeFixContent(); + expect(writeSpy.callCount).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'confirm', async () => true) + .it('skips schema with missing uid and writes rest', async () => { + const ctInstance = new FieldRule({ ...constructorParam, fix: true }); + (ctInstance as any).schema = [ + { uid: undefined, title: 'NoUid' }, + { uid: 'ct_1', title: 'CT One' }, + ]; + const writeSpy = sinon.spy(fs, 'writeFileSync'); + await ctInstance.writeFixContent(); + expect(writeSpy.callCount).to.equal(1); + expect(writeSpy.calledWith(join(ctInstance.folderPath, 'ct_1.json'), sinon.match.string)).to.be.true; + }); + }); + + describe('prepareEntryMetaData', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('logs when additional locales file not found', async () => { + const frInstance = new FieldRule(constructorParam); + const localesFolderPath = resolve(constructorParam.config.basePath, frInstance.config.moduleConfig.locales.dirName); + const localesPath = join(localesFolderPath, frInstance.config.moduleConfig.locales.fileName); + const origExists = fs.existsSync; + sinon.stub(fs, 'existsSync').callsFake((p: fs.PathLike) => { + if (String(p) === localesPath) return false; + return origExists.call(fs, p); + }); + await frInstance.prepareEntryMetaData(); + expect(frInstance.locales.length).to.be.greaterThanOrEqual(0); + }); }); describe('Test Other methods', () => { diff --git a/packages/contentstack-audit/test/unit/modules/workflow.test.ts b/packages/contentstack-audit/test/unit/modules/workflow.test.ts index 69ad1e73c..d5a942023 100644 --- a/packages/contentstack-audit/test/unit/modules/workflow.test.ts +++ b/packages/contentstack-audit/test/unit/modules/workflow.test.ts @@ -1,9 +1,9 @@ import fs from 'fs'; -import { resolve } from 'path'; +import { join, resolve } from 'path'; import { fancy } from 'fancy-test'; import { expect } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; -import { ux } from '@contentstack/cli-utilities'; +import { ux, cliux } from '@contentstack/cli-utilities'; import sinon from 'sinon'; import config from '../../../src/config'; @@ -17,11 +17,25 @@ describe('Workflows', () => { // Mock the logger for all tests sinon.stub(require('@contentstack/cli-utilities'), 'log').value(mockLogger); }); - + afterEach(() => { sinon.restore(); }); - + + describe('validateModules', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns default workflows when moduleName not in moduleConfig', () => { + const wf = new Workflows({ + moduleName: 'workflows' as any, + ctSchema: [], + config: Object.assign(config, { basePath: resolve(__dirname, '..', 'mock', 'contents'), flags: {} }), + }); + const result = (wf as any).validateModules('invalid-module' as any, config.moduleConfig); + expect(result).to.equal('workflows'); + }); + }); + describe('run method with invalid path for workflows', () => { const wf = new Workflows({ moduleName: 'workflows', @@ -93,6 +107,67 @@ describe('Workflows', () => { ); }); + describe('run method with totalCount', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('creates progress when totalCount is provided', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: cloneDeep(require('../mock/contents/workflows/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + }); + const createProgress = sinon.spy(wf as any, 'createSimpleProgress'); + await wf.run(5); + expect(createProgress.calledWith('workflows', 5)).to.be.true; + }); + }); + + describe('run method with no branch config', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('runs and hits no branch configuration path', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: cloneDeep(require('../mock/contents/workflows/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + branch: undefined, + }), + }); + const result = await wf.run(); + expect(result).to.be.an('array'); + }); + }); + + describe('run method throws', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('completeProgress false and rethrows when run throws', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: cloneDeep(require('../mock/contents/workflows/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + }); + sinon.stub(fs, 'readFileSync').throws(new Error('read failed')); + const completeProgress = sinon.spy(wf as any, 'completeProgress'); + try { + await wf.run(); + } catch (e: any) { + expect(completeProgress.calledWith(false, 'read failed')).to.be.true; + expect(e.message).to.equal('read failed'); + } finally { + (fs.readFileSync as any).restore?.(); + } + }); + }); + describe('run method with audit fix for workflows with valid path and empty ctSchema', () => { const wf = new Workflows({ moduleName: 'workflows', @@ -111,6 +186,7 @@ describe('Workflows', () => { .stub(wf, 'WriteFileSync', () => {}) .stub(wf, 'writeFixContent', () => {}) .it('the run function should run and flow should go till fixWorkflowSchema', async () => { + wf.config.branch = 'development'; const fixedReference = await wf.run(); expect(fixedReference).eql([ { @@ -147,4 +223,186 @@ describe('Workflows', () => { ]); }); }); + + describe('fixWorkflowSchema', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .it('hits no branch configuration when config.branch is missing', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: cloneDeep(require('../mock/contents/workflows/ctSchema.json')), + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + branch: undefined, + }), + fix: true, + }); + wf.workflowPath = join(resolve(__dirname, '..', 'mock', 'contents'), 'workflows', 'workflows.json'); + wf.workflowSchema = values(JSON.parse(fs.readFileSync(wf.workflowPath, 'utf8'))); + wf.missingCts = new Set(['ct45', 'ct14']); + const writeStub = sinon.stub(wf, 'writeFixContent').resolves(); + await (wf as any).fixWorkflowSchema(); + expect(writeStub.called).to.be.true; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .it('deletes workflow when no valid content types and user confirms', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + wf.workflowSchema = [{ uid: 'orphan', name: 'Orphan', content_types: ['ct-missing'], branches: [] } as any]; + wf.missingCts = new Set(['ct-missing']); + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns(JSON.stringify({ orphan: { uid: 'orphan', name: 'Orphan', content_types: ['ct-missing'] } })); + const writeStub = sinon.stub(wf, 'writeFixContent').resolves(); + await (wf as any).fixWorkflowSchema(); + expect(writeStub.called).to.be.true; + const writeArg = writeStub.firstCall.args[0]; + expect(writeArg.orphan).to.be.undefined; + (fs.existsSync as any).restore?.(); + (fs.readFileSync as any).restore?.(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .it('fixWorkflowSchema asks for confirmation once when multiple workflows would be deleted', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + wf.workflowSchema = [ + { uid: 'orphan1', name: 'Orphan 1', content_types: ['ct-missing'], branches: [] } as any, + { uid: 'orphan2', name: 'Orphan 2', content_types: ['ct-missing'], branches: [] } as any, + ]; + wf.missingCts = new Set(['ct-missing']); + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns( + JSON.stringify({ + orphan1: { uid: 'orphan1', name: 'Orphan 1', content_types: ['ct-missing'] }, + orphan2: { uid: 'orphan2', name: 'Orphan 2', content_types: ['ct-missing'] }, + }), + ); + const confirmStub = sinon.stub(cliux, 'confirm').resolves(true); + sinon.stub(wf, 'writeFixContent').resolves(); + await (wf as any).fixWorkflowSchema(); + expect(confirmStub.callCount).to.equal(1); + (fs.existsSync as any).restore?.(); + (fs.readFileSync as any).restore?.(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => false) + .it('keeps workflow when no valid content types and user declines', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { basePath: resolve(__dirname, '..', 'mock', 'contents'), flags: {} }), + fix: true, + }); + wf.workflowSchema = [{ uid: 'keep', name: 'Keep', content_types: ['ct-missing'], branches: [] } as any]; + wf.missingCts = new Set(['ct-missing']); + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns(JSON.stringify({ keep: { uid: 'keep', name: 'Keep', content_types: ['ct-missing'] } })); + const writeStub = sinon.stub(wf, 'writeFixContent').resolves(); + await (wf as any).fixWorkflowSchema(); + expect(writeStub.called).to.be.true; + expect(writeStub.firstCall.args[0].keep).to.be.ok; + (fs.existsSync as any).restore?.(); + (fs.readFileSync as any).restore?.(); + }); + }); + + describe('writeFixContent', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(cliux, 'confirm', async () => true) + .it('writes file when fix true and user confirms', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + const writeStub = sinon.stub(fs, 'writeFileSync'); + await (wf as any).writeFixContent({ wf1: {} }); + expect(writeStub.called).to.be.true; + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('skips write when fix is false', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: false, + }); + const writeStub = sinon.stub(fs, 'writeFileSync'); + await (wf as any).writeFixContent({ wf1: {} }); + expect(writeStub.called).to.be.false; + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('writes when fix true and copy-dir flag set', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: { 'copy-dir': true }, + }), + fix: true, + }); + const writeStub = sinon.stub(fs, 'writeFileSync'); + await (wf as any).writeFixContent({ wf1: {} }); + expect(writeStub.called).to.be.true; + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('writeFixContent does not prompt when preConfirmed is true', async () => { + const wf = new Workflows({ + moduleName: 'workflows', + ctSchema: [], + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + }), + fix: true, + }); + const writeStub = sinon.stub(fs, 'writeFileSync'); + const confirmStub = sinon.stub(cliux, 'confirm'); + await (wf as any).writeFixContent({ wf1: {} }, true); + expect(writeStub.called).to.be.true; + expect(confirmStub.called).to.be.false; + writeStub.restore(); + }); + }); });