feat(13-01): implement attachment validation service

GREEN phase - TDD cycle
- validateFileSize: checks 10 MB limit, customizable
- validateFileType: extension whitelist validation
- getAttachmentType: audio vs notation detection
- validateFile: combined validation with backend warnings
- formatFileSize: human-readable display (B/KB/MB)
- All constants exported for component usage

Implementation follows REACT_INTEGRATION_DESIGN.md Section 7
All 37 tests passing (100% coverage)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-02-05 10:59:03 +05:30
parent 1eef4d227e
commit 878e9e44aa
1 changed files with 127 additions and 0 deletions

View File

@ -0,0 +1,127 @@
/**
* Attachment Validation Service
*
* Client-side file validation for session attachment uploads
* - Validates file size (10 MB limit)
* - Validates file type (extension whitelist)
* - Determines attachment type (audio vs notation)
* - Formats file sizes for display
*/
/**
* Maximum file size: 10 MB
* Matches legacy client-side validation
*/
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB in bytes
/**
* Allowed file extensions
* Note: .mp3 support pending backend decision (see Phase 12 docs)
*/
export const ALLOWED_EXTENSIONS = [
'.pdf', '.xml', '.mxl', '.txt', // Notation
'.png', '.jpg', '.jpeg', '.gif', // Images
'.mp3', '.wav' // Audio (pending backend mp3 support)
];
/**
* Backend-supported extensions (from MusicNotationUploader)
* Used for warning messages if frontend allows extensions backend doesn't
*/
export const BACKEND_SUPPORTED_EXTENSIONS = [
'.pdf', '.xml', '.mxl', '.txt',
'.png', '.jpg', '.jpeg', '.gif',
'.wav', '.flac', '.ogg', '.aiff', '.aifc', '.au'
];
/**
* Audio file extensions (for attachment_type determination)
*/
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.flac', '.ogg', '.aiff', '.aifc', '.au'];
/**
* Validate file size
* @param {File} file - File object from input
* @param {number} [maxSizeBytes=MAX_FILE_SIZE] - Maximum allowed size
* @returns {Object} { valid: boolean, error: string|null }
*/
export const validateFileSize = (file, maxSizeBytes = MAX_FILE_SIZE) => {
if (file.size > maxSizeBytes) {
const maxSizeMB = Math.floor(maxSizeBytes / (1024 * 1024));
return {
valid: false,
error: `File exceeds ${maxSizeMB} MB limit`
};
}
return { valid: true, error: null };
};
/**
* Validate file type by extension
* @param {File} file - File object from input
* @param {string[]} [allowedTypes=ALLOWED_EXTENSIONS] - Array of allowed extensions
* @returns {Object} { valid: boolean, error: string|null }
*/
export const validateFileType = (file, allowedTypes = ALLOWED_EXTENSIONS) => {
const fileName = file.name.toLowerCase();
const hasAllowedExtension = allowedTypes.some(ext => fileName.endsWith(ext));
if (!hasAllowedExtension) {
return {
valid: false,
error: `File type not allowed. Supported: ${allowedTypes.join(', ')}`
};
}
return { valid: true, error: null };
};
/**
* Get attachment type from filename extension
* @param {string} filename - File name with extension
* @returns {string} 'notation' | 'audio'
*/
export const getAttachmentType = (filename) => {
const ext = '.' + filename.split('.').pop().toLowerCase();
return AUDIO_EXTENSIONS.includes(ext) ? 'audio' : 'notation';
};
/**
* Comprehensive file validation (size + type)
* @param {File} file - File object from input
* @returns {Object} { valid: boolean, error: string|null, warnings: string[] }
*/
export const validateFile = (file) => {
const warnings = [];
// Validate size first (fail fast)
const sizeValidation = validateFileSize(file);
if (!sizeValidation.valid) {
return { valid: false, error: sizeValidation.error, warnings };
}
// Validate type
const typeValidation = validateFileType(file);
if (!typeValidation.valid) {
return { valid: false, error: typeValidation.error, warnings };
}
// Check if backend supports this type (warning only)
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!BACKEND_SUPPORTED_EXTENSIONS.includes(ext)) {
warnings.push(`File type ${ext} may not be supported by server`);
}
return { valid: true, error: null, warnings };
};
/**
* Format file size for display
* @param {number} bytes - File size in bytes
* @returns {string} Formatted size (e.g., "2.5 MB")
*/
export const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};