jam-cloud/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js

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);
});
});
});