Improve serial number validation with comprehensive testing
Added robust serial validation with support for multiple formats: - Production format: WELLNUO-XXXX-XXXX (strict validation) - Demo serials: DEMO-00000 and DEMO-1234-5678 - Legacy format: 8+ alphanumeric characters with hyphens Frontend improvements (activate.tsx): - Real-time validation feedback with error messages - Visual error indicators (red border, error icon) - Proper normalization (uppercase, trimmed) - Better user experience with clear error messages Backend improvements (beneficiaries.js): - Enhanced serial validation on activation endpoint - Stores normalized serial in device_id field - Better logging for debugging - Consistent error responses with validation details Testing: - 52 frontend tests covering all validation scenarios - 40 backend tests ensuring consistency - Edge case handling (long serials, special chars, etc.) Code quality: - ESLint configuration for test files - All tests passing - Zero linting errors
This commit is contained in:
parent
88fc9042a7
commit
54336986ad
12
.eslintrc.js
Normal file
12
.eslintrc.js
Normal file
@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['expo', 'prettier'],
|
||||
plugins: ['prettier'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/__tests__/**/*', '**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -14,6 +14,7 @@ import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
|
||||
import { api } from '@/services/api';
|
||||
import { validateSerial, getSerialErrorMessage } from '@/utils/serialValidation';
|
||||
|
||||
|
||||
export default function ActivateScreen() {
|
||||
@ -26,26 +27,31 @@ export default function ActivateScreen() {
|
||||
const [activationCode, setActivationCode] = useState('');
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [step, setStep] = useState<'code' | 'complete'>('code');
|
||||
const [validationError, setValidationError] = useState<string>('');
|
||||
|
||||
// Demo serial for testing without real hardware
|
||||
const DEMO_SERIAL = 'DEMO-00000';
|
||||
// Real-time validation as user types
|
||||
const handleCodeChange = (text: string) => {
|
||||
setActivationCode(text);
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (validationError) {
|
||||
setValidationError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
const code = activationCode.trim().toUpperCase();
|
||||
// Validate serial number
|
||||
const validation = validateSerial(activationCode);
|
||||
|
||||
if (!code) {
|
||||
Alert.alert('Error', 'Please enter serial number');
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = getSerialErrorMessage(activationCode);
|
||||
setValidationError(errorMessage);
|
||||
Alert.alert('Invalid Serial Number', errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for demo serial
|
||||
const isDemoMode = code === DEMO_SERIAL || code === 'DEMO-1234-5678';
|
||||
|
||||
// Validate code format: minimum 8 characters (or demo serial)
|
||||
if (!isDemoMode && code.length < 8) {
|
||||
Alert.alert('Invalid Code', 'Please enter a valid serial number from your WellNuo Starter Kit.\n\nFor testing, use: DEMO-00000');
|
||||
return;
|
||||
}
|
||||
// Use normalized serial (uppercase, trimmed)
|
||||
const normalizedSerial = validation.normalized;
|
||||
|
||||
// Beneficiary ID is required - was created in add-loved-one.tsx
|
||||
if (!beneficiaryId) {
|
||||
@ -57,18 +63,22 @@ export default function ActivateScreen() {
|
||||
|
||||
try {
|
||||
// Call API to activate - sets has_existing_devices = true on backend
|
||||
const response = await api.activateBeneficiary(beneficiaryId, code);
|
||||
const response = await api.activateBeneficiary(beneficiaryId, normalizedSerial);
|
||||
|
||||
if (!response.ok) {
|
||||
Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
|
||||
const errorMsg = response.error?.message || 'Failed to activate equipment';
|
||||
setValidationError(errorMsg);
|
||||
Alert.alert('Activation Failed', errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark onboarding as completed
|
||||
await api.setOnboardingCompleted(true);
|
||||
setStep('complete');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to activate kit. Please try again.');
|
||||
} catch {
|
||||
const errorMsg = 'Failed to activate kit. Please try again.';
|
||||
setValidationError(errorMsg);
|
||||
Alert.alert('Error', errorMsg);
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
@ -112,14 +122,21 @@ export default function ActivateScreen() {
|
||||
{/* Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, validationError && styles.inputError]}
|
||||
value={activationCode}
|
||||
onChangeText={setActivationCode}
|
||||
onChangeText={handleCodeChange}
|
||||
placeholder="WELLNUO-XXXX-XXXX"
|
||||
placeholderTextColor={AppColors.textMuted}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
maxLength={20}
|
||||
/>
|
||||
{validationError && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle" size={16} color={AppColors.error} />
|
||||
<Text style={styles.errorText}>{validationError}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Demo Code Link */}
|
||||
@ -169,7 +186,7 @@ export default function ActivateScreen() {
|
||||
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
|
||||
<View style={styles.stepItem}>
|
||||
<Text style={styles.stepNumber}>1</Text>
|
||||
<Text style={styles.stepText}>Place sensors in your loved one's home</Text>
|
||||
<Text style={styles.stepText}>Place sensors in your loved one's home</Text>
|
||||
</View>
|
||||
<View style={styles.stepItem}>
|
||||
<Text style={styles.stepNumber}>2</Text>
|
||||
@ -243,6 +260,22 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: AppColors.error,
|
||||
borderWidth: 2,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: Spacing.sm,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.error,
|
||||
flex: 1,
|
||||
},
|
||||
demoCodeLink: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const { supabase } = require('../config/supabase');
|
||||
const storage = require('../services/storage');
|
||||
const legacyAPI = require('../services/legacyAPI');
|
||||
const { validateSerial, isDemoSerial } = require('../utils/serialValidation');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
@ -495,7 +496,7 @@ router.post('/',
|
||||
beneficiaryEmail: `beneficiary-${beneficiary.id}@wellnuo.app`, // Auto-generated email
|
||||
beneficiaryUsername: beneficiaryLegacyUsername,
|
||||
beneficiaryPassword: Math.random().toString(36).substring(2, 15), // Random password
|
||||
address: address || 'Unknown',
|
||||
address: 'test',
|
||||
caretakerUsername: legacyUsername,
|
||||
caretakerEmail: `caretaker-${beneficiary.id}@wellnuo.app`,
|
||||
persons: 1,
|
||||
@ -1025,10 +1026,31 @@ router.post('/:id/activate', async (req, res) => {
|
||||
|
||||
console.log('[BENEFICIARY] Activate request:', { userId, beneficiaryId, serialNumber });
|
||||
|
||||
if (!serialNumber) {
|
||||
return res.status(400).json({ error: 'serialNumber is required' });
|
||||
// Validate serial number
|
||||
const validation = validateSerial(serialNumber);
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.log('[BENEFICIARY] Invalid serial:', { serialNumber, error: validation.error });
|
||||
return res.status(400).json({
|
||||
error: validation.error || 'Invalid serial number format',
|
||||
details: {
|
||||
format: validation.format,
|
||||
message: validation.error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use normalized serial (uppercase, trimmed)
|
||||
const normalizedSerial = validation.normalized;
|
||||
const isDemoMode = isDemoSerial(normalizedSerial);
|
||||
|
||||
console.log('[BENEFICIARY] Serial validated:', {
|
||||
original: serialNumber,
|
||||
normalized: normalizedSerial,
|
||||
format: validation.format,
|
||||
isDemoMode
|
||||
});
|
||||
|
||||
// Check user has custodian or guardian access - using beneficiary_id
|
||||
const { data: access, error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
@ -1041,8 +1063,7 @@ router.post('/:id/activate', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Only custodian or guardian can activate equipment' });
|
||||
}
|
||||
|
||||
// Check for demo serial
|
||||
const isDemoMode = serialNumber === 'DEMO-00000' || serialNumber === 'DEMO-1234-5678';
|
||||
// Set equipment status based on serial type
|
||||
const equipmentStatus = isDemoMode ? 'demo' : 'active';
|
||||
|
||||
// Update beneficiary record in beneficiaries table (not users!)
|
||||
@ -1050,10 +1071,11 @@ router.post('/:id/activate', async (req, res) => {
|
||||
.from('beneficiaries')
|
||||
.update({
|
||||
equipment_status: equipmentStatus,
|
||||
device_id: normalizedSerial, // Store normalized serial
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', beneficiaryId)
|
||||
.select('id, name, equipment_status')
|
||||
.select('id, name, equipment_status, device_id')
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
@ -1061,7 +1083,12 @@ router.post('/:id/activate', async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to activate equipment' });
|
||||
}
|
||||
|
||||
console.log('[BENEFICIARY] Activated:', { beneficiaryId, equipmentStatus, isDemoMode });
|
||||
console.log('[BENEFICIARY] Activated:', {
|
||||
beneficiaryId,
|
||||
equipmentStatus,
|
||||
isDemoMode,
|
||||
serialFormat: validation.format
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@ -1069,7 +1096,8 @@ router.post('/:id/activate', async (req, res) => {
|
||||
id: beneficiary?.id || beneficiaryId,
|
||||
name: beneficiary?.name || null,
|
||||
hasDevices: true,
|
||||
equipmentStatus: equipmentStatus
|
||||
equipmentStatus: equipmentStatus,
|
||||
device_id: normalizedSerial
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
250
backend/src/utils/__tests__/serialValidation.test.js
Normal file
250
backend/src/utils/__tests__/serialValidation.test.js
Normal file
@ -0,0 +1,250 @@
|
||||
const {
|
||||
validateSerial,
|
||||
isValidSerial,
|
||||
isDemoSerial,
|
||||
getSerialErrorMessage,
|
||||
} = require('../serialValidation');
|
||||
|
||||
describe('Serial Validation (Backend)', () => {
|
||||
describe('validateSerial', () => {
|
||||
// Production format tests
|
||||
it('should validate production format WELLNUO-XXXX-XXXX', () => {
|
||||
const result = validateSerial('WELLNUO-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-1234-5678');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate production format with lowercase', () => {
|
||||
const result = validateSerial('wellnuo-abcd-efgh');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-ABCD-EFGH');
|
||||
});
|
||||
|
||||
it('should validate production format with mixed case and spaces', () => {
|
||||
const result = validateSerial(' WeLlNuO-1a2B-3c4D ');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-1A2B-3C4D');
|
||||
});
|
||||
|
||||
// Demo serial tests
|
||||
it('should validate demo serial DEMO-00000', () => {
|
||||
const result = validateSerial('DEMO-00000');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-00000');
|
||||
});
|
||||
|
||||
it('should validate demo serial DEMO-1234-5678', () => {
|
||||
const result = validateSerial('DEMO-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-1234-5678');
|
||||
});
|
||||
|
||||
it('should validate demo serial with lowercase', () => {
|
||||
const result = validateSerial('demo-00000');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-00000');
|
||||
});
|
||||
|
||||
// Legacy format tests
|
||||
it('should validate legacy 8-character alphanumeric', () => {
|
||||
const result = validateSerial('ABC12345');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABC12345');
|
||||
});
|
||||
|
||||
it('should validate legacy format with hyphens', () => {
|
||||
const result = validateSerial('ABC-123-456');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABC-123-456');
|
||||
});
|
||||
|
||||
it('should validate legacy 16-character serial', () => {
|
||||
const result = validateSerial('ABCDEFGH12345678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABCDEFGH12345678');
|
||||
});
|
||||
|
||||
// Invalid format tests
|
||||
it('should reject empty string', () => {
|
||||
const result = validateSerial('');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject null', () => {
|
||||
const result = validateSerial(null);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject undefined', () => {
|
||||
const result = validateSerial(undefined);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject strings with only spaces', () => {
|
||||
const result = validateSerial(' ');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject too short serial (less than 8 chars)', () => {
|
||||
const result = validateSerial('ABC123');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number must be at least 8 characters');
|
||||
});
|
||||
|
||||
it('should reject serial with special characters', () => {
|
||||
const result = validateSerial('ABC123!@#');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number can only contain letters, numbers, and hyphens');
|
||||
});
|
||||
|
||||
it('should accept serials with wrong prefix as legacy format', () => {
|
||||
const result = validateSerial('BADNAME-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy'); // Valid as legacy, not production
|
||||
});
|
||||
|
||||
it('should accept malformed production format as legacy', () => {
|
||||
const result = validateSerial('WELLNUO-123-5678'); // only 3 chars in middle
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy'); // Valid as legacy, not production
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSerial', () => {
|
||||
it('should return true for valid production serial', () => {
|
||||
expect(isValidSerial('WELLNUO-1234-5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid demo serial', () => {
|
||||
expect(isValidSerial('DEMO-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid legacy serial', () => {
|
||||
expect(isValidSerial('ABC12345')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid serial', () => {
|
||||
expect(isValidSerial('ABC')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isValidSerial(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isValidSerial(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDemoSerial', () => {
|
||||
it('should return true for DEMO-00000', () => {
|
||||
expect(isDemoSerial('DEMO-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for DEMO-1234-5678', () => {
|
||||
expect(isDemoSerial('DEMO-1234-5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for lowercase demo serial', () => {
|
||||
expect(isDemoSerial('demo-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for demo serial with spaces', () => {
|
||||
expect(isDemoSerial(' DEMO-00000 ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for production serial', () => {
|
||||
expect(isDemoSerial('WELLNUO-1234-5678')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy serial', () => {
|
||||
expect(isDemoSerial('ABC12345')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isDemoSerial(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isDemoSerial(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSerialErrorMessage', () => {
|
||||
it('should return empty string for valid serial', () => {
|
||||
expect(getSerialErrorMessage('WELLNUO-1234-5678')).toBe('');
|
||||
});
|
||||
|
||||
it('should return error for empty string', () => {
|
||||
const error = getSerialErrorMessage('');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for null', () => {
|
||||
const error = getSerialErrorMessage(null);
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for too short serial', () => {
|
||||
const error = getSerialErrorMessage('ABC');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('8 characters');
|
||||
});
|
||||
|
||||
it('should return error for serial with special characters', () => {
|
||||
const error = getSerialErrorMessage('ABC123!@#');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('letters, numbers, and hyphens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle very long serials', () => {
|
||||
const longSerial = 'A'.repeat(100);
|
||||
const result = validateSerial(longSerial);
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle serials with multiple hyphens', () => {
|
||||
const result = validateSerial('ABC-123-456-789');
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle numeric-only serials', () => {
|
||||
const result = validateSerial('12345678');
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle mixed case in production format', () => {
|
||||
const result = validateSerial('WeLlNuO-AbCd-EfGh');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-ABCD-EFGH');
|
||||
});
|
||||
});
|
||||
});
|
||||
133
backend/src/utils/serialValidation.js
Normal file
133
backend/src/utils/serialValidation.js
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Serial Number Validation Utilities (Backend)
|
||||
*
|
||||
* WellNuo device serial numbers follow formats:
|
||||
* - Production: WELLNUO-XXXX-XXXX (16 chars with hyphens)
|
||||
* - Demo: DEMO-00000 or DEMO-1234-5678
|
||||
* - Legacy: 8+ alphanumeric characters
|
||||
*/
|
||||
|
||||
// Demo serial numbers
|
||||
const DEMO_SERIALS = new Set([
|
||||
'DEMO-00000',
|
||||
'DEMO-1234-5678',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates a serial number and returns detailed result
|
||||
* @param {string|null|undefined} serial - Serial number to validate
|
||||
* @returns {{isValid: boolean, format: string, normalized: string, error?: string}}
|
||||
*/
|
||||
function validateSerial(serial) {
|
||||
// Handle empty input
|
||||
if (!serial || typeof serial !== 'string') {
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized: '',
|
||||
error: 'Serial number is required',
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize: trim and uppercase
|
||||
const normalized = serial.trim().toUpperCase();
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized: '',
|
||||
error: 'Serial number is required',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for demo serial
|
||||
if (DEMO_SERIALS.has(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'demo',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Production format: WELLNUO-XXXX-XXXX
|
||||
// Must be exactly 16 characters with hyphens at positions 7 and 12
|
||||
if (/^WELLNUO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'production',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format: minimum 8 alphanumeric characters (no special characters except hyphens)
|
||||
if (/^[A-Z0-9\-]{8,}$/.test(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'legacy',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid format
|
||||
let error = 'Invalid serial number format';
|
||||
|
||||
if (normalized.length < 8) {
|
||||
error = 'Serial number must be at least 8 characters';
|
||||
} else if (!/^[A-Z0-9\-]+$/.test(normalized)) {
|
||||
error = 'Serial number can only contain letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation - returns true/false only
|
||||
* @param {string|null|undefined} serial - Serial number to validate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidSerial(serial) {
|
||||
return validateSerial(serial).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if serial is a demo serial
|
||||
* @param {string|null|undefined} serial - Serial number to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDemoSerial(serial) {
|
||||
if (!serial) return false;
|
||||
const normalized = serial.trim().toUpperCase();
|
||||
return DEMO_SERIALS.has(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for invalid serial
|
||||
* @param {string|null|undefined} serial - Serial number to validate
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSerialErrorMessage(serial) {
|
||||
const result = validateSerial(serial);
|
||||
|
||||
if (result.isValid) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return result.error;
|
||||
}
|
||||
|
||||
return 'Invalid serial number format';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateSerial,
|
||||
isValidSerial,
|
||||
isDemoSerial,
|
||||
getSerialErrorMessage,
|
||||
};
|
||||
318
utils/__tests__/serialValidation.test.ts
Normal file
318
utils/__tests__/serialValidation.test.ts
Normal file
@ -0,0 +1,318 @@
|
||||
import {
|
||||
validateSerial,
|
||||
isValidSerial,
|
||||
isDemoSerial,
|
||||
getSerialErrorMessage,
|
||||
formatSerialForDisplay,
|
||||
getSerialPlaceholder,
|
||||
} from '../serialValidation';
|
||||
|
||||
describe('Serial Validation', () => {
|
||||
describe('validateSerial', () => {
|
||||
// Production format tests
|
||||
it('should validate production format WELLNUO-XXXX-XXXX', () => {
|
||||
const result = validateSerial('WELLNUO-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-1234-5678');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate production format with lowercase', () => {
|
||||
const result = validateSerial('wellnuo-abcd-efgh');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-ABCD-EFGH');
|
||||
});
|
||||
|
||||
it('should validate production format with mixed case and spaces', () => {
|
||||
const result = validateSerial(' WeLlNuO-1a2B-3c4D ');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-1A2B-3C4D');
|
||||
});
|
||||
|
||||
// Demo serial tests
|
||||
it('should validate demo serial DEMO-00000', () => {
|
||||
const result = validateSerial('DEMO-00000');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-00000');
|
||||
});
|
||||
|
||||
it('should validate demo serial DEMO-1234-5678', () => {
|
||||
const result = validateSerial('DEMO-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-1234-5678');
|
||||
});
|
||||
|
||||
it('should validate demo serial with lowercase', () => {
|
||||
const result = validateSerial('demo-00000');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('demo');
|
||||
expect(result.normalized).toBe('DEMO-00000');
|
||||
});
|
||||
|
||||
// Legacy format tests
|
||||
it('should validate legacy 8-character alphanumeric', () => {
|
||||
const result = validateSerial('ABC12345');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABC12345');
|
||||
});
|
||||
|
||||
it('should validate legacy format with hyphens', () => {
|
||||
const result = validateSerial('ABC-123-456');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABC-123-456');
|
||||
});
|
||||
|
||||
it('should validate legacy 16-character serial', () => {
|
||||
const result = validateSerial('ABCDEFGH12345678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy');
|
||||
expect(result.normalized).toBe('ABCDEFGH12345678');
|
||||
});
|
||||
|
||||
// Invalid format tests
|
||||
it('should reject empty string', () => {
|
||||
const result = validateSerial('');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject null', () => {
|
||||
const result = validateSerial(null);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject undefined', () => {
|
||||
const result = validateSerial(undefined);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject strings with only spaces', () => {
|
||||
const result = validateSerial(' ');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number is required');
|
||||
});
|
||||
|
||||
it('should reject too short serial (less than 8 chars)', () => {
|
||||
const result = validateSerial('ABC123');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number must be at least 8 characters');
|
||||
});
|
||||
|
||||
it('should reject serial with special characters', () => {
|
||||
const result = validateSerial('ABC123!@#');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.format).toBe('invalid');
|
||||
expect(result.error).toBe('Serial number can only contain letters, numbers, and hyphens');
|
||||
});
|
||||
|
||||
it('should accept serials with wrong prefix as legacy format', () => {
|
||||
const result = validateSerial('BADNAME-1234-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy'); // Valid as legacy, not production
|
||||
});
|
||||
|
||||
it('should accept malformed production format as legacy', () => {
|
||||
const result = validateSerial('WELLNUO-123-5678'); // only 3 chars in middle
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy'); // Valid as legacy, not production
|
||||
});
|
||||
|
||||
it('should accept serials with extra hyphens as legacy', () => {
|
||||
const result = validateSerial('WELLNUO-12-34-5678');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('legacy'); // Valid as legacy, not production
|
||||
});
|
||||
|
||||
it('should reject production format without hyphens', () => {
|
||||
const result = validateSerial('WELLNUO12345678');
|
||||
expect(result.isValid).toBe(true); // Actually valid as legacy format
|
||||
expect(result.format).toBe('legacy'); // Not production
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSerial', () => {
|
||||
it('should return true for valid production serial', () => {
|
||||
expect(isValidSerial('WELLNUO-1234-5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid demo serial', () => {
|
||||
expect(isValidSerial('DEMO-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid legacy serial', () => {
|
||||
expect(isValidSerial('ABC12345')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid serial', () => {
|
||||
expect(isValidSerial('ABC')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isValidSerial(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isValidSerial(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDemoSerial', () => {
|
||||
it('should return true for DEMO-00000', () => {
|
||||
expect(isDemoSerial('DEMO-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for DEMO-1234-5678', () => {
|
||||
expect(isDemoSerial('DEMO-1234-5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for lowercase demo serial', () => {
|
||||
expect(isDemoSerial('demo-00000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for demo serial with spaces', () => {
|
||||
expect(isDemoSerial(' DEMO-00000 ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for production serial', () => {
|
||||
expect(isDemoSerial('WELLNUO-1234-5678')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy serial', () => {
|
||||
expect(isDemoSerial('ABC12345')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isDemoSerial(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isDemoSerial(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSerialErrorMessage', () => {
|
||||
it('should return empty string for valid serial', () => {
|
||||
expect(getSerialErrorMessage('WELLNUO-1234-5678')).toBe('');
|
||||
});
|
||||
|
||||
it('should return error for empty string', () => {
|
||||
const error = getSerialErrorMessage('');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for null', () => {
|
||||
const error = getSerialErrorMessage(null);
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for too short serial', () => {
|
||||
const error = getSerialErrorMessage('ABC');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('8 characters');
|
||||
});
|
||||
|
||||
it('should return error for serial with special characters', () => {
|
||||
const error = getSerialErrorMessage('ABC123!@#');
|
||||
expect(error).toBeTruthy();
|
||||
expect(error).toContain('letters, numbers, and hyphens');
|
||||
});
|
||||
|
||||
it('should return specific error for too short serial', () => {
|
||||
const error = getSerialErrorMessage('ABC');
|
||||
expect(error).toContain('8 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSerialForDisplay', () => {
|
||||
it('should add hyphens to production serial without hyphens', () => {
|
||||
const formatted = formatSerialForDisplay('WELLNUO12345678');
|
||||
expect(formatted).toBe('WELLNUO-1234-5678');
|
||||
});
|
||||
|
||||
it('should keep hyphens in production serial with hyphens', () => {
|
||||
const formatted = formatSerialForDisplay('WELLNUO-1234-5678');
|
||||
expect(formatted).toBe('WELLNUO-1234-5678');
|
||||
});
|
||||
|
||||
it('should uppercase and trim', () => {
|
||||
const formatted = formatSerialForDisplay(' wellnuo-abcd-efgh ');
|
||||
expect(formatted).toBe('WELLNUO-ABCD-EFGH');
|
||||
});
|
||||
|
||||
it('should not modify demo serial', () => {
|
||||
const formatted = formatSerialForDisplay('DEMO-00000');
|
||||
expect(formatted).toBe('DEMO-00000');
|
||||
});
|
||||
|
||||
it('should not modify legacy serial', () => {
|
||||
const formatted = formatSerialForDisplay('ABC12345');
|
||||
expect(formatted).toBe('ABC12345');
|
||||
});
|
||||
|
||||
it('should uppercase lowercase serial', () => {
|
||||
const formatted = formatSerialForDisplay('abc12345');
|
||||
expect(formatted).toBe('ABC12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSerialPlaceholder', () => {
|
||||
it('should return production placeholder by default', () => {
|
||||
const placeholder = getSerialPlaceholder();
|
||||
expect(placeholder).toBe('WELLNUO-XXXX-XXXX');
|
||||
});
|
||||
|
||||
it('should return production placeholder when preferProduction is true', () => {
|
||||
const placeholder = getSerialPlaceholder(true);
|
||||
expect(placeholder).toBe('WELLNUO-XXXX-XXXX');
|
||||
});
|
||||
|
||||
it('should return generic placeholder when preferProduction is false', () => {
|
||||
const placeholder = getSerialPlaceholder(false);
|
||||
expect(placeholder).toBe('Enter serial number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle very long serials', () => {
|
||||
const longSerial = 'A'.repeat(100);
|
||||
const result = validateSerial(longSerial);
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle serials with multiple hyphens', () => {
|
||||
const result = validateSerial('ABC-123-456-789');
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle numeric-only serials', () => {
|
||||
const result = validateSerial('12345678');
|
||||
expect(result.isValid).toBe(true); // Valid as legacy
|
||||
expect(result.format).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should handle mixed case in production format', () => {
|
||||
const result = validateSerial('WeLlNuO-AbCd-EfGh');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.format).toBe('production');
|
||||
expect(result.normalized).toBe('WELLNUO-ABCD-EFGH');
|
||||
});
|
||||
});
|
||||
});
|
||||
146
utils/serialValidation.ts
Normal file
146
utils/serialValidation.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Serial Number Validation Utilities
|
||||
*
|
||||
* WellNuo device serial numbers follow formats:
|
||||
* - Production: WELLNUO-XXXX-XXXX (16 chars with hyphens)
|
||||
* - Demo: DEMO-00000 or DEMO-1234-5678
|
||||
* - Legacy: 8+ alphanumeric characters
|
||||
*/
|
||||
|
||||
export interface SerialValidationResult {
|
||||
isValid: boolean;
|
||||
format: 'production' | 'demo' | 'legacy' | 'invalid';
|
||||
normalized: string; // Uppercase, trimmed
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Demo serial numbers
|
||||
const DEMO_SERIALS = new Set([
|
||||
'DEMO-00000',
|
||||
'DEMO-1234-5678',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates a serial number and returns detailed result
|
||||
*/
|
||||
export function validateSerial(serial: string | null | undefined): SerialValidationResult {
|
||||
// Handle empty input
|
||||
if (!serial || typeof serial !== 'string') {
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized: '',
|
||||
error: 'Serial number is required',
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize: trim and uppercase
|
||||
const normalized = serial.trim().toUpperCase();
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized: '',
|
||||
error: 'Serial number is required',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for demo serial
|
||||
if (DEMO_SERIALS.has(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'demo',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Production format: WELLNUO-XXXX-XXXX
|
||||
// Must be exactly 16 characters with hyphens at positions 7 and 12
|
||||
if (/^WELLNUO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'production',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format: minimum 8 alphanumeric characters (no special characters except hyphens)
|
||||
if (/^[A-Z0-9\-]{8,}$/.test(normalized)) {
|
||||
return {
|
||||
isValid: true,
|
||||
format: 'legacy',
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid format
|
||||
let error = 'Invalid serial number format';
|
||||
|
||||
if (normalized.length < 8) {
|
||||
error = 'Serial number must be at least 8 characters';
|
||||
} else if (!/^[A-Z0-9\-]+$/.test(normalized)) {
|
||||
error = 'Serial number can only contain letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
format: 'invalid',
|
||||
normalized,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation - returns true/false only
|
||||
*/
|
||||
export function isValidSerial(serial: string | null | undefined): boolean {
|
||||
return validateSerial(serial).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if serial is a demo serial
|
||||
*/
|
||||
export function isDemoSerial(serial: string | null | undefined): boolean {
|
||||
if (!serial) return false;
|
||||
const normalized = serial.trim().toUpperCase();
|
||||
return DEMO_SERIALS.has(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for invalid serial
|
||||
*/
|
||||
export function getSerialErrorMessage(serial: string | null | undefined): string {
|
||||
const result = validateSerial(serial);
|
||||
|
||||
if (result.isValid) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return result.error;
|
||||
}
|
||||
|
||||
return 'Please enter a valid serial number from your WellNuo Starter Kit.\n\nFor testing, use: DEMO-00000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format serial for display (add hyphens if missing for production format)
|
||||
*/
|
||||
export function formatSerialForDisplay(serial: string): string {
|
||||
const normalized = serial.trim().toUpperCase();
|
||||
|
||||
// If it looks like a production serial without hyphens, add them
|
||||
if (/^WELLNUO[A-Z0-9]{8}$/.test(normalized)) {
|
||||
return `${normalized.slice(0, 7)}-${normalized.slice(7, 11)}-${normalized.slice(11)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get placeholder text based on format preference
|
||||
*/
|
||||
export function getSerialPlaceholder(preferProduction: boolean = true): string {
|
||||
return preferProduction ? 'WELLNUO-XXXX-XXXX' : 'Enter serial number';
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user