diff --git a/.planning/phases/12-attachment-research-&-backend-validation/docs/REACT_INTEGRATION_DESIGN.md b/.planning/phases/12-attachment-research-&-backend-validation/docs/REACT_INTEGRATION_DESIGN.md new file mode 100644 index 000000000..b6338ead1 --- /dev/null +++ b/.planning/phases/12-attachment-research-&-backend-validation/docs/REACT_INTEGRATION_DESIGN.md @@ -0,0 +1,1319 @@ +# React Integration Design for Session Attachments + +**Phase:** 12 (Attachment Research & Backend Validation) +**Plan:** 02 +**Date:** 2026-02-02 +**Integration Strategy:** Extend existing jam-ui chat components with attachment upload/display + +--- + +## Executive Summary + +This document provides the complete React integration strategy for adding file attachment capabilities to the existing jam-ui session chat. The design extends existing components (`JKChatComposer`, `JKChatMessage`, `JKSessionScreen`) and Redux state (`sessionChatSlice`) to support file selection, validation, upload, progress tracking, and attachment display. + +**Design Principles:** +1. **Minimal changes:** Extend, don't replace existing components +2. **Pattern reuse:** Follow established jam-ui patterns (WindowPortal, Redux thunks, WebSocket handlers) +3. **TDD implementation:** All new functionality requires tests before code +4. **Progressive enhancement:** Attachment features are additive to existing chat + +--- + +## 1. Component Modifications Overview + +| Component | Current State | Modifications Needed | Phase | Lines Est. | +|-----------|---------------|----------------------|-------|------------| +| `JKChatComposer.js` | Text input + Send button | Add attach button, file validation, upload trigger | 13 | +80 | +| `JKChatMessage.js` | Text display with avatar | Handle attachment messages, render download link | 14 | +40 | +| `JKSessionScreen.js` | WebSocket CHAT_MESSAGE handler | Extract attachment fields from WebSocket payload | 14-15 | +10 | +| `sessionChatSlice.js` | Chat state management | Add upload state, uploadAttachment thunk | 13 | +120 | +| `rest.js` | API helper functions | Add uploadMusicNotation, getMusicNotationUrl | 13 | +40 | +| **New:** `JKChatAttachButton.js` | N/A | Hidden file input + visible button trigger | 13 | +60 | +| **New:** `attachmentValidation.js` | N/A | Client-side file validation utilities | 13 | +80 | + +**Total estimated additions:** ~430 lines of code + ~200 lines of tests = 630 lines total + +--- + +## 2. New Component: JKChatAttachButton + +### Purpose +Provide a user-friendly file selection interface with hidden `` triggered by visible button. + +### Component Skeleton + +```javascript +// jam-ui/src/components/client/chat/JKChatAttachButton.js +import React, { useRef, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +/** + * JKChatAttachButton - File selection button for chat attachments + * + * Pattern: Hidden file input + visible button trigger + * - User clicks visible button + * - Button triggers hidden file input click + * - File input onChange calls onFileSelect callback + * - Input value reset after selection (allows re-selecting same file) + * + * @param {Object} props + * @param {function} props.onFileSelect - Callback when file selected: (file) => void + * @param {boolean} props.disabled - Disable button (during upload or when disconnected) + * @param {boolean} props.isUploading - Show uploading state + */ +const JKChatAttachButton = ({ onFileSelect, disabled, isUploading }) => { + const fileInputRef = useRef(null); + + const handleButtonClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback((e) => { + const file = e.target.files?.[0]; + if (file) { + onFileSelect(file); + // Reset input value to allow re-selecting same file + e.target.value = ''; + } + }, [onFileSelect]); + + return ( + <> + {/* Visible button */} + + + {/* Hidden file input */} + + + ); +}; + +JKChatAttachButton.propTypes = { + onFileSelect: PropTypes.func.isRequired, + disabled: PropTypes.bool, + isUploading: PropTypes.bool +}; + +JKChatAttachButton.defaultProps = { + disabled: false, + isUploading: false +}; + +export default React.memo(JKChatAttachButton); +``` + +### Accept Attribute Value + +**From requirements (pending mp3 decision):** +```javascript +accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav" +``` + +**If mp3 is NOT supported (after backend decision):** +```javascript +accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.wav" +``` + +### Integration in JKChatComposer + +```javascript +// Add to JKChatComposer.js imports +import JKChatAttachButton from './JKChatAttachButton'; + +// Add file upload handler +const handleFileSelect = useCallback(async (file) => { + // Validate file before upload + const validation = validateFile(file); + if (!validation.valid) { + // Show error (could use toast or inline error display) + console.error('File validation failed:', validation.error); + return; + } + + // Dispatch upload action + await dispatch(uploadAttachment({ + file, + sessionId, + clientId: server?.clientId + })); +}, [dispatch, sessionId, server?.clientId]); + +// Add button before Send button in render +
+ + +
+``` + +--- + +## 3. Redux State Extensions (sessionChatSlice.js) + +### New State Shape + +```javascript +const initialState = { + // ... existing state + messagesByChannel: {}, + activeChannel: null, + // ... etc + + // NEW: Upload state tracking + uploadState: { + status: 'idle', // 'idle' | 'uploading' | 'error' + progress: 0, // 0-100 (future: if progress tracking implemented) + error: null, // Error message string or null + fileName: null // Currently uploading file name (for UI display) + } +}; +``` + +### New Reducers + +```javascript +const sessionChatSlice = createSlice({ + name: 'sessionChat', + initialState, + reducers: { + // ... existing reducers + + // NEW: Set upload status + setUploadStatus: (state, action) => { + const { status, progress, error, fileName } = action.payload; + state.uploadState.status = status; + if (progress !== undefined) state.uploadState.progress = progress; + if (error !== undefined) state.uploadState.error = error; + if (fileName !== undefined) state.uploadState.fileName = fileName; + }, + + // NEW: Clear upload error + clearUploadError: (state) => { + state.uploadState.error = null; + state.uploadState.status = 'idle'; + } + }, + extraReducers: (builder) => { + // ... existing thunk handlers + + // NEW: Upload attachment thunk handlers + builder + .addCase(uploadAttachment.pending, (state, action) => { + state.uploadState.status = 'uploading'; + state.uploadState.error = null; + state.uploadState.fileName = action.meta.arg.file.name; + state.uploadState.progress = 0; + }) + .addCase(uploadAttachment.fulfilled, (state, action) => { + state.uploadState.status = 'idle'; + state.uploadState.fileName = null; + state.uploadState.progress = 0; + // Note: Actual chat message arrives via WebSocket, not from upload response + }) + .addCase(uploadAttachment.rejected, (state, action) => { + state.uploadState.status = 'error'; + state.uploadState.error = action.error.message || 'Upload failed'; + state.uploadState.progress = 0; + }); + } +}); + +export const { setUploadStatus, clearUploadError } = sessionChatSlice.actions; +``` + +### New Async Thunk: uploadAttachment + +```javascript +/** + * Upload file attachment to session + * Creates MusicNotation record, which triggers ChatMessage creation + * ChatMessage broadcast via WebSocket (handled separately) + * + * @param {Object} params + * @param {File} params.file - File object from input + * @param {string} params.sessionId - Music session ID + * @param {string} [params.clientId] - WebSocket client ID (for deduplication) + */ +export const uploadAttachment = createAsyncThunk( + 'sessionChat/uploadAttachment', + async ({ file, sessionId, clientId }, { rejectWithValue }) => { + try { + // Build FormData + const formData = new FormData(); + formData.append('files[]', file); + formData.append('session_id', sessionId); + + // Determine attachment type based on file extension + const ext = file.name.split('.').pop().toLowerCase(); + const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aiff', 'aifc', 'au']; + const attachmentType = audioExts.includes(ext) ? 'audio' : 'notation'; + formData.append('attachment_type', attachmentType); + + // Upload via REST API + const response = await uploadMusicNotation(formData); + + // Response contains file metadata, but ChatMessage arrives via WebSocket + return response; + } catch (error) { + // Check for specific error types + if (error.status === 413) { + return rejectWithValue('File too large - maximum 10 MB'); + } + if (error.status === 422) { + return rejectWithValue('Invalid file type or format'); + } + return rejectWithValue(error.message || 'Upload failed'); + } + } +); +``` + +### New Selectors + +```javascript +// Select upload state +export const selectUploadStatus = (state) => state.sessionChat.uploadState.status; +export const selectUploadError = (state) => state.sessionChat.uploadState.error; +export const selectUploadProgress = (state) => state.sessionChat.uploadState.progress; +export const selectUploadFileName = (state) => state.sessionChat.uploadState.fileName; +export const selectIsUploading = (state) => state.sessionChat.uploadState.status === 'uploading'; +``` + +--- + +## 4. REST Helper Additions (rest.js) + +### New Function: uploadMusicNotation + +```javascript +/** + * Upload file attachment to S3 via backend + * + * IMPORTANT: Uses native fetch, NOT apiFetch wrapper + * Reason: FormData requires browser to set Content-Type with boundary + * + * @param {FormData} formData - FormData with files[], session_id, attachment_type + * @returns {Promise} Array of uploaded file metadata + */ +export const uploadMusicNotation = (formData) => { + const baseUrl = process.env.REACT_APP_API_BASE_URL || 'http://www.jamkazam.local:3000'; + + return new Promise((resolve, reject) => { + fetch(`${baseUrl}/api/music_notations`, { + method: 'POST', + credentials: 'include', // Include session cookie + // Do NOT set Content-Type - browser sets it with boundary for FormData + body: formData + }) + .then(response => { + if (!response.ok) { + // Pass status to error for specific handling + const error = new Error('Upload failed'); + error.status = response.status; + throw error; + } + return response.json(); + }) + .then(data => resolve(data)) + .catch(error => reject(error)); + }); +}; +``` + +### New Function: getMusicNotationUrl + +```javascript +/** + * Get signed S3 URL for attachment download + * + * Uses apiFetch because response is JSON (not FormData) + * + * @param {string} id - MusicNotation UUID + * @returns {Promise} { url: "https://s3..." } + */ +export const getMusicNotationUrl = (id) => { + return new Promise((resolve, reject) => { + apiFetch(`/music_notations/${id}`) + .then(response => resolve(response)) + .catch(error => reject(error)); + }); +}; +``` + +### JSDoc Documentation + +```javascript +/** + * @typedef {Object} MusicNotationUploadResponse + * @property {string} id - UUID of created MusicNotation record + * @property {string} file_name - Original filename + * @property {string} file_url - Relative URL (not used directly - use getMusicNotationUrl) + */ + +/** + * @typedef {Object} MusicNotationUrlResponse + * @property {string} url - Signed S3 URL (expires in 120 seconds) + */ +``` + +--- + +## 5. WebSocket Handler Updates (JKSessionScreen.js) + +### Current handleChatMessage Transformation + +**Location:** `jam-ui/src/components/client/JKSessionScreen.js` (search for `MessageType.CHAT_MESSAGE`) + +**Current code (approximate):** +```javascript +case MessageType.CHAT_MESSAGE: + const payload = message.chat_message; + const chatMessage = { + id: payload.msg_id, + senderId: payload.sender_id, + senderName: payload.sender_name, + message: payload.msg, + createdAt: payload.created_at, + channel: payload.channel || 'session', + sessionId: sessionId, + lessonSessionId: payload.lesson_session_id + }; + dispatch(addMessageFromWebSocket(chatMessage)); + break; +``` + +### Updated Code with Attachment Fields + +```javascript +case MessageType.CHAT_MESSAGE: + const payload = message.chat_message; + const chatMessage = { + id: payload.msg_id, + senderId: payload.sender_id, + senderName: payload.sender_name, + message: payload.msg, + createdAt: payload.created_at, + channel: payload.channel || 'session', + sessionId: sessionId, + lessonSessionId: payload.lesson_session_id, + + // NEW: Attachment metadata fields + purpose: payload.purpose, // 'Notation File', 'Audio File', or undefined + attachmentId: payload.attachment_id, // UUID or undefined + attachmentType: payload.attachment_type, // 'notation', 'audio', or undefined + attachmentName: payload.attachment_name // 'document.pdf' or undefined + }; + dispatch(addMessageFromWebSocket(chatMessage)); + break; +``` + +### Code Diff + +```diff + case MessageType.CHAT_MESSAGE: + const payload = message.chat_message; + const chatMessage = { + id: payload.msg_id, + senderId: payload.sender_id, + senderName: payload.sender_name, + message: payload.msg, + createdAt: payload.created_at, + channel: payload.channel || 'session', + sessionId: sessionId, +- lessonSessionId: payload.lesson_session_id ++ lessonSessionId: payload.lesson_session_id, ++ ++ // Attachment metadata (optional fields) ++ purpose: payload.purpose, ++ attachmentId: payload.attachment_id, ++ attachmentType: payload.attachment_type, ++ attachmentName: payload.attachment_name + }; + dispatch(addMessageFromWebSocket(chatMessage)); + break; +``` + +**Impact:** +5 lines, no existing functionality affected (all new fields are optional). + +--- + +## 6. JKChatMessage Attachment Display + +### Updated Component with Attachment Handling + +```javascript +// jam-ui/src/components/client/chat/JKChatMessage.js +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { formatTimestamp } from '../../../utils/formatTimestamp'; +import { getMusicNotationUrl } from '../../../helpers/rest'; + +/** + * Get initials for avatar from sender name + */ +const getInitials = (name) => { + if (!name) return '?'; + const parts = name.trim().split(' '); + if (parts.length === 1) return parts[0][0].toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +}; + +/** + * JKChatMessage - Individual message display component + * + * Supports both text messages and attachment messages + */ +const JKChatMessage = ({ message }) => { + const { senderName, message: text, createdAt, attachmentId, attachmentName, purpose } = message; + const [isDownloading, setIsDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); + + // Handle attachment click - fetch signed URL and open + const handleAttachmentClick = useCallback(async () => { + if (!attachmentId) return; + + try { + setIsDownloading(true); + setDownloadError(null); + + const response = await getMusicNotationUrl(attachmentId); + const { url } = response; + + // Open signed URL in new tab + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (error) { + console.error('Failed to download attachment:', error); + setDownloadError('Failed to download file'); + } finally { + setIsDownloading(false); + } + }, [attachmentId]); + + // Determine if this is an attachment message + const isAttachmentMessage = !!attachmentId; + + return ( +
+ {/* Avatar */} +
+ {getInitials(senderName)} +
+ + {/* Message content */} +
+
+ + {senderName || 'Unknown'} + + + {formatTimestamp(createdAt)} + +
+ + {/* Regular text message */} + {!isAttachmentMessage && ( +
+ {text} +
+ )} + + {/* Attachment message */} + {isAttachmentMessage && ( +
+
+ {text || `${senderName} shared a file`} +
+
+ {/* File icon based on purpose */} + + {purpose === 'Notation File' ? '📄' : '🎵'} + + + {/* Attachment link */} + + + {/* Download error */} + {downloadError && ( + + {downloadError} + + )} +
+
+ )} +
+
+ ); +}; + +JKChatMessage.propTypes = { + message: PropTypes.shape({ + id: PropTypes.string.isRequired, + senderId: PropTypes.string.isRequired, + senderName: PropTypes.string, + message: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + // Attachment fields (optional) + attachmentId: PropTypes.string, + attachmentName: PropTypes.string, + attachmentType: PropTypes.string, + purpose: PropTypes.string + }).isRequired +}; + +export default React.memo(JKChatMessage); +``` + +### Visual Design + +**Attachment message appearance:** +``` +┌─────────────────────────────────────────┐ +│ [JD] John Doe 2 minutes ago │ +│ John shared a file │ +│ ┌────────────────────────────────┐ │ +│ │ 📄 sheet-music.pdf │ │ +│ └────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +**Clickable link behavior:** +1. User clicks filename +2. React fetches signed S3 URL via `getMusicNotationUrl(attachmentId)` +3. Browser opens URL in new tab +4. File downloads (or displays in browser for PDFs/images) + +--- + +## 7. File Validation Service + +### New File: attachmentValidation.js + +```javascript +// jam-ui/src/services/attachmentValidation.js + +/** + * Maximum file size: 10 MB + * Matches legacy client-side validation + */ +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB in bytes + +/** + * Allowed file extensions + * Note: Pending mp3 backend decision (see BACKEND_VALIDATION.md) + */ +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 + */ +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) + */ +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 + 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`; +}; + +// Export constants for use in components +export { + MAX_FILE_SIZE, + ALLOWED_EXTENSIONS, + BACKEND_SUPPORTED_EXTENSIONS, + AUDIO_EXTENSIONS +}; +``` + +### Usage in JKChatComposer + +```javascript +import { validateFile } from '../../../services/attachmentValidation'; + +const handleFileSelect = useCallback(async (file) => { + // Validate before upload + const validation = validateFile(file); + + if (!validation.valid) { + // Show error to user + alert(validation.error); // Replace with better UI (toast, inline error) + return; + } + + if (validation.warnings.length > 0) { + // Show warnings but allow upload + console.warn('File validation warnings:', validation.warnings); + } + + // Proceed with upload + await dispatch(uploadAttachment({ file, sessionId, clientId })); +}, [dispatch, sessionId, clientId]); +``` + +--- + +## 8. Implementation Sequence + +### Phase 13: Upload Infrastructure (REQ-1.*, REQ-6.*, REQ-7.1) + +**Objective:** User can select and upload files from chat composer + +**Tasks:** +1. Create `JKChatAttachButton` component with file input +2. Create `attachmentValidation.js` service with size/type validation +3. Add REST helpers: `uploadMusicNotation`, `getMusicNotationUrl` +4. Extend `sessionChatSlice` with upload state and `uploadAttachment` thunk +5. Integrate attach button into `JKChatComposer` +6. Display upload progress/errors in `JKChatComposer` +7. Write unit tests for validation service +8. Write integration tests for upload flow + +**Estimated Duration:** 2-3 plans + +**TDD Requirements:** +- Unit tests: attachmentValidation.js (100% coverage) +- Redux tests: uploadAttachment thunk, upload reducers +- Integration tests: file selection, validation, upload API call + +--- + +### Phase 14: Message Display & Download (REQ-2.*, REQ-4.*, REQ-7.2-3) + +**Objective:** Users can see and download attachments in chat + +**Tasks:** +1. Update `JKSessionScreen` WebSocket handler to extract attachment fields +2. Modify `JKChatMessage` to detect and render attachment messages +3. Add attachment link click handler with signed URL fetch +4. Display file icons based on attachment type +5. Handle download errors gracefully +6. Test attachment display with various file types +7. Write integration tests for download flow + +**Estimated Duration:** 1-2 plans + +**TDD Requirements:** +- Unit tests: JKChatMessage attachment rendering +- Integration tests: WebSocket message with attachments, download flow + +--- + +### Phase 15: WebSocket Sync & Deduplication (REQ-3.*) + +**Objective:** Attachment messages sync properly across all session participants + +**Tasks:** +1. Verify WebSocket broadcast includes attachment metadata +2. Test message deduplication (upload response + WebSocket message) +3. Test multi-user scenarios (sender sees message, receivers see message) +4. Test edge cases (upload success, WebSocket failure) +5. Test edge cases (upload failure, WebSocket still broadcasts) + +**Estimated Duration:** 1 plan + +**TDD Requirements:** +- Integration tests: Multi-client WebSocket scenarios +- E2E tests: Complete upload-broadcast-receive flow + +--- + +### Phase 16: Error Handling & Edge Cases (REQ-5.*) + +**Objective:** Robust error handling and polished UX + +**Tasks:** +1. Handle all upload error types (413, 422, network errors) +2. Add retry capability for failed uploads +3. Handle disconnection during upload +4. Display helpful error messages +5. Test quota exceeded scenarios +6. Test invalid file scenarios +7. UAT execution across all requirements + +**Estimated Duration:** 1-2 plans + +**TDD Requirements:** +- Unit tests: Error message formatting, retry logic +- Integration tests: All error scenarios +- E2E tests: Complete happy path + error paths + +--- + +## 9. Testing Strategy + +### Unit Tests + +**attachmentValidation.js (Jest):** +```javascript +describe('validateFileSize', () => { + test('accepts file under limit', () => { + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 }); // 5 MB + expect(validateFileSize(file)).toEqual({ valid: true, error: null }); + }); + + test('rejects file over limit', () => { + const file = new File(['content'], 'large.pdf', { type: 'application/pdf' }); + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }); // 15 MB + const result = validateFileSize(file); + expect(result.valid).toBe(false); + expect(result.error).toContain('10 MB'); + }); +}); + +describe('validateFileType', () => { + test('accepts pdf file', () => { + const file = new File(['content'], 'document.pdf'); + expect(validateFileType(file)).toEqual({ valid: true, error: null }); + }); + + test('rejects exe file', () => { + const file = new File(['content'], 'virus.exe'); + const result = validateFileType(file); + expect(result.valid).toBe(false); + expect(result.error).toContain('not allowed'); + }); +}); +``` + +### Integration Tests (Playwright) + +**test/attachments/file-upload.spec.ts:** +```typescript +import { test, expect } from '@playwright/test'; +import { APIInterceptor } from '../helpers/apiInterceptor'; + +test('upload pdf file to session', async ({ page }) => { + const interceptor = new APIInterceptor(); + await interceptor.intercept(page); + + // Navigate to session + await page.goto('/session/test-session-123'); + + // Open chat window + await page.click('[data-testid="chat-button"]'); + + // Click attach button + await page.click('[aria-label="Attach file"]'); + + // Upload file (simulate file selection) + const fileInput = await page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'test-document.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('fake pdf content') + }); + + // Wait for upload API call + await page.waitForTimeout(1000); + const uploadCalls = interceptor.getCallsByPath('/api/music_notations'); + expect(uploadCalls.length).toBe(1); + expect(uploadCalls[0].method).toBe('POST'); + + // Verify FormData includes correct fields + // (Note: Playwright makes FormData inspection difficult, may need backend logs) +}); + +test('display attachment message in chat', async ({ page }) => { + // Mock WebSocket message with attachment + await page.evaluate(() => { + window.__REDUX_STORE__.dispatch({ + type: 'sessionChat/addMessageFromWebSocket', + payload: { + id: 'msg-123', + senderId: 'user-456', + senderName: 'John Doe', + message: 'John shared a file', + createdAt: new Date().toISOString(), + attachmentId: 'notation-789', + attachmentName: 'sheet-music.pdf', + attachmentType: 'notation', + purpose: 'Notation File' + } + }); + }); + + // Verify attachment displays + await expect(page.locator('text=sheet-music.pdf')).toBeVisible(); + await expect(page.locator('text=📄')).toBeVisible(); // File icon +}); +``` + +### E2E Tests + +**test/e2e/session-attachment-flow.spec.ts:** +```typescript +test('complete attachment flow: upload, broadcast, download', async ({ page, context }) => { + // User A uploads file + await page.goto('/session/test-session-123'); + await page.click('[data-testid="chat-button"]'); + await uploadFile(page, 'document.pdf'); + + // User B opens session in new tab + const page2 = await context.newPage(); + await page2.goto('/session/test-session-123'); + await page2.click('[data-testid="chat-button"]'); + + // Verify User B sees attachment message + await expect(page2.locator('text=document.pdf')).toBeVisible(); + + // User B clicks to download + await page2.click('text=document.pdf'); + + // Verify signed URL fetch + await page2.waitForResponse(resp => resp.url().includes('/api/music_notations/')); + // Note: Can't verify S3 download in test, but can verify URL fetch +}); +``` + +--- + +## 10. Component Integration Map + +### Data Flow Diagram + +``` +┌─────────────────┐ +│ User clicks │ +│ Attach button │ +└────────┬────────┘ + │ + ▼ +┌─────────────────────────┐ +│ JKChatAttachButton │ +│ - Triggers file input │ +│ - Calls onFileSelect │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ JKChatComposer │ +│ - Validates file │ +│ - Dispatches upload │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ sessionChatSlice │ +│ - uploadAttachment │ +│ thunk executes │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ uploadMusicNotation │ +│ (rest.js) │ +│ - POST FormData to API │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Rails Backend │ +│ - Saves to S3 │ +│ - Creates ChatMessage │ +│ - Broadcasts WebSocket │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ JKSessionScreen │ +│ - Receives WebSocket │ +│ - Extracts attachment │ +│ metadata │ +│ - Dispatches to Redux │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ sessionChatSlice │ +│ - addMessageFromWS │ +│ - Stores message with │ +│ attachment fields │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ JKChatMessage │ +│ - Detects attachment │ +│ - Renders file link │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ User clicks filename │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ getMusicNotationUrl │ +│ (rest.js) │ +│ - GET signed S3 URL │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ window.open(url) │ +│ - Opens in new tab │ +│ - Browser downloads │ +└─────────────────────────┘ +``` + +--- + +## 11. Requirements Coverage Matrix + +| Requirement | Phase | Components Modified | Testing Strategy | +|-------------|-------|---------------------|------------------| +| REQ-1.1: Attach button in composer | 13 | JKChatComposer, JKChatAttachButton | Unit, Integration | +| REQ-1.2: File type validation | 13 | attachmentValidation.js | Unit (100%) | +| REQ-1.3: File size validation | 13 | attachmentValidation.js | Unit (100%) | +| REQ-1.4: Upload progress | 13 | sessionChatSlice, JKChatComposer | Integration | +| REQ-2.1: Display attachments | 14 | JKChatMessage | Unit, Integration | +| REQ-2.2: File type icons | 14 | JKChatMessage | Unit | +| REQ-2.3: Downloadable links | 14 | JKChatMessage, rest.js | Integration | +| REQ-3.1: WebSocket broadcast | 15 | JKSessionScreen | Integration | +| REQ-3.2: Message deduplication | 15 | sessionChatSlice | Unit, Integration | +| REQ-4.1: Chat history includes attachments | 14 | REST API (existing) | Integration | +| REQ-4.2: Attachment metadata persists | 14 | Backend (existing) | Integration | +| REQ-5.1: Size limit errors | 16 | attachmentValidation.js | Unit, E2E | +| REQ-5.2: Type errors | 16 | attachmentValidation.js | Unit, E2E | +| REQ-5.3: Upload errors | 16 | uploadAttachment thunk | Integration, E2E | +| REQ-5.4: Network errors | 16 | uploadAttachment thunk | Integration | +| REQ-6.1: Backend storage | N/A | Backend (existing) | Validated | +| REQ-6.2: S3 integration | N/A | Backend (existing) | Validated | +| REQ-7.1: Client validation | 13 | attachmentValidation.js | Unit | +| REQ-7.2: Server validation | N/A | Backend (existing) | Integration | +| REQ-7.3: Signed URLs | 14 | rest.js, Backend | Integration | + +**Coverage:** 19/19 requirements mapped to implementation phases (100%) + +--- + +## 12. Performance Considerations + +### Upload Performance + +**File size impact:** +- 1 MB file: ~2-3 seconds on good connection +- 5 MB file: ~10-15 seconds on good connection +- 10 MB file: ~20-30 seconds on good connection + +**Optimization strategies:** +- Client-side validation fails fast (no network wait) +- Single upload per interaction (no batch upload complexity) +- Disable attach button during upload (prevent duplicates) + +### Download Performance + +**Signed URL caching:** +- URLs expire in 120 seconds (backend configured) +- No client-side caching needed (backend handles) +- Each click fetches fresh URL (prevents expired URL errors) + +**Browser behavior:** +- PDFs: Often display in browser (fast) +- Images: Display in browser (fast) +- Audio: Browser may play inline or download +- Other: Triggers download + +--- + +## 13. Accessibility + +### Keyboard Navigation + +- Attach button: Tab-accessible, Enter/Space to activate +- File input: Hidden but screen reader accessible +- Attachment link: Tab-accessible, Enter to open + +### ARIA Labels + +```javascript +// Attach button +