1644 lines
47 KiB
JavaScript
1644 lines
47 KiB
JavaScript
import sessionChatReducer, {
|
|
addMessageFromWebSocket,
|
|
setActiveChannel,
|
|
openChatWindow,
|
|
closeChatWindow,
|
|
markAsRead,
|
|
incrementUnreadCount,
|
|
setWindowPosition,
|
|
setUploadStatus,
|
|
clearUploadError,
|
|
uploadAttachment
|
|
} from '../sessionChatSlice';
|
|
|
|
describe('sessionChatSlice initial state', () => {
|
|
test('has empty messagesByChannel object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.messagesByChannel).toEqual({});
|
|
});
|
|
|
|
test('has null activeChannel', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.activeChannel).toBeNull();
|
|
});
|
|
|
|
test('has null channelType', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.channelType).toBeNull();
|
|
});
|
|
|
|
test('has empty unreadCounts object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.unreadCounts).toEqual({});
|
|
});
|
|
|
|
test('has empty lastReadAt object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.lastReadAt).toEqual({});
|
|
});
|
|
|
|
test('has empty fetchStatus object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.fetchStatus).toEqual({});
|
|
});
|
|
|
|
test('has empty fetchError object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.fetchError).toEqual({});
|
|
});
|
|
|
|
test('has idle sendStatus', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.sendStatus).toBe('idle');
|
|
});
|
|
|
|
test('has null sendError', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.sendError).toBeNull();
|
|
});
|
|
|
|
test('has empty nextCursors object', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.nextCursors).toEqual({});
|
|
});
|
|
|
|
test('has isWindowOpen set to false', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.isWindowOpen).toBe(false);
|
|
});
|
|
|
|
test('has null windowPosition', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state.windowPosition).toBeNull();
|
|
});
|
|
|
|
test('complete initial state structure matches design', () => {
|
|
const state = sessionChatReducer(undefined, { type: 'unknown' });
|
|
expect(state).toEqual({
|
|
messagesByChannel: {},
|
|
activeChannel: null,
|
|
channelType: null,
|
|
unreadCounts: {},
|
|
lastReadAt: {},
|
|
fetchStatus: {},
|
|
fetchError: {},
|
|
sendStatus: 'idle',
|
|
sendError: null,
|
|
nextCursors: {},
|
|
isWindowOpen: false,
|
|
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
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('addMessageFromWebSocket', () => {
|
|
test('adds message to new channel', () => {
|
|
const state = {
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const message = {
|
|
id: 'msg-1',
|
|
senderId: 'user-1',
|
|
senderName: 'Alice',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.messagesByChannel['session-abc']).toBeDefined();
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(newState.messagesByChannel['session-abc'][0]).toEqual(message);
|
|
});
|
|
|
|
test('adds message to existing channel', () => {
|
|
const existingMessage = {
|
|
id: 'msg-1',
|
|
message: 'Hello',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [existingMessage]
|
|
},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newMessage = {
|
|
id: 'msg-2',
|
|
senderId: 'user-2',
|
|
senderName: 'Bob',
|
|
message: 'Hi there',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:01:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(newMessage);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(2);
|
|
expect(newState.messagesByChannel['session-abc'][1]).toEqual(newMessage);
|
|
});
|
|
|
|
test('deduplicates messages by msg_id', () => {
|
|
const message = {
|
|
id: 'msg-1',
|
|
senderId: 'user-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [message]
|
|
},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
// Try to add same message again
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
});
|
|
|
|
test('sorts messages by createdAt ascending', () => {
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'msg-2', message: 'Second', createdAt: '2026-01-26T12:01:00Z' }
|
|
]
|
|
},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const earlierMessage = {
|
|
id: 'msg-1',
|
|
senderId: 'user-1',
|
|
message: 'First',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(earlierMessage);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(2);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-1');
|
|
expect(newState.messagesByChannel['session-abc'][1].id).toBe('msg-2');
|
|
});
|
|
|
|
test('increments unread count if window closed', () => {
|
|
const state = {
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: 'session-abc'
|
|
};
|
|
const message = {
|
|
id: 'msg-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(1);
|
|
});
|
|
|
|
test('increments unread count if viewing different channel', () => {
|
|
const state = {
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: true,
|
|
activeChannel: 'session-xyz'
|
|
};
|
|
const message = {
|
|
id: 'msg-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(1);
|
|
});
|
|
|
|
test('does not increment unread count if window open and viewing same channel', () => {
|
|
const state = {
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: true,
|
|
activeChannel: 'session-abc'
|
|
};
|
|
const message = {
|
|
id: 'msg-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBeUndefined();
|
|
});
|
|
|
|
test('handles global channel correctly', () => {
|
|
const state = {
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const message = {
|
|
id: 'msg-1',
|
|
message: 'Hello world',
|
|
channel: 'global',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
const action = addMessageFromWebSocket(message);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.messagesByChannel['global']).toBeDefined();
|
|
expect(newState.messagesByChannel['global']).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('setActiveChannel', () => {
|
|
test('sets active channel and type', () => {
|
|
const state = {
|
|
activeChannel: null,
|
|
channelType: null
|
|
};
|
|
const action = setActiveChannel({
|
|
channel: 'session-abc',
|
|
channelType: 'session'
|
|
});
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.activeChannel).toBe('session-abc');
|
|
expect(newState.channelType).toBe('session');
|
|
});
|
|
|
|
test('updates existing active channel', () => {
|
|
const state = {
|
|
activeChannel: 'session-abc',
|
|
channelType: 'session'
|
|
};
|
|
const action = setActiveChannel({
|
|
channel: 'global',
|
|
channelType: 'global'
|
|
});
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.activeChannel).toBe('global');
|
|
expect(newState.channelType).toBe('global');
|
|
});
|
|
});
|
|
|
|
describe('openChatWindow', () => {
|
|
test('opens window and resets unread count for active channel', () => {
|
|
const state = {
|
|
isWindowOpen: false,
|
|
activeChannel: 'session-abc',
|
|
unreadCounts: { 'session-abc': 5 },
|
|
lastReadAt: {}
|
|
};
|
|
const action = openChatWindow();
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.isWindowOpen).toBe(true);
|
|
expect(newState.unreadCounts['session-abc']).toBe(0);
|
|
expect(newState.lastReadAt['session-abc']).toBeTruthy();
|
|
// Verify it's an ISO timestamp
|
|
expect(newState.lastReadAt['session-abc']).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
});
|
|
|
|
test('opens window without active channel', () => {
|
|
const state = {
|
|
isWindowOpen: false,
|
|
activeChannel: null,
|
|
unreadCounts: {},
|
|
lastReadAt: {}
|
|
};
|
|
const action = openChatWindow();
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.isWindowOpen).toBe(true);
|
|
expect(Object.keys(newState.unreadCounts)).toHaveLength(0);
|
|
});
|
|
|
|
test('preserves other channel unread counts', () => {
|
|
const state = {
|
|
isWindowOpen: false,
|
|
activeChannel: 'session-abc',
|
|
unreadCounts: { 'session-abc': 5, 'global': 3 },
|
|
lastReadAt: {}
|
|
};
|
|
const action = openChatWindow();
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(0);
|
|
expect(newState.unreadCounts['global']).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('closeChatWindow', () => {
|
|
test('closes window without changing unread counts', () => {
|
|
const state = {
|
|
isWindowOpen: true,
|
|
unreadCounts: { 'session-abc': 0, 'global': 2 }
|
|
};
|
|
const action = closeChatWindow();
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.isWindowOpen).toBe(false);
|
|
expect(newState.unreadCounts['session-abc']).toBe(0);
|
|
expect(newState.unreadCounts['global']).toBe(2);
|
|
});
|
|
|
|
test('closes already closed window', () => {
|
|
const state = {
|
|
isWindowOpen: false,
|
|
unreadCounts: {}
|
|
};
|
|
const action = closeChatWindow();
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.isWindowOpen).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('markAsRead', () => {
|
|
test('resets unread count for specified channel', () => {
|
|
const state = {
|
|
unreadCounts: { 'session-abc': 5, 'global': 2 },
|
|
lastReadAt: {}
|
|
};
|
|
const action = markAsRead({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(0);
|
|
expect(newState.unreadCounts['global']).toBe(2); // Unchanged
|
|
expect(newState.lastReadAt['session-abc']).toBeTruthy();
|
|
expect(newState.lastReadAt['session-abc']).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
});
|
|
|
|
test('handles channel with no existing unread count', () => {
|
|
const state = {
|
|
unreadCounts: {},
|
|
lastReadAt: {}
|
|
};
|
|
const action = markAsRead({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(0);
|
|
expect(newState.lastReadAt['session-abc']).toBeTruthy();
|
|
});
|
|
|
|
test('updates lastReadAt timestamp', () => {
|
|
const oldTimestamp = '2026-01-26T10:00:00.000Z';
|
|
const state = {
|
|
unreadCounts: { 'session-abc': 3 },
|
|
lastReadAt: { 'session-abc': oldTimestamp }
|
|
};
|
|
const action = markAsRead({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.lastReadAt['session-abc']).not.toBe(oldTimestamp);
|
|
expect(new Date(newState.lastReadAt['session-abc']).getTime()).toBeGreaterThan(
|
|
new Date(oldTimestamp).getTime()
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('incrementUnreadCount', () => {
|
|
test('increments unread count for channel', () => {
|
|
const state = {
|
|
unreadCounts: { 'session-abc': 3 }
|
|
};
|
|
const action = incrementUnreadCount({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(4);
|
|
});
|
|
|
|
test('initializes unread count if not exists', () => {
|
|
const state = {
|
|
unreadCounts: {}
|
|
};
|
|
const action = incrementUnreadCount({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(1);
|
|
});
|
|
|
|
test('handles multiple channels independently', () => {
|
|
const state = {
|
|
unreadCounts: { 'session-abc': 2, 'global': 5 }
|
|
};
|
|
const action = incrementUnreadCount({ channel: 'session-abc' });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.unreadCounts['session-abc']).toBe(3);
|
|
expect(newState.unreadCounts['global']).toBe(5); // Unchanged
|
|
});
|
|
});
|
|
|
|
describe('setWindowPosition', () => {
|
|
test('updates window position', () => {
|
|
const state = {
|
|
windowPosition: null
|
|
};
|
|
const action = setWindowPosition({ x: 300, y: 400 });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.windowPosition).toEqual({ x: 300, y: 400 });
|
|
});
|
|
|
|
test('overwrites existing position', () => {
|
|
const state = {
|
|
windowPosition: { x: 100, y: 200 }
|
|
};
|
|
const action = setWindowPosition({ x: 500, y: 600 });
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.windowPosition).toEqual({ x: 500, y: 600 });
|
|
});
|
|
|
|
test('can set position to null', () => {
|
|
const state = {
|
|
windowPosition: { x: 300, y: 400 }
|
|
};
|
|
const action = setWindowPosition(null);
|
|
const newState = sessionChatReducer(state, action);
|
|
|
|
expect(newState.windowPosition).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchChatHistory async thunk', () => {
|
|
// Import will be added in implementation phase
|
|
// This is the RED phase - tests should fail
|
|
let fetchChatHistory;
|
|
|
|
test('sets loading state on pending', () => {
|
|
// Mock the pending action type
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/pending',
|
|
meta: { arg: { channel: 'session-abc' } }
|
|
};
|
|
const state = {
|
|
fetchStatus: {},
|
|
fetchError: {},
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.fetchStatus['session-abc']).toBe('loading');
|
|
expect(newState.fetchError['session-abc']).toBeNull();
|
|
});
|
|
|
|
test('adds messages on fulfilled', () => {
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/fulfilled',
|
|
meta: { arg: { channel: 'session-abc' } },
|
|
payload: {
|
|
channel: 'session-abc',
|
|
messages: [
|
|
{ id: 'msg-1', message: 'Hello', sender_id: 'user-1', created_at: '2026-01-26T12:00:00Z' }
|
|
],
|
|
next: 20
|
|
}
|
|
};
|
|
const state = {
|
|
messagesByChannel: {},
|
|
fetchStatus: { 'session-abc': 'loading' },
|
|
fetchError: {},
|
|
nextCursors: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(newState.fetchStatus['session-abc']).toBe('succeeded');
|
|
expect(newState.nextCursors['session-abc']).toBe(20);
|
|
});
|
|
|
|
test('deduplicates messages on fulfilled', () => {
|
|
// Test that fetched messages don't duplicate existing ones
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [{ id: 'msg-1', message: 'Hello', createdAt: '2026-01-26T12:00:00Z' }]
|
|
},
|
|
fetchStatus: { 'session-abc': 'loading' },
|
|
fetchError: {},
|
|
nextCursors: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/fulfilled',
|
|
meta: { arg: { channel: 'session-abc' } },
|
|
payload: {
|
|
channel: 'session-abc',
|
|
messages: [
|
|
{ id: 'msg-1', message: 'Hello', createdAt: '2026-01-26T12:00:00Z' }, // Duplicate
|
|
{ id: 'msg-2', message: 'World', createdAt: '2026-01-26T12:01:00Z' } // New
|
|
],
|
|
next: null
|
|
}
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(2);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-1');
|
|
expect(newState.messagesByChannel['session-abc'][1].id).toBe('msg-2');
|
|
});
|
|
|
|
test('prepends older messages for pagination', () => {
|
|
// When fetching older messages, they should be prepended (oldest first)
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'msg-3', message: 'Newest', createdAt: '2026-01-26T12:02:00Z' }
|
|
]
|
|
},
|
|
fetchStatus: { 'session-abc': 'loading' },
|
|
fetchError: {},
|
|
nextCursors: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/fulfilled',
|
|
meta: { arg: { channel: 'session-abc', before: 3 } },
|
|
payload: {
|
|
channel: 'session-abc',
|
|
messages: [
|
|
{ id: 'msg-1', message: 'Oldest', createdAt: '2026-01-26T12:00:00Z' },
|
|
{ id: 'msg-2', message: 'Middle', createdAt: '2026-01-26T12:01:00Z' }
|
|
],
|
|
next: null
|
|
}
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(3);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-1');
|
|
expect(newState.messagesByChannel['session-abc'][1].id).toBe('msg-2');
|
|
expect(newState.messagesByChannel['session-abc'][2].id).toBe('msg-3');
|
|
});
|
|
|
|
test('sets error state on rejected', () => {
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/rejected',
|
|
meta: { arg: { channel: 'session-abc' } },
|
|
error: { message: 'Not Found' }
|
|
};
|
|
const state = {
|
|
fetchStatus: { 'session-abc': 'loading' },
|
|
fetchError: {},
|
|
messagesByChannel: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.fetchStatus['session-abc']).toBe('failed');
|
|
expect(newState.fetchError['session-abc']).toBe('Not Found');
|
|
});
|
|
|
|
test('handles null next cursor', () => {
|
|
const action = {
|
|
type: 'sessionChat/fetchChatHistory/fulfilled',
|
|
meta: { arg: { channel: 'session-abc' } },
|
|
payload: {
|
|
channel: 'session-abc',
|
|
messages: [
|
|
{ id: 'msg-1', message: 'Hello', createdAt: '2026-01-26T12:00:00Z' }
|
|
],
|
|
next: null
|
|
}
|
|
};
|
|
const state = {
|
|
messagesByChannel: {},
|
|
fetchStatus: { 'session-abc': 'loading' },
|
|
fetchError: {},
|
|
nextCursors: {},
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.nextCursors['session-abc']).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('sendMessage async thunk', () => {
|
|
// Import will be added in implementation phase
|
|
// This is the RED phase - tests should fail
|
|
let sendMessage;
|
|
|
|
test('sets loading state on pending', () => {
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/pending',
|
|
meta: {
|
|
arg: {
|
|
channel: 'session-abc',
|
|
message: 'Hello world',
|
|
optimisticId: 'temp-1',
|
|
userId: 'user-1',
|
|
userName: 'John Doe'
|
|
}
|
|
}
|
|
};
|
|
const state = {
|
|
sendStatus: 'idle',
|
|
sendError: null,
|
|
messagesByChannel: { 'session-abc': [] },
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.sendStatus).toBe('loading');
|
|
expect(newState.sendError).toBeNull();
|
|
});
|
|
|
|
test('adds message optimistically on pending', () => {
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/pending',
|
|
meta: {
|
|
arg: {
|
|
channel: 'session-abc',
|
|
message: 'Hello world',
|
|
optimisticId: 'temp-1',
|
|
userId: 'user-1',
|
|
userName: 'John Doe'
|
|
}
|
|
}
|
|
};
|
|
const state = {
|
|
messagesByChannel: { 'session-abc': [] },
|
|
sendStatus: 'idle',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('temp-1');
|
|
expect(newState.messagesByChannel['session-abc'][0].message).toBe('Hello world');
|
|
expect(newState.messagesByChannel['session-abc'][0].senderId).toBe('user-1');
|
|
expect(newState.messagesByChannel['session-abc'][0].senderName).toBe('John Doe');
|
|
expect(newState.messagesByChannel['session-abc'][0].isOptimistic).toBe(true);
|
|
});
|
|
|
|
test('initializes channel if not exists on pending', () => {
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/pending',
|
|
meta: {
|
|
arg: {
|
|
channel: 'new-channel',
|
|
message: 'First message',
|
|
optimisticId: 'temp-1',
|
|
userId: 'user-1',
|
|
userName: 'John'
|
|
}
|
|
}
|
|
};
|
|
const state = {
|
|
messagesByChannel: {},
|
|
sendStatus: 'idle',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['new-channel']).toBeDefined();
|
|
expect(newState.messagesByChannel['new-channel']).toHaveLength(1);
|
|
});
|
|
|
|
test('replaces optimistic message with real one on fulfilled', () => {
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'temp-1', message: 'Hello world', senderId: 'user-1', isOptimistic: true }
|
|
]
|
|
},
|
|
sendStatus: 'loading',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/fulfilled',
|
|
meta: {
|
|
arg: { optimisticId: 'temp-1', channel: 'session-abc' }
|
|
},
|
|
payload: {
|
|
message: {
|
|
id: 'msg-real',
|
|
message: 'Hello world',
|
|
sender_id: 'user-1',
|
|
sender_name: 'John Doe',
|
|
created_at: '2026-01-26T12:00:00Z',
|
|
channel: 'session'
|
|
}
|
|
}
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-real');
|
|
expect(newState.messagesByChannel['session-abc'][0].isOptimistic).toBeUndefined();
|
|
expect(newState.sendStatus).toBe('succeeded');
|
|
});
|
|
|
|
test('removes optimistic message on rejected', () => {
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'temp-1', message: 'Hello world', senderId: 'user-1', isOptimistic: true }
|
|
]
|
|
},
|
|
sendStatus: 'loading',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/rejected',
|
|
meta: {
|
|
arg: { optimisticId: 'temp-1', channel: 'session-abc' }
|
|
},
|
|
error: { message: 'Network error' }
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(0);
|
|
expect(newState.sendStatus).toBe('failed');
|
|
expect(newState.sendError).toBe('Network error');
|
|
});
|
|
|
|
test('keeps other messages when replacing optimistic message', () => {
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'msg-1', message: 'Existing', createdAt: '2026-01-26T11:00:00Z' },
|
|
{ id: 'temp-1', message: 'Optimistic', senderId: 'user-1', isOptimistic: true }
|
|
]
|
|
},
|
|
sendStatus: 'loading',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/fulfilled',
|
|
meta: {
|
|
arg: { optimisticId: 'temp-1', channel: 'session-abc' }
|
|
},
|
|
payload: {
|
|
message: {
|
|
id: 'msg-real',
|
|
message: 'Optimistic',
|
|
sender_id: 'user-1',
|
|
created_at: '2026-01-26T12:00:00Z'
|
|
}
|
|
}
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(2);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-1');
|
|
expect(newState.messagesByChannel['session-abc'][1].id).toBe('msg-real');
|
|
});
|
|
|
|
test('keeps other messages when removing optimistic message', () => {
|
|
const state = {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'msg-1', message: 'Existing', createdAt: '2026-01-26T11:00:00Z' },
|
|
{ id: 'temp-1', message: 'Failed', senderId: 'user-1', isOptimistic: true }
|
|
]
|
|
},
|
|
sendStatus: 'loading',
|
|
sendError: null,
|
|
unreadCounts: {},
|
|
isWindowOpen: false,
|
|
activeChannel: null
|
|
};
|
|
const action = {
|
|
type: 'sessionChat/sendMessage/rejected',
|
|
meta: {
|
|
arg: { optimisticId: 'temp-1', channel: 'session-abc' }
|
|
},
|
|
error: { message: 'Failed to send' }
|
|
};
|
|
const newState = sessionChatReducer(state, action);
|
|
expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-1');
|
|
});
|
|
});
|
|
|
|
describe('sessionChat integration', () => {
|
|
test('complete message flow: receive → set active → open window → mark read', () => {
|
|
const initialState = {
|
|
messagesByChannel: {},
|
|
activeChannel: null,
|
|
channelType: null,
|
|
unreadCounts: {},
|
|
lastReadAt: {},
|
|
isWindowOpen: false,
|
|
windowPosition: null
|
|
};
|
|
|
|
// Step 1: Receive message (window closed, no active channel)
|
|
let state = sessionChatReducer(
|
|
initialState,
|
|
addMessageFromWebSocket({
|
|
id: 'msg-1',
|
|
senderId: 'user-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
})
|
|
);
|
|
expect(state.messagesByChannel['session-abc']).toHaveLength(1);
|
|
expect(state.unreadCounts['session-abc']).toBe(1);
|
|
|
|
// Step 2: Set active channel
|
|
state = sessionChatReducer(
|
|
state,
|
|
setActiveChannel({ channel: 'session-abc', channelType: 'session' })
|
|
);
|
|
expect(state.activeChannel).toBe('session-abc');
|
|
expect(state.channelType).toBe('session');
|
|
|
|
// Step 3: Open window (should mark as read)
|
|
state = sessionChatReducer(state, openChatWindow());
|
|
expect(state.isWindowOpen).toBe(true);
|
|
expect(state.unreadCounts['session-abc']).toBe(0);
|
|
expect(state.lastReadAt['session-abc']).toBeTruthy();
|
|
|
|
// Step 4: Receive another message while window open and viewing channel
|
|
state = sessionChatReducer(
|
|
state,
|
|
addMessageFromWebSocket({
|
|
id: 'msg-2',
|
|
senderId: 'user-2',
|
|
message: 'Hi back',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:01:00Z'
|
|
})
|
|
);
|
|
expect(state.messagesByChannel['session-abc']).toHaveLength(2);
|
|
expect(state.unreadCounts['session-abc']).toBe(0); // Should NOT increment
|
|
|
|
// Step 5: Close window
|
|
state = sessionChatReducer(state, closeChatWindow());
|
|
expect(state.isWindowOpen).toBe(false);
|
|
|
|
// Step 6: Receive message while window closed
|
|
state = sessionChatReducer(
|
|
state,
|
|
addMessageFromWebSocket({
|
|
id: 'msg-3',
|
|
senderId: 'user-1',
|
|
message: 'Are you there?',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:02:00Z'
|
|
})
|
|
);
|
|
expect(state.messagesByChannel['session-abc']).toHaveLength(3);
|
|
expect(state.unreadCounts['session-abc']).toBe(1); // Should increment
|
|
});
|
|
|
|
test('multi-channel flow: messages in different channels', () => {
|
|
const initialState = {
|
|
messagesByChannel: {},
|
|
activeChannel: null,
|
|
channelType: null,
|
|
unreadCounts: {},
|
|
lastReadAt: {},
|
|
isWindowOpen: true,
|
|
windowPosition: null
|
|
};
|
|
|
|
// Receive message in session-abc (window open, but no active channel)
|
|
let state = sessionChatReducer(
|
|
initialState,
|
|
addMessageFromWebSocket({
|
|
id: 'msg-1',
|
|
message: 'Session message',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
})
|
|
);
|
|
expect(state.unreadCounts['session-abc']).toBe(1); // Increments (no active channel)
|
|
|
|
// Set active to session-abc
|
|
state = sessionChatReducer(
|
|
state,
|
|
setActiveChannel({ channel: 'session-abc', channelType: 'session' })
|
|
);
|
|
|
|
// Receive message in global (window open, but viewing session-abc)
|
|
state = sessionChatReducer(
|
|
state,
|
|
addMessageFromWebSocket({
|
|
id: 'msg-2',
|
|
message: 'Global message',
|
|
channel: 'global',
|
|
createdAt: '2026-01-26T12:01:00Z'
|
|
})
|
|
);
|
|
expect(state.unreadCounts['global']).toBe(1); // Increments (different channel)
|
|
|
|
// Mark global as read
|
|
state = sessionChatReducer(state, markAsRead({ channel: 'global' }));
|
|
expect(state.unreadCounts['global']).toBe(0);
|
|
expect(state.unreadCounts['session-abc']).toBe(1); // Unchanged
|
|
});
|
|
|
|
test('message deduplication prevents double unread increment', () => {
|
|
const initialState = {
|
|
messagesByChannel: {},
|
|
activeChannel: null,
|
|
channelType: null,
|
|
unreadCounts: {},
|
|
lastReadAt: {},
|
|
isWindowOpen: false,
|
|
windowPosition: null
|
|
};
|
|
|
|
const message = {
|
|
id: 'msg-1',
|
|
message: 'Hello',
|
|
channel: 'session',
|
|
sessionId: 'session-abc',
|
|
createdAt: '2026-01-26T12:00:00Z'
|
|
};
|
|
|
|
// Add message first time
|
|
let state = sessionChatReducer(initialState, addMessageFromWebSocket(message));
|
|
expect(state.unreadCounts['session-abc']).toBe(1);
|
|
|
|
// Try to add same message again (WebSocket + REST scenario)
|
|
state = sessionChatReducer(state, addMessageFromWebSocket(message));
|
|
expect(state.unreadCounts['session-abc']).toBe(1); // Should NOT increment again
|
|
expect(state.messagesByChannel['session-abc']).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('sessionChat selectors', () => {
|
|
let mockState;
|
|
|
|
beforeEach(() => {
|
|
mockState = {
|
|
sessionChat: {
|
|
messagesByChannel: {
|
|
'session-abc': [
|
|
{ id: 'msg-1', message: 'Hello', createdAt: '2026-01-26T12:00:00Z' },
|
|
{ id: 'msg-2', message: 'World', createdAt: '2026-01-26T12:01:00Z' }
|
|
],
|
|
'global': [
|
|
{ id: 'msg-3', message: 'Global msg', createdAt: '2026-01-26T12:02:00Z' }
|
|
]
|
|
},
|
|
activeChannel: 'session-abc',
|
|
unreadCounts: {
|
|
'session-abc': 0,
|
|
'global': 5,
|
|
'session-xyz': 3
|
|
},
|
|
isWindowOpen: true,
|
|
fetchStatus: {
|
|
'session-abc': 'succeeded',
|
|
'global': 'loading'
|
|
},
|
|
sendStatus: 'idle',
|
|
sendError: null
|
|
}
|
|
};
|
|
});
|
|
|
|
describe('selectChatMessages', () => {
|
|
let selectChatMessages;
|
|
|
|
beforeAll(() => {
|
|
// Import will be added after implementation
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectChatMessages = slice.selectChatMessages;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns messages for specified channel', () => {
|
|
if (!selectChatMessages) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const messages = selectChatMessages(mockState, 'session-abc');
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[0].id).toBe('msg-1');
|
|
expect(messages[1].id).toBe('msg-2');
|
|
});
|
|
|
|
test('returns empty array for non-existent channel', () => {
|
|
if (!selectChatMessages) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const messages = selectChatMessages(mockState, 'session-nonexistent');
|
|
expect(messages).toEqual([]);
|
|
});
|
|
|
|
test('memoizes result for same channel', () => {
|
|
if (!selectChatMessages) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const result1 = selectChatMessages(mockState, 'session-abc');
|
|
const result2 = selectChatMessages(mockState, 'session-abc');
|
|
expect(result1).toBe(result2); // Same reference
|
|
});
|
|
});
|
|
|
|
describe('selectUnreadCount', () => {
|
|
let selectUnreadCount;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectUnreadCount = slice.selectUnreadCount;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns unread count for channel', () => {
|
|
if (!selectUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const count = selectUnreadCount(mockState, 'global');
|
|
expect(count).toBe(5);
|
|
});
|
|
|
|
test('returns 0 for channel with no unread', () => {
|
|
if (!selectUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const count = selectUnreadCount(mockState, 'session-abc');
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
test('returns 0 for non-existent channel', () => {
|
|
if (!selectUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const count = selectUnreadCount(mockState, 'nonexistent');
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('selectTotalUnreadCount', () => {
|
|
let selectTotalUnreadCount;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectTotalUnreadCount = slice.selectTotalUnreadCount;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('sums all unread counts across channels', () => {
|
|
if (!selectTotalUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const total = selectTotalUnreadCount(mockState);
|
|
expect(total).toBe(8); // 0 + 5 + 3
|
|
});
|
|
|
|
test('returns 0 when no unread messages', () => {
|
|
if (!selectTotalUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const emptyState = {
|
|
sessionChat: { unreadCounts: {} }
|
|
};
|
|
const total = selectTotalUnreadCount(emptyState);
|
|
expect(total).toBe(0);
|
|
});
|
|
|
|
test('memoizes result', () => {
|
|
if (!selectTotalUnreadCount) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const result1 = selectTotalUnreadCount(mockState);
|
|
const result2 = selectTotalUnreadCount(mockState);
|
|
expect(result1).toBe(result2);
|
|
});
|
|
});
|
|
|
|
describe('selectIsChatWindowOpen', () => {
|
|
let selectIsChatWindowOpen;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectIsChatWindowOpen = slice.selectIsChatWindowOpen;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns window open state', () => {
|
|
if (!selectIsChatWindowOpen) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const isOpen = selectIsChatWindowOpen(mockState);
|
|
expect(isOpen).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('selectActiveChannel', () => {
|
|
let selectActiveChannel;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectActiveChannel = slice.selectActiveChannel;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns active channel', () => {
|
|
if (!selectActiveChannel) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const channel = selectActiveChannel(mockState);
|
|
expect(channel).toBe('session-abc');
|
|
});
|
|
});
|
|
|
|
describe('selectFetchStatus', () => {
|
|
let selectFetchStatus;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectFetchStatus = slice.selectFetchStatus;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns fetch status for channel', () => {
|
|
if (!selectFetchStatus) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const status = selectFetchStatus(mockState, 'session-abc');
|
|
expect(status).toBe('succeeded');
|
|
});
|
|
|
|
test('returns idle for non-existent channel', () => {
|
|
if (!selectFetchStatus) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const status = selectFetchStatus(mockState, 'nonexistent');
|
|
expect(status).toBe('idle');
|
|
});
|
|
});
|
|
|
|
describe('selectSendStatus', () => {
|
|
let selectSendStatus;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectSendStatus = slice.selectSendStatus;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns send status', () => {
|
|
if (!selectSendStatus) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const status = selectSendStatus(mockState);
|
|
expect(status).toBe('idle');
|
|
});
|
|
});
|
|
|
|
describe('selectSendError', () => {
|
|
let selectSendError;
|
|
|
|
beforeAll(() => {
|
|
try {
|
|
const slice = require('../sessionChatSlice');
|
|
selectSendError = slice.selectSendError;
|
|
} catch (e) {
|
|
// Selector not yet exported
|
|
}
|
|
});
|
|
|
|
test('returns send error', () => {
|
|
if (!selectSendError) {
|
|
expect(true).toBe(false); // RED phase
|
|
return;
|
|
}
|
|
const error = selectSendError(mockState);
|
|
expect(error).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|