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:
parent
1eef4d227e
commit
878e9e44aa
|
|
@ -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`;
|
||||
};
|
||||
Loading…
Reference in New Issue