← back to Watches

utils/validator.js

156 lines

/**
 * Data Validation Utilities
 * Validates watch data against schema
 */

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export class WatchDataValidator {
  constructor() {
    this.schema = JSON.parse(
      fs.readFileSync(path.join(__dirname, '../schema/watch-schema.json'), 'utf8')
    );
    this.errors = [];
  }

  validate(data) {
    this.errors = [];

    // Check required top-level properties
    if (!data.watches || !Array.isArray(data.watches)) {
      this.errors.push('Missing or invalid "watches" array');
      return false;
    }

    if (!data.metadata || typeof data.metadata !== 'object') {
      this.errors.push('Missing or invalid "metadata" object');
      return false;
    }

    // Validate each watch
    data.watches.forEach((watch, index) => {
      this.validateWatch(watch, index);
    });

    // Validate metadata
    this.validateMetadata(data.metadata, data.watches.length);

    return this.errors.length === 0;
  }

  validateWatch(watch, index) {
    const requiredFields = ['id', 'model', 'series', 'reference', 'yearIntroduced', 'description', 'priceHistory', 'specifications'];

    requiredFields.forEach(field => {
      if (!watch[field]) {
        this.errors.push(`Watch #${index}: Missing required field "${field}"`);
      }
    });

    // Validate ID format
    if (watch.id && !/^[a-z0-9-]+$/.test(watch.id)) {
      this.errors.push(`Watch #${index} (${watch.id}): Invalid ID format - must be lowercase alphanumeric with hyphens`);
    }

    // Validate year
    if (watch.yearIntroduced && (watch.yearIntroduced < 1848 || watch.yearIntroduced > 2100)) {
      this.errors.push(`Watch #${index} (${watch.id}): Invalid year ${watch.yearIntroduced}`);
    }

    // Validate price history
    if (watch.priceHistory && Array.isArray(watch.priceHistory)) {
      if (watch.priceHistory.length === 0) {
        this.errors.push(`Watch #${index} (${watch.id}): Price history is empty`);
      }

      watch.priceHistory.forEach((price, priceIndex) => {
        if (!price.year || !price.price || !price.condition || !price.source) {
          this.errors.push(`Watch #${index} (${watch.id}): Price history #${priceIndex} missing required fields`);
        }

        if (price.price < 0) {
          this.errors.push(`Watch #${index} (${watch.id}): Negative price in history #${priceIndex}`);
        }

        if (!['new', 'vintage', 'prototype'].includes(price.condition)) {
          this.errors.push(`Watch #${index} (${watch.id}): Invalid condition "${price.condition}" in history #${priceIndex}`);
        }
      });

      // Check chronological order
      for (let i = 1; i < watch.priceHistory.length; i++) {
        if (watch.priceHistory[i].year < watch.priceHistory[i - 1].year) {
          this.errors.push(`Watch #${index} (${watch.id}): Price history not in chronological order`);
          break;
        }
      }
    }

    // Validate specifications
    if (watch.specifications) {
      const requiredSpecs = ['caseSize', 'caseMaterial', 'movement'];
      requiredSpecs.forEach(spec => {
        if (!watch.specifications[spec]) {
          this.errors.push(`Watch #${index} (${watch.id}): Missing specification "${spec}"`);
        }
      });
    }
  }

  validateMetadata(metadata, watchCount) {
    if (metadata.totalWatches !== watchCount) {
      this.errors.push(`Metadata totalWatches (${metadata.totalWatches}) doesn't match actual count (${watchCount})`);
    }

    if (!metadata.collections || !Array.isArray(metadata.collections)) {
      this.errors.push('Metadata missing collections array');
    }

    if (!metadata.lastUpdated) {
      this.errors.push('Metadata missing lastUpdated field');
    }
  }

  getErrors() {
    return this.errors;
  }

  getReport() {
    return {
      valid: this.errors.length === 0,
      errorCount: this.errors.length,
      errors: this.errors
    };
  }
}

// CLI usage
if (import.meta.url === `file://${process.argv[1]}`) {
  const dataPath = process.argv[2] || './data/watches.json';
  const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));

  const validator = new WatchDataValidator();
  const isValid = validator.validate(data);

  console.log('\n=== Watch Data Validation Report ===\n');

  if (isValid) {
    console.log('✓ Validation passed!');
    console.log(`✓ ${data.watches.length} watches validated successfully`);
  } else {
    console.log('✗ Validation failed!\n');
    console.log('Errors found:');
    validator.getErrors().forEach((error, index) => {
      console.log(`  ${index + 1}. ${error}`);
    });
  }

  console.log();
  process.exit(isValid ? 0 : 1);
}