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:
parent
878e9e44aa
commit
3b52b58cc5
|
|
@ -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));
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue