feat(13-02): add Redux upload state and REST helpers

- Add uploadState to sessionChatSlice initialState
- Implement setUploadStatus and clearUploadError reducers
- Create uploadAttachment async thunk with error handling
- Add 5 upload selectors (status, error, progress, fileName, isUploading)
- Implement uploadMusicNotation using native fetch (NOT apiFetch)
- Implement getMusicNotationUrl using apiFetch
- Add comprehensive unit tests for all upload functionality

TDD: RED-GREEN cycle complete
Tests: 85/88 passing (3 pre-existing failures unrelated to upload)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-02-05 11:00:58 +05:30
parent 878e9e44aa
commit 3b52b58cc5
3 changed files with 550 additions and 5 deletions

View File

@ -1009,3 +1009,52 @@ export const sendChatMessage = async ({ channel, sessionId, message, clientId })
return response.json();
};
/**
* 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<Object[]>} 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));
});
};
/**
* Get signed S3 URL for attachment download
*
* Uses apiFetch because response is JSON (not FormData)
*
* @param {string} id - MusicNotation UUID
* @returns {Promise<Object>} { url: "https://s3..." }
*/
export const getMusicNotationUrl = (id) => {
return new Promise((resolve, reject) => {
apiFetch(`/music_notations/${id}`)
.then(response => resolve(response))
.catch(error => reject(error));
});
};

View File

@ -5,7 +5,10 @@ import sessionChatReducer, {
closeChatWindow,
markAsRead,
incrementUnreadCount,
setWindowPosition
setWindowPosition,
setUploadStatus,
clearUploadError,
uploadAttachment
} from '../sessionChatSlice';
describe('sessionChatSlice initial state', () => {
@ -83,7 +86,23 @@ describe('sessionChatSlice initial state', () => {
sendError: null,
nextCursors: {},
isWindowOpen: false,
windowPosition: null
windowPosition: null,
uploadState: {
status: 'idle',
progress: 0,
error: null,
fileName: null
}
});
});
test('has uploadState with idle status', () => {
const state = sessionChatReducer(undefined, { type: 'unknown' });
expect(state.uploadState).toEqual({
status: 'idle',
progress: 0,
error: null,
fileName: null
});
});
});
@ -1292,3 +1311,333 @@ describe('sessionChat selectors', () => {
});
});
});
// Phase 13 Plan 02: Upload state management tests
describe('setUploadStatus reducer', () => {
test('updates upload status', () => {
const state = {
uploadState: {
status: 'idle',
progress: 0,
error: null,
fileName: null
}
};
const action = setUploadStatus({ status: 'uploading', fileName: 'test.pdf' });
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.status).toBe('uploading');
expect(newState.uploadState.fileName).toBe('test.pdf');
});
test('updates progress when provided', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 0,
error: null,
fileName: 'test.pdf'
}
};
const action = setUploadStatus({ progress: 50 });
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.progress).toBe(50);
expect(newState.uploadState.status).toBe('uploading');
});
test('updates error when provided', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 50,
error: null,
fileName: 'test.pdf'
}
};
const action = setUploadStatus({ status: 'error', error: 'Upload failed' });
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.status).toBe('error');
expect(newState.uploadState.error).toBe('Upload failed');
});
test('preserves other fields when updating one field', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 25,
error: null,
fileName: 'document.pdf'
}
};
const action = setUploadStatus({ progress: 75 });
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.progress).toBe(75);
expect(newState.uploadState.status).toBe('uploading');
expect(newState.uploadState.fileName).toBe('document.pdf');
});
});
describe('clearUploadError reducer', () => {
test('clears error and resets status to idle', () => {
const state = {
uploadState: {
status: 'error',
progress: 0,
error: 'Upload failed',
fileName: 'test.pdf'
}
};
const action = clearUploadError();
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.error).toBeNull();
expect(newState.uploadState.status).toBe('idle');
});
test('preserves fileName when clearing error', () => {
const state = {
uploadState: {
status: 'error',
progress: 0,
error: 'File too large',
fileName: 'large-file.pdf'
}
};
const action = clearUploadError();
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.fileName).toBe('large-file.pdf');
expect(newState.uploadState.error).toBeNull();
});
});
describe('uploadAttachment async thunk', () => {
test('sets uploading state on pending', () => {
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' });
const action = {
type: 'sessionChat/uploadAttachment/pending',
meta: {
arg: {
file: mockFile,
sessionId: 'session-123',
clientId: 'client-456'
}
}
};
const state = {
uploadState: {
status: 'idle',
progress: 0,
error: null,
fileName: null
}
};
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.status).toBe('uploading');
expect(newState.uploadState.error).toBeNull();
expect(newState.uploadState.fileName).toBe('test.pdf');
expect(newState.uploadState.progress).toBe(0);
});
test('resets to idle on fulfilled', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 0,
error: null,
fileName: 'test.pdf'
}
};
const action = {
type: 'sessionChat/uploadAttachment/fulfilled',
payload: {
id: 'notation-789',
file_name: 'test.pdf'
}
};
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.status).toBe('idle');
expect(newState.uploadState.fileName).toBeNull();
expect(newState.uploadState.progress).toBe(0);
});
test('sets error state on rejected', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 0,
error: null,
fileName: 'test.pdf'
}
};
const action = {
type: 'sessionChat/uploadAttachment/rejected',
error: { message: 'Upload failed' }
};
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.status).toBe('error');
expect(newState.uploadState.error).toBe('Upload failed');
expect(newState.uploadState.progress).toBe(0);
});
test('handles 413 error with custom message', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 0,
error: null,
fileName: 'large.pdf'
}
};
const action = {
type: 'sessionChat/uploadAttachment/rejected',
error: { message: 'File too large - maximum 10 MB' }
};
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.error).toBe('File too large - maximum 10 MB');
});
test('handles 422 error with custom message', () => {
const state = {
uploadState: {
status: 'uploading',
progress: 0,
error: null,
fileName: 'invalid.exe'
}
};
const action = {
type: 'sessionChat/uploadAttachment/rejected',
error: { message: 'Invalid file type or format' }
};
const newState = sessionChatReducer(state, action);
expect(newState.uploadState.error).toBe('Invalid file type or format');
});
});
// Phase 13 Plan 02: Upload selectors tests
describe('upload selectors', () => {
let mockState;
let selectUploadStatus;
let selectUploadError;
let selectUploadProgress;
let selectUploadFileName;
let selectIsUploading;
beforeEach(() => {
mockState = {
sessionChat: {
uploadState: {
status: 'uploading',
progress: 50,
error: null,
fileName: 'test.pdf'
}
}
};
try {
const slice = require('../sessionChatSlice');
selectUploadStatus = slice.selectUploadStatus;
selectUploadError = slice.selectUploadError;
selectUploadProgress = slice.selectUploadProgress;
selectUploadFileName = slice.selectUploadFileName;
selectIsUploading = slice.selectIsUploading;
} catch (e) {
// Selectors not yet exported
}
});
describe('selectUploadStatus', () => {
test('returns upload status', () => {
if (!selectUploadStatus) {
expect(true).toBe(false); // RED phase
return;
}
const status = selectUploadStatus(mockState);
expect(status).toBe('uploading');
});
});
describe('selectUploadError', () => {
test('returns upload error', () => {
if (!selectUploadError) {
expect(true).toBe(false); // RED phase
return;
}
const error = selectUploadError(mockState);
expect(error).toBeNull();
});
test('returns error message when present', () => {
if (!selectUploadError) {
expect(true).toBe(false); // RED phase
return;
}
mockState.sessionChat.uploadState.error = 'Upload failed';
const error = selectUploadError(mockState);
expect(error).toBe('Upload failed');
});
});
describe('selectUploadProgress', () => {
test('returns upload progress', () => {
if (!selectUploadProgress) {
expect(true).toBe(false); // RED phase
return;
}
const progress = selectUploadProgress(mockState);
expect(progress).toBe(50);
});
});
describe('selectUploadFileName', () => {
test('returns upload file name', () => {
if (!selectUploadFileName) {
expect(true).toBe(false); // RED phase
return;
}
const fileName = selectUploadFileName(mockState);
expect(fileName).toBe('test.pdf');
});
});
describe('selectIsUploading', () => {
test('returns true when status is uploading', () => {
if (!selectIsUploading) {
expect(true).toBe(false); // RED phase
return;
}
const isUploading = selectIsUploading(mockState);
expect(isUploading).toBe(true);
});
test('returns false when status is idle', () => {
if (!selectIsUploading) {
expect(true).toBe(false); // RED phase
return;
}
mockState.sessionChat.uploadState.status = 'idle';
const isUploading = selectIsUploading(mockState);
expect(isUploading).toBe(false);
});
test('returns false when status is error', () => {
if (!selectIsUploading) {
expect(true).toBe(false); // RED phase
return;
}
mockState.sessionChat.uploadState.status = 'error';
const isUploading = selectIsUploading(mockState);
expect(isUploading).toBe(false);
});
});
});

View File

@ -1,5 +1,5 @@
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { getChatMessages, sendChatMessage } from '../../helpers/rest';
import { getChatMessages, sendChatMessage, uploadMusicNotation } from '../../helpers/rest';
import { saveLastReadAt, loadLastReadAt } from '../../helpers/chatStorage';
/**
@ -45,6 +45,49 @@ export const sendMessage = createAsyncThunk(
}
);
/**
* Async thunk to 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');
}
}
);
/**
* Initial state for session chat
* @type {Object}
@ -60,6 +103,11 @@ export const sendMessage = createAsyncThunk(
* @property {Object.<string, number|null>} nextCursors - Pagination cursors per channel
* @property {boolean} isWindowOpen - Whether chat window is open
* @property {Object|null} windowPosition - Window position {x, y}
* @property {Object} uploadState - File upload state tracking
* @property {string} uploadState.status - Upload status ('idle', 'uploading', 'error')
* @property {number} uploadState.progress - Upload progress 0-100
* @property {string|null} uploadState.error - Upload error message
* @property {string|null} uploadState.fileName - Currently uploading file name
*/
const initialState = {
messagesByChannel: {},
@ -73,7 +121,13 @@ const initialState = {
sendError: null,
nextCursors: {},
isWindowOpen: false,
windowPosition: null
windowPosition: null,
uploadState: {
status: 'idle',
progress: 0,
error: null,
fileName: null
}
};
/**
@ -211,6 +265,27 @@ const sessionChatSlice = createSlice({
clearSendError: (state) => {
state.sendError = null;
state.sendStatus = 'idle';
},
/**
* Set upload status
* Updates upload state fields (status, progress, error, fileName)
*/
setUploadStatus: (state, action) => {
const { status, progress, error, fileName } = action.payload;
if (status !== undefined) 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;
},
/**
* Clear upload error
* Resets error to null, status to idle
*/
clearUploadError: (state) => {
state.uploadState.error = null;
state.uploadState.status = 'idle';
}
},
extraReducers: (builder) => {
@ -322,6 +397,26 @@ const sessionChatSlice = createSlice({
messages.splice(index, 1);
}
}
})
// uploadAttachment pending
.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;
})
// uploadAttachment fulfilled
.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
})
// uploadAttachment rejected
.addCase(uploadAttachment.rejected, (state, action) => {
state.uploadState.status = 'error';
state.uploadState.error = action.error.message || 'Upload failed';
state.uploadState.progress = 0;
});
}
});
@ -334,7 +429,9 @@ export const {
markAsRead,
incrementUnreadCount,
setWindowPosition,
clearSendError
clearSendError,
setUploadStatus,
clearUploadError
} = sessionChatSlice.actions;
// Phase 7 Plan 3: Memoized selectors using createSelector from Redux Toolkit
@ -472,4 +569,54 @@ export const selectSendError = createSelector(
(chatState) => chatState.sendError
);
/**
* Select upload status
*
* Returns current upload status: 'idle', 'uploading', or 'error'.
*
* @param {Object} state - Redux state
* @returns {string} Upload status
*/
export const selectUploadStatus = (state) => state.sessionChat.uploadState.status;
/**
* Select upload error message
*
* Returns error message if upload failed.
*
* @param {Object} state - Redux state
* @returns {string|null} Error message, or null if no error
*/
export const selectUploadError = (state) => state.sessionChat.uploadState.error;
/**
* Select upload progress
*
* Returns upload progress 0-100.
*
* @param {Object} state - Redux state
* @returns {number} Upload progress percentage
*/
export const selectUploadProgress = (state) => state.sessionChat.uploadState.progress;
/**
* Select upload file name
*
* Returns currently uploading file name.
*
* @param {Object} state - Redux state
* @returns {string|null} File name, or null if not uploading
*/
export const selectUploadFileName = (state) => state.sessionChat.uploadState.fileName;
/**
* Select whether currently uploading
*
* Returns true if status is 'uploading'.
*
* @param {Object} state - Redux state
* @returns {boolean} True if uploading
*/
export const selectIsUploading = (state) => state.sessionChat.uploadState.status === 'uploading';
export default sessionChatSlice.reducer;