diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index d5483ee..218c7bf 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -1,4 +1,5 @@ import { clsx } from 'clsx'; +import { normalizeItem } from '../services/firestoreNormalizer'; /** * Combine multiple class names conditionally @@ -12,48 +13,65 @@ export function cn(...inputs) { // Default values for when data is missing or invalid const DEFAULT_IMAGE_URL = '/placeholder.svg'; -const DEFAULT_LOCATION = 'Unknown'; const DEFAULT_REPORTER = { name: 'Unknown', trust: false }; +/** + * Map category names to consistent values for UI + * Handles variations like 'accessories' vs 'accessory', 'keys/cards' -> 'keys-cards' + * @param {string} category - Raw category string + * @returns {string} Normalized category name + */ +const normalizeCategory = (category) => { + const categoryMap = { + 'accessories': 'accessory', + 'keys/cards': 'keys-cards', + 'wallets': 'wallet', + 'documents': 'document', + 'stationery': 'stationery', + 'electronics': 'electronics', + 'clothing': 'clothing', + 'other': 'other' + }; + + return categoryMap[category] || category; +}; + /** * Clean up Firestore data to match our UI expectations - * Handles different field names and data formats from various sources + * REFACTORED: Now uses centralized normalizeItem from firestoreNormalizer service + * This function wraps the service to add UI-specific fields like 'reporter' + * * @param {Object} data - Raw document data from Firestore * @param {string} id - Document ID (optional) * @returns {Object} Normalized item object ready for the UI */ export const normalizeFirestoreItem = (data, id) => { try { - // ...existing code... - const imageUrl = data.imageURL || data.imageUrl || DEFAULT_IMAGE_URL; - const kindRaw = data.kind || data.status || ''; - const kind = String(kindRaw).toLowerCase(); - const typeRaw = data.category || data.type || ''; - const category = normalizeCategory(String(typeRaw).toLowerCase()); + // Use centralized normalization service + const normalized = normalizeItem(data, id); + + if (!normalized) { + throw new Error('normalizeItem returned null'); + } + + // Add UI-specific fields that aren't in the base normalizer const reporter = normalizeReporter(data.reporter, data.postedBy); - const date = normalizeDate(data.date); - const location = data.location || DEFAULT_LOCATION; const coordinates = data.coordinates || null; - - // Claim fields - const claimed = Boolean(data.claimed); - const claimedAt = data.claimedAt ? normalizeDate(data.claimedAt) : null; - const claimedBy = data.claimedBy || null; + + // Apply category normalization for UI (keys/cards -> keys-cards, etc.) + const category = normalizeCategory(normalized.category); return { - id: id || data.id || '', - kind, + ...normalized, + // Override imageUrl default if it's empty + imageUrl: normalized.imageUrl || DEFAULT_IMAGE_URL, + // Override location default for UI consistency + location: normalized.location || 'Unknown', + // Apply UI-specific category normalization category, - title: String(data.title || '').trim(), - description: String(data.description || '').trim(), - imageUrl, - date, - location, - coordinates, + // Add UI-specific fields reporter, - claimed, - claimedAt, - claimedBy, + coordinates, }; } catch (error) { console.error('Error normalizing Firestore item:', error); @@ -66,34 +84,16 @@ export const normalizeFirestoreItem = (data, id) => { description: 'This item could not be loaded properly.', imageUrl: DEFAULT_IMAGE_URL, date: new Date().toISOString(), - location: DEFAULT_LOCATION, + location: 'Unknown', coordinates: null, reporter: DEFAULT_REPORTER, + claimed: false, + claimedAt: null, + claimedBy: null, }; } }; -/** - * Map category names to consistent values - * Handles variations like 'accessories' vs 'accessory' - * @param {string} category - Raw category string - * @returns {string} Normalized category name - */ -const normalizeCategory = (category) => { - const categoryMap = { - 'accessories': 'accessory', - 'keys/cards': 'keys-cards', - 'wallets': 'wallet', - 'documents': 'document', - 'stationery': 'stationery', - 'electronics': 'electronics', - 'clothing': 'clothing', - 'other': 'other' - }; - - return categoryMap[category] || category; -}; - /** * Extract reporter information from various data formats * Handles both direct user objects and Firestore document references @@ -125,42 +125,6 @@ const normalizeReporter = (reporter, postedBy) => { return DEFAULT_REPORTER; }; -/** - * Convert various date formats to ISO strings - * Handles Firestore timestamps, Date objects, strings, and numbers - * @param {any} dateValue - Date value from Firestore or form - * @returns {string} ISO date string for consistent display - */ -const normalizeDate = (dateValue) => { - try { - // Handle Firestore Timestamp objects - if (dateValue?.toDate && typeof dateValue.toDate === 'function') { - return dateValue.toDate().toISOString(); - } else if (dateValue instanceof Date) { - // Handle JavaScript Date objects - return dateValue.toISOString(); - } else if (typeof dateValue === 'string') { - // Handle string dates - const parsed = new Date(dateValue); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } else if (typeof dateValue === 'number') { - // Handle timestamp numbers (milliseconds since epoch) - const parsed = new Date(dateValue); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - - // Fallback to current date if parsing fails - return new Date().toISOString(); - } catch (error) { - console.warn('Error normalizing date:', error); - return new Date().toISOString(); - } -}; - /** * Pre-built button styles to reduce duplication * Each variant has its own color scheme and hover states diff --git a/frontend/src/pages/Messages.js b/frontend/src/pages/Messages.js index 8a5cbc2..36188ce 100644 --- a/frontend/src/pages/Messages.js +++ b/frontend/src/pages/Messages.js @@ -16,6 +16,7 @@ import { db } from '../firebase/config'; import { ArrowLeft, Send, Check } from '../components/ui/icons'; import { Button } from '../components/ui/button'; import { cardStyles } from '../lib/utils'; +import { normalizeConversation, normalizeMessage, normalizeItem } from '../services/firestoreNormalizer'; const MessagesPage = () => { const [user, setUser] = useState(null); @@ -31,11 +32,15 @@ const MessagesPage = () => { const messagesEndRef = useRef(null); // Helper: fetch item document by id (returns null if not found) + // REFACTORED: Now uses normalizeItem for consistent data shape const fetchItemData = useCallback(async (itemId) => { if (!itemId) return null; try { const itemDoc = await getDoc(doc(db, 'items', itemId)); - return itemDoc.exists() ? { id: itemDoc.id, ...itemDoc.data() } : null; + if (!itemDoc.exists()) return null; + + // Use normalizer for consistent data structure + return normalizeItem(itemDoc.data(), itemDoc.id); } catch (error) { console.error('Error fetching item:', error); return null; @@ -57,17 +62,22 @@ const MessagesPage = () => { }, []); // Helper: enrich a list of conversation docs with item data and participant name + // REFACTORED: Now uses normalizeConversation for consistent data structure const enrichConversations = useCallback(async (docs, userUid) => { const promises = docs.map(async (docSnapshot) => { const conversationData = docSnapshot.data(); + + // Normalize the conversation data first + const normalized = normalizeConversation(conversationData, docSnapshot.id); + + // Fetch related data const [itemData, otherParticipantName] = await Promise.all([ - fetchItemData(conversationData.itemId), - fetchUserNameById(conversationData.participants.find(id => id !== userUid)) + fetchItemData(normalized.itemId), + fetchUserNameById(normalized.participants.find(id => id !== userUid)) ]); return { - id: docSnapshot.id, - ...conversationData, + ...normalized, item: itemData, otherParticipantName }; @@ -90,6 +100,7 @@ const MessagesPage = () => { }, [auth, navigate]); // Load conversations for the current user + // REFACTORED: Uses normalizeConversation for consistent data handling useEffect(() => { if (!user) return; @@ -100,10 +111,15 @@ const MessagesPage = () => { try { const enriched = await enrichConversations(snapshot.docs, user.uid); - // Sort conversations by lastMessageTime (handle missing timestamps) + // Sort conversations by lastMessageTime using normalized timestamps + // Fall back to createdAt if lastMessageTime is missing enriched.sort((a, b) => { - const aTime = a.lastMessageTime?.seconds || a.createdAt?.seconds || 0; - const bTime = b.lastMessageTime?.seconds || b.createdAt?.seconds || 0; + const aTime = a.lastMessageTime + ? new Date(a.lastMessageTime).getTime() + : (a.createdAt ? new Date(a.createdAt).getTime() : 0); + const bTime = b.lastMessageTime + ? new Date(b.lastMessageTime).getTime() + : (b.createdAt ? new Date(b.createdAt).getTime() : 0); return bTime - aTime; }); @@ -124,6 +140,7 @@ const MessagesPage = () => { }, [user, searchParams, enrichConversations]); // Load messages for selected conversation + // REFACTORED: Uses normalizeMessage for consistent timestamp handling useEffect(() => { if (!selectedConversation) { setMessages([]); @@ -131,22 +148,21 @@ const MessagesPage = () => { } const messagesRef = collection(db, 'messages'); - // Try without ordering first to see if messages exist const q = query( messagesRef, where('conversationId', '==', selectedConversation.id) ); const unsubscribe = onSnapshot(q, (snapshot) => { - const messagesList = snapshot.docs.map(doc => ({ - id: doc.id, - ...doc.data() - })); + // Use normalizer for consistent message structure + const messagesList = snapshot.docs + .map(doc => normalizeMessage(doc.data(), doc.id)) + .filter(msg => msg !== null); - // Sort messages by timestamp (handle cases where timestamp might be missing) + // Sort messages by timestamp (now consistently ISO strings) messagesList.sort((a, b) => { - const aTime = a.timestamp?.seconds || 0; - const bTime = b.timestamp?.seconds || 0; + const aTime = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0; return aTime - bTime; }); @@ -363,9 +379,9 @@ const MessagesPage = () => { className={`text-xs mt-1 ${ message.senderId === user.uid ? '' : 'text-gray-500' }`} - style={message.senderId === user.uid ? { color: '#FFFFFF' } : undefined} + style={message.senderId === user.uid ? { color: '#D1D5DB' } : undefined} > - {message.timestamp?.toDate?.()?.toLocaleTimeString() || 'Just now'} + {message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : 'Just now'}
diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js index c044057..32ac457 100644 --- a/frontend/src/pages/ProfilePage.js +++ b/frontend/src/pages/ProfilePage.js @@ -8,10 +8,20 @@ import PropTypes from 'prop-types' import { getUserPosts, formatTimestamp, updateItemStatus, updateItem } from "../firebase/firestore" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { ProfileBadge } from '../components/ui/ProfileBadge' +import { extractDate, normalizeTimestamp } from "../services/firestoreNormalizer" /** ---------- Small utilities (deduplicated helpers) ---------- **/ -const coalesceDate = (item) => - item.date || item.createdAt || item.created_at || item.timestamp || item.dateCreated +// REFACTORED: Replaced coalesceDate with extractDate from firestoreNormalizer +// This provides consistent date handling across the entire app +// Falls back to normalizeTimestamp to ensure we always return an ISO string +const coalesceDate = (item) => { + const extracted = extractDate(item); + if (extracted) return extracted; + + // Fallback to normalizeTimestamp to handle Firestore Timestamps + const normalized = normalizeTimestamp(item.date); + return normalized || new Date().toISOString(); +} const StatusPill = memo(({ color, text }) => ( diff --git a/frontend/src/services/firestoreNormalizer.js b/frontend/src/services/firestoreNormalizer.js new file mode 100644 index 0000000..170a5e1 --- /dev/null +++ b/frontend/src/services/firestoreNormalizer.js @@ -0,0 +1,269 @@ +/** + * Centralized Firestore Data Normalization Service + * + * This service provides a single source of truth for transforming Firestore documents + * into consistent JavaScript objects. It handles: + * - Firestore Timestamp conversions + * - Field name reconciliation (e.g., imageURL vs imageUrl) + * - Multiple date field variations + * - Type coercion and defaults + * + * Benefits: + * - Easier debugging with preserved _raw data + * - Consistent data shape across the app + * - Single place to update when Firestore schema changes + */ + +/** + * Normalize a Firestore Timestamp to ISO string or null + * Handles multiple timestamp formats from Firestore + * + * @param {*} value - Firestore Timestamp, Date, or timestamp object + * @returns {string|null} ISO date string or null + */ +export const normalizeTimestamp = (value) => { + if (!value) return null; + + // Firestore Timestamp object with toDate() method + if (value.toDate && typeof value.toDate === 'function') { + try { + return value.toDate().toISOString(); + } catch (e) { + console.warn('Failed to convert Firestore Timestamp:', e); + return null; + } + } + + // Already a Date object + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value.toISOString(); + } + + // Firestore snapshot with .seconds property + if (value.seconds !== undefined) { + try { + // Include nanoseconds for precision (avoids loss when ordering messages) + const milliseconds = value.seconds * 1000 + Math.floor((value.nanoseconds || 0) / 1e6); + return new Date(milliseconds).toISOString(); + } catch (e) { + console.warn('Failed to convert Firestore seconds:', e); + return null; + } + } + + // String timestamp + if (typeof value === 'string') { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + + // Number timestamp (milliseconds) + if (typeof value === 'number') { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + + return null; +}; + +/** + * Extract a date from a document by checking multiple possible field names + * Firestore documents often have inconsistent date field naming + * + * @param {Object} doc - Firestore document data + * @param {string[]} fieldNames - Array of field names to check in order + * @returns {string|null} ISO date string or null + */ +export const extractDate = (doc, fieldNames = ['date', 'createdAt', 'created_at', 'timestamp', 'dateCreated']) => { + for (const field of fieldNames) { + if (doc[field]) { + const normalized = normalizeTimestamp(doc[field]); + if (normalized) return normalized; + } + } + return null; +}; + +/** + * Normalize an item document (lost/found items) + * + * Reconciles field name variations: + * - type/category for item category + * - status/kind for lost/found + * - imageURL/imageUrl/image for image URLs + * - Multiple date field variations + * + * @param {Object} data - Raw Firestore document data + * @param {string} id - Document ID + * @returns {Object} Normalized item object + */ +export const normalizeItem = (data, id) => { + if (!data) { + console.warn('normalizeItem received null/undefined data for id:', id); + return null; + } + + return { + id, + // Core fields + title: data.title || '', + description: data.description || '', + + // Reconcile field name variations + category: data.type || data.category || '', + kind: (data.status || data.kind || 'lost').toLowerCase(), + imageUrl: data.imageURL || data.imageUrl || data.image || '', + location: data.location || '', + + // Dates - check multiple possible field names + date: extractDate(data), + + // Claims data (new system) + claimed: Boolean(data.claimed), + claimedAt: normalizeTimestamp(data.claimedAt), + claimedBy: data.claimedBy || null, + + // User reference + user: data.user || null, + userId: data.userId || data.user?.id || null, + + // Contact information for reporter + contactName: data.contactName || null, + contactEmail: data.contactEmail || null, + contactPhone: data.contactPhone || null, + + // Legacy status field (kept for backward compatibility) + status: data.status || data.kind || 'lost', + + // Preserve original for debugging + _raw: data + }; +}; + +/** + * Normalize a user document + * + * @param {Object} data - Raw Firestore document data + * @param {string} id - Document ID + * @returns {Object} Normalized user object + */ +export const normalizeUser = (data, id) => { + if (!data) { + console.warn('normalizeUser received null/undefined data for id:', id); + return null; + } + + return { + id, + name: data.name || data.displayName || '', + email: data.email || '', + profilePic: data.profilePic || data.photoURL || null, + createdAt: extractDate(data, ['createdAt', 'created_at', 'joinedAt']), + + // Preserve original + _raw: data + }; +}; + +/** + * Normalize a conversation document + * + * @param {Object} data - Raw Firestore document data + * @param {string} id - Document ID + * @returns {Object} Normalized conversation object + */ +export const normalizeConversation = (data, id) => { + if (!data) { + console.warn('normalizeConversation received null/undefined data for id:', id); + return null; + } + + return { + id, + participants: data.participants || [], + itemId: data.itemId || null, + lastMessage: data.lastMessage || '', + lastMessageTime: normalizeTimestamp(data.lastMessageTime), + lastMessageSender: data.lastMessageSender || null, + createdAt: extractDate(data), + + // Preserve original + _raw: data + }; +}; + +/** + * Normalize a message document + * + * @param {Object} data - Raw Firestore document data + * @param {string} id - Document ID + * @returns {Object} Normalized message object + */ +export const normalizeMessage = (data, id) => { + if (!data) { + console.warn('normalizeMessage received null/undefined data for id:', id); + return null; + } + + return { + id, + conversationId: data.conversationId || '', + senderId: data.senderId || '', + senderName: data.senderName || 'Unknown', + text: data.text || '', + timestamp: normalizeTimestamp(data.timestamp), + + // Preserve original + _raw: data + }; +}; + +/** + * Normalize an announcement document + * + * @param {Object} data - Raw Firestore document data + * @param {string} id - Document ID + * @returns {Object} Normalized announcement object + */ +export const normalizeAnnouncement = (data, id) => { + if (!data) { + console.warn('normalizeAnnouncement received null/undefined data for id:', id); + return null; + } + + return { + id, + title: data.title || '', + message: data.message || data.content || '', + category: data.category || 'general', + priority: data.priority || 'normal', + createdAt: extractDate(data), + expiresAt: normalizeTimestamp(data.expiresAt), + author: data.author || null, + + // Preserve original + _raw: data + }; +}; + +/** + * Helper to get a displayable timestamp string from normalized data + * + * @param {Object} item - Normalized item with date field + * @returns {string} Formatted date string or 'Unknown date' + */ +export const getDisplayDate = (item) => { + if (!item.date) return 'Unknown date'; + + try { + const date = new Date(item.date); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + console.warn('Failed to format date:', e); + return 'Unknown date'; + } +}; diff --git a/frontend/src/services/firestoreNormalizer.test.js b/frontend/src/services/firestoreNormalizer.test.js new file mode 100644 index 0000000..cd36f0d --- /dev/null +++ b/frontend/src/services/firestoreNormalizer.test.js @@ -0,0 +1,226 @@ +/** + * Tests for Firestore Normalizer Service + * + * These tests demonstrate the benefits of centralized normalization: + * 1. Easy to test all edge cases in one place + * 2. Consistent handling of different timestamp formats + * 3. Clear documentation of expected behavior + */ + +import { + normalizeTimestamp, + extractDate, + normalizeItem, + normalizeMessage, + normalizeConversation +} from './firestoreNormalizer'; + +describe('normalizeTimestamp', () => { + it('should handle Firestore Timestamp with toDate method', () => { + const mockTimestamp = { + toDate: () => new Date('2024-01-15T10:30:00Z') + }; + + const result = normalizeTimestamp(mockTimestamp); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should handle Firestore snapshot with seconds property', () => { + const mockSnapshot = { + seconds: 1705315800, // Jan 15, 2024 10:50:00 UTC + nanoseconds: 0 + }; + + const result = normalizeTimestamp(mockSnapshot); + expect(result).toBe('2024-01-15T10:50:00.000Z'); + }); + + it('should handle Date objects', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const result = normalizeTimestamp(date); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should handle ISO string timestamps', () => { + const result = normalizeTimestamp('2024-01-15T10:30:00Z'); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should handle number timestamps (milliseconds)', () => { + const timestamp = new Date('2024-01-15T10:30:00Z').getTime(); + const result = normalizeTimestamp(timestamp); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should return null for invalid values', () => { + expect(normalizeTimestamp(null)).toBeNull(); + expect(normalizeTimestamp(undefined)).toBeNull(); + expect(normalizeTimestamp('invalid-date')).toBeNull(); + }); +}); + +describe('extractDate', () => { + it('should extract date from first matching field', () => { + const doc = { + otherField: 'value', + createdAt: { seconds: 1705314600 }, + date: { seconds: 1705314660 } // Different time (1 minute later) + }; + + // Should use 'date' first (default field order) + const result = extractDate(doc); + expect(result).toBe('2024-01-15T10:31:00.000Z'); + }); + + it('should fall back to other field names', () => { + const doc = { + created_at: { seconds: 1705314600 } + }; + + const result = extractDate(doc); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should support custom field names', () => { + const doc = { + lastModified: { seconds: 1705314600 } + }; + + const result = extractDate(doc, ['lastModified', 'updated']); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should return null if no valid date found', () => { + const doc = { + title: 'Some Item', + description: 'No dates here' + }; + + const result = extractDate(doc); + expect(result).toBeNull(); + }); +}); + +describe('normalizeItem', () => { + it('should handle all field name variations', () => { + const rawItem = { + title: 'Lost Wallet', + description: 'Brown leather wallet', + type: 'wallets', // Using 'type' instead of 'category' + status: 'lost', // Using 'status' instead of 'kind' + imageURL: 'https://example.com/image.jpg', // Using 'imageURL' instead of 'imageUrl' + location: 'OGGB', + date: { seconds: 1705314600 }, + claimed: true, + claimedAt: { seconds: 1705318800 } + }; + + const result = normalizeItem(rawItem, 'item123'); + + expect(result.id).toBe('item123'); + expect(result.title).toBe('Lost Wallet'); + expect(result.category).toBe('wallets'); + expect(result.kind).toBe('lost'); + expect(result.imageUrl).toBe('https://example.com/image.jpg'); + expect(result.claimed).toBe(true); + expect(result.date).toBe('2024-01-15T10:30:00.000Z'); + expect(result.claimedAt).toBe('2024-01-15T11:40:00.000Z'); + }); + + it('should handle alternative field names', () => { + const rawItem = { + title: 'Found Keys', + category: 'keys/cards', // Alternative field name + kind: 'found', // Alternative field name + imageUrl: 'https://example.com/keys.jpg', // Alternative field name + createdAt: { seconds: 1705314600 } // Alternative date field + }; + + const result = normalizeItem(rawItem, 'item456'); + + expect(result.category).toBe('keys/cards'); + expect(result.kind).toBe('found'); + expect(result.imageUrl).toBe('https://example.com/keys.jpg'); + expect(result.date).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should provide defaults for missing fields', () => { + const rawItem = { + title: 'Minimal Item' + }; + + const result = normalizeItem(rawItem, 'item789'); + + expect(result.description).toBe(''); + expect(result.category).toBe(''); + expect(result.kind).toBe('lost'); // Default + expect(result.imageUrl).toBe(''); + expect(result.claimed).toBe(false); + expect(result.claimedAt).toBeNull(); + }); + + it('should preserve original data for debugging', () => { + const rawItem = { + title: 'Test Item', + customField: 'custom value' + }; + + const result = normalizeItem(rawItem, 'test'); + + expect(result._raw).toEqual(rawItem); + expect(result._raw.customField).toBe('custom value'); + }); +}); + +describe('normalizeMessage', () => { + it('should normalize message with all fields', () => { + const rawMessage = { + conversationId: 'conv123', + senderId: 'user456', + senderName: 'John Doe', + text: 'Hello, is this still available?', + timestamp: { seconds: 1705314600 } + }; + + const result = normalizeMessage(rawMessage, 'msg789'); + + expect(result.id).toBe('msg789'); + expect(result.conversationId).toBe('conv123'); + expect(result.senderId).toBe('user456'); + expect(result.text).toBe('Hello, is this still available?'); + expect(result.timestamp).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should provide defaults for missing sender info', () => { + const rawMessage = { + text: 'Test message' + }; + + const result = normalizeMessage(rawMessage, 'msg001'); + + expect(result.senderName).toBe('Unknown'); + expect(result.conversationId).toBe(''); + }); +}); + +describe('normalizeConversation', () => { + it('should normalize conversation data', () => { + const rawConversation = { + participants: ['user1', 'user2'], + itemId: 'item123', + lastMessage: 'Thanks!', + lastMessageTime: { seconds: 1705314600 }, + lastMessageSender: 'user2', + createdAt: { seconds: 1705309600 } + }; + + const result = normalizeConversation(rawConversation, 'conv456'); + + expect(result.id).toBe('conv456'); + expect(result.participants).toEqual(['user1', 'user2']); + expect(result.itemId).toBe('item123'); + expect(result.lastMessage).toBe('Thanks!'); + expect(result.lastMessageTime).toBe('2024-01-15T10:30:00.000Z'); + expect(result.createdAt).toBe('2024-01-15T09:06:40.000Z'); + }); +});