UNPKG

apiveritas

Version:

Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.

336 lines (286 loc) 12.3 kB
/** * @file PayloadComparer.ts * @author Mario Galea * @description * The PayloadComparer class compares JSON payload files between two snapshot folders * representing different states of API responses. It supports schema generation, * structural comparison, JSON Schema validation, and reports differences. * The comparison results are logged and an HTML report is generated. */ import fs from 'fs'; import path from 'path'; import generateSchema from 'generate-schema'; import { HtmlReporter } from './core/reporters/HtmlReporter'; import { BasicComparator } from './core/comparators/BasicComparator'; import { SchemaValidator } from './core/schema/SchemaValidator'; import { SchemaStrictifier } from './core/schema/SchemaStrictifier'; import { IComparerOptions } from './interfaces/IComparerOptions'; import { IFileComparisonResult } from './interfaces/IFileComparisonResult'; import { Logger } from './core/utils/Logger'; import chalk from 'chalk'; import { ConfigLoader } from './core/config/ConfigLoader'; /** * Compares JSON payloads between two folders and reports differences. * Supports strict schema validation, value strictness, empty response tolerance, * and mock server overrides via config. */ export class PayloadComparer { private payloadsDir: string; private comparator: BasicComparator; private options: IComparerOptions; private logger: Logger; private config: any; private configLoader: ConfigLoader; /** * Creates a new PayloadComparer instance. * @param {IComparerOptions} [options] - Options to control comparison behavior. * @param {boolean} [options.strictSchema=true] - Enforce strict JSON schema validation. * @param {boolean} [options.strictValues=true] - Enforce strict value matching. * @param {boolean} [options.tolerateEmptyResponses=false] - Allow empty or missing payloads without failing. * @param {Logger} [logger] - Optional logger instance to capture logs. */ constructor( testSuiteName: string, options: IComparerOptions = { strictSchema: true, strictValues: true, tolerateEmptyResponses: false, }, logger: Logger = new Logger() ) { this.configLoader = new ConfigLoader(); this.config = this.configLoader.loadConfig(); // Use custom or default payloads path const basePayloadsDir = this.config.payloadsPath || path.join(process.cwd(), 'apiveritas', 'payloads'); // Include test suite in the path this.payloadsDir = path.join(basePayloadsDir, testSuiteName); this.options = options; this.comparator = new BasicComparator(this.options.strictValues); this.logger = logger; logger.info(chalk.white.bold.underline('Payload Comparison:\n')); logger.info(`${chalk.white('Test Suite Folder:')} ${chalk.white(testSuiteName)}`); logger.info(`${chalk.white('Payload Directory:')} ${chalk.white(this.payloadsDir)}\n`); } /** * Retrieves the two most recent payload snapshot folders for comparison. * Folders are sorted by name descending (newest assumed last). * @returns {[string, string] | null} - Tuple of [previousFolder, latestFolder] or null if insufficient data. getLatestTwoPayloadFolders(): [string, string] | null { if (!fs.existsSync(this.payloadsDir)) { this.logger.warn('No payloads directory found.'); return null; } /** * Returns the names of the latest two timestamp folders which contain the given testSuite folder. * If no testSuite specified, returns the latest two timestamp folders. */ getLatestTwoPayloadFolders(testSuite?: string): [string, string] | null { if (!fs.existsSync(this.payloadsDir)) { this.logger.warn('No payloads directory found.'); return null; } // Read all timestamp folders sorted newest first const timestampFolders = fs .readdirSync(this.payloadsDir) .filter((file) => fs.statSync(path.join(this.payloadsDir, file)).isDirectory()) .sort() .reverse(); if (timestampFolders.length < 2) { this.logger.warn('Not enough payload folders to compare.'); return null; } // No testSuite specified, just return latest two timestamp folders return [timestampFolders[0], timestampFolders[1]]; } /** * Compares JSON payload files in two folders (optionally within a test suite subfolder). * Generates detailed logs, counts matched and differing files, validates schema, * and generates an HTML report. * @param {string} oldFolder - The folder name of the baseline payload snapshot. * @param {string} newFolder - The folder name of the new payload snapshot. * @param {string} [testSuite] - Optional test suite folder name within each snapshot folder. * If mock server is enabled in config, this will be overridden to 'mock'. * @returns {void} */ compareFolders( newFolder: string, oldFolder: string, testSuite?: string): boolean { if (this.config.enableMockServer) { testSuite = 'mock'; } const startTime = Date.now(); this.logger.info(`${chalk.white('Present Prod :')} ${chalk.white(oldFolder)}`); this.logger.info(`${chalk.white('Future Prod :')} ${chalk.white(newFolder)}`); if (testSuite) { this.logger.info(`${chalk.white('Test Suite :')} ${chalk.white(testSuite)}\n`); } else { this.logger.info(chalk.yellowBright('! No test suite specified. Comparing top-level payload files.\n')); } if (!testSuite) { this.logger.warn('No test suite specified. Cannot compare folders.'); return false; } const oldPath = path.join(this.payloadsDir, oldFolder); const newPath = path.join(this.payloadsDir, newFolder); if (!fs.existsSync(oldPath)) { this.logger.warn(`Old folder path does not exist: ${oldPath}`); return false; } if (!fs.existsSync(newPath)) { this.logger.warn(`New folder path does not exist: ${newPath}`); return false; } const files = fs.readdirSync(oldPath).filter((file) => file.endsWith('.json')); if (files.length === 0) { this.logger.warn('No payload files found in the old folder to compare.'); console.log(); this.logger.info(chalk.bgYellow.black('WARNING - NO FILES TO COMPARE')); return false; } let isDifferent = false; let matchedCount = 0; let diffCount = 0; const results: IFileComparisonResult[] = []; const validator = new SchemaValidator(); files.forEach((file) => { const oldFilePath = path.join(oldPath, file); const newFilePath = path.join(newPath, file); if (!fs.existsSync(newFilePath)) { isDifferent = true; diffCount++; results.push({ fileName: file, matched: false, differences: ['X File missing in new version'], oldContent: this.loadAndParseJson(oldFilePath), }); return; } const oldData = this.loadAndParseJson(oldFilePath); const newData = this.loadAndParseJson(newFilePath); const oldIsEmpty = oldData === null; const newIsEmpty = newData === null; if (this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) { matchedCount++; const msg = `! One or both payloads in ${file} are empty or missing. Ignored due to tolerateEmptyResponses=true.`; results.push({ fileName: file, matched: true, differences: [msg], oldContent: oldData, newContent: newData, }); return; } if (!this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) { isDifferent = true; diffCount++; const diffs = [ ...(oldIsEmpty ? ['X Old data is not a valid object. Got empty or missing.'] : []), ...(newIsEmpty ? ['X New data is not a valid object. Got empty or missing.'] : []), ]; results.push({ fileName: file, matched: false, differences: diffs, oldContent: oldData, newContent: newData, }); return; } const structuralDiffs = this.comparator.compare(oldData, newData); const ignoredDiffs = structuralDiffs.filter((d: string) => d.startsWith('IGNORED::')); const realDiffs = structuralDiffs.filter((d: string) => !d.startsWith('IGNORED::')); const schema = generateSchema.json('Response', oldData); if (this.options.strictSchema) { SchemaStrictifier.enforceNoAdditionalProperties(schema); } schema.$schema = 'http://json-schema.org/draft-07/schema#'; const isValid = validator.validate(schema, newData); const schemaErrors = isValid ? [] : validator.getErrors(); const actualDiffs = [...realDiffs, ...schemaErrors]; const matched = actualDiffs.length === 0; if (matched) { matchedCount++; } else { isDifferent = true; diffCount++; } results.push({ fileName: file, matched, differences: [...actualDiffs, ...ignoredDiffs], oldContent: oldData, newContent: newData, }); }); results.forEach((result) => { if (result.matched) { this.logger.info(chalk.green(`✓ ${result.fileName} matches`)); result.differences?.forEach((diff) => { if (diff.startsWith('IGNORED::')) { this.logger.warn(diff.replace(/^IGNORED::\s*/, '')); } }); } else { this.logger.error(`X Differences found in ${result.fileName}`); result.differences?.forEach((diff) => this.logger.error(diff)); } }); this.configLoader = new ConfigLoader(); // create ConfigLoader instance this.config = this.configLoader.loadConfig(); const maxKeyLength = Math.max(...Object.keys(this.config).map(key => key.length)); console.log(); if (!isDifferent) { this.logger.info(chalk.bgGreen.bold('SUCCESS - ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG')); } else { this.logger.info(chalk.bgRed('FAILURE - NOT ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG')); } Object.entries(this.config).forEach(([key, value]) => { const paddedKey = key.padEnd(maxKeyLength, ' '); this.logger.info(`${chalk.white(paddedKey)} : ${chalk.white.bold(String(value))}`); }); const endTime = Date.now(); const elapsedSeconds = ((endTime - startTime) / 1000).toFixed(2); const summaryParts = [ `${matchedCount} matched`, diffCount > 0 ? `${diffCount} differed` : null, `${files.length} total files`, `in ${elapsedSeconds}s`, ].filter(Boolean); this.logger.info(chalk.white('\n ' + summaryParts.join(' | ') + '\n')); const reporter = new HtmlReporter(); reporter.generateReport(oldFolder, newFolder, results); return isDifferent; } /** * Loads and parses a JSON file safely. * Returns null if the file is empty, null, or invalid JSON. * @param {string} filePath - Path to the JSON file. * @returns {any | null} Parsed JSON object or null if empty/invalid. */ private loadAndParseJson(filePath: string): any | null { try { const raw = fs.readFileSync(filePath, 'utf-8').trim(); if (!raw || raw === '""' || raw === 'null') { return null; } return JSON.parse(raw); } catch (err: unknown) { const error = err as Error; this.logger.warn(`! Failed to parse JSON at ${filePath}: ${error.message}`); return null; } } /** * Logs a formatted header block with a title, a separator line, and a message. * @param {string} title - Title text for the header. * @param {string} message - Message text under the title. */ private logHeader(title: string, message: string): void { const separator = '-'.repeat(72); this.logger.info(''); this.logger.info(chalk.cyan(title)); this.logger.info(separator); this.logger.info(message); this.logger.info(''); } }