import { usersReducer, initialState, UsersState } from './users.reducer'; import { UsersActions } from './users.actions'; import { User } from '../../shared-kernel'; function createUser(overrides: Partial = {}): User { return { id: 'user-1', oderId: 'oder-1', username: 'testuser', displayName: 'Test User', status: 'online', role: 'member', joinedAt: Date.now(), ...overrides }; } describe('users reducer - status', () => { let baseState: UsersState; beforeEach(() => { const user = createUser(); baseState = usersReducer( initialState, UsersActions.setCurrentUser({ user }) ); }); describe('setManualStatus', () => { it('sets manualStatus in state and updates current user status', () => { const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' })); expect(state.manualStatus).toBe('busy'); expect(state.entities['user-1']?.status).toBe('busy'); }); it('clears manual status when null and sets online', () => { const intermediate = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' })); const state = usersReducer(intermediate, UsersActions.setManualStatus({ status: null })); expect(state.manualStatus).toBeNull(); expect(state.entities['user-1']?.status).toBe('online'); }); it('sets away status correctly', () => { const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'away' })); expect(state.manualStatus).toBe('away'); expect(state.entities['user-1']?.status).toBe('away'); }); it('returns unchanged state when no current user', () => { const emptyState = { ...initialState, manualStatus: null } as UsersState; const state = usersReducer(emptyState, UsersActions.setManualStatus({ status: 'busy' })); expect(state.manualStatus).toBe('busy'); // No user entities to update }); }); describe('updateRemoteUserStatus', () => { it('updates status of an existing remote user', () => { const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' }); const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'away' })); expect(state.entities['remote-1']?.status).toBe('away'); }); it('updates remote user to busy (DND)', () => { const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' }); const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'busy' })); expect(state.entities['remote-1']?.status).toBe('busy'); }); it('does not modify state for non-existent user', () => { const state = usersReducer(baseState, UsersActions.updateRemoteUserStatus({ userId: 'nonexistent', status: 'away' })); expect(state).toBe(baseState); }); }); describe('avatar updates', () => { it('updates current user avatar metadata', () => { const state = usersReducer(baseState, UsersActions.updateCurrentUserAvatar({ avatar: { avatarUrl: 'data:image/webp;base64,abc123', avatarHash: 'hash-1', avatarMime: 'image/webp', avatarUpdatedAt: 1234 } })); expect(state.entities['user-1']?.avatarUrl).toBe('data:image/webp;base64,abc123'); expect(state.entities['user-1']?.avatarHash).toBe('hash-1'); expect(state.entities['user-1']?.avatarMime).toBe('image/webp'); expect(state.entities['user-1']?.avatarUpdatedAt).toBe(1234); }); it('keeps newer remote avatar when stale update arrives later', () => { const withRemote = usersReducer( baseState, UsersActions.upsertRemoteUserAvatar({ user: { id: 'remote-1', oderId: 'oder-remote-1', username: 'remote', displayName: 'Remote', avatarUrl: 'data:image/webp;base64,newer', avatarHash: 'hash-newer', avatarMime: 'image/webp', avatarUpdatedAt: 200 } }) ); const state = usersReducer( withRemote, UsersActions.upsertRemoteUserAvatar({ user: { id: 'remote-1', oderId: 'oder-remote-1', username: 'remote', displayName: 'Remote', avatarUrl: 'data:image/webp;base64,older', avatarHash: 'hash-older', avatarMime: 'image/webp', avatarUpdatedAt: 100 } }) ); expect(state.entities['remote-1']?.avatarUrl).toBe('data:image/webp;base64,newer'); expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer'); expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200); }); it('updates the current user profile metadata', () => { const state = usersReducer(baseState, UsersActions.updateCurrentUserProfile({ profile: { displayName: 'Updated User', description: 'New description', profileUpdatedAt: 4567 } })); expect(state.entities['user-1']?.displayName).toBe('Updated User'); expect(state.entities['user-1']?.description).toBe('New description'); expect(state.entities['user-1']?.profileUpdatedAt).toBe(4567); }); it('keeps newer remote profile text when stale profile data arrives later', () => { const withRemote = usersReducer( baseState, UsersActions.upsertRemoteUserAvatar({ user: { id: 'remote-1', oderId: 'oder-remote-1', username: 'remote', displayName: 'Remote Newer', description: 'Newest bio', profileUpdatedAt: 300 } }) ); const state = usersReducer( withRemote, UsersActions.upsertRemoteUserAvatar({ user: { id: 'remote-1', oderId: 'oder-remote-1', username: 'remote', displayName: 'Remote Older', description: 'Old bio', profileUpdatedAt: 100 } }) ); expect(state.entities['remote-1']?.displayName).toBe('Remote Newer'); expect(state.entities['remote-1']?.description).toBe('Newest bio'); expect(state.entities['remote-1']?.profileUpdatedAt).toBe(300); }); it('allows remote profile-only sync updates without avatar bytes', () => { const state = usersReducer( baseState, UsersActions.upsertRemoteUserAvatar({ user: { id: 'remote-2', oderId: 'oder-remote-2', username: 'remote2', displayName: 'Remote Profile', description: 'Profile only sync', profileUpdatedAt: 700 } }) ); expect(state.entities['remote-2']?.displayName).toBe('Remote Profile'); expect(state.entities['remote-2']?.description).toBe('Profile only sync'); expect(state.entities['remote-2']?.profileUpdatedAt).toBe(700); expect(state.entities['remote-2']?.avatarUrl).toBeUndefined(); }); }); describe('presence-aware user with status', () => { it('preserves incoming status on user join', () => { const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] }); const state = usersReducer(baseState, UsersActions.userJoined({ user })); expect(state.entities['away-user']?.status).toBe('away'); }); it('preserves busy status on user join', () => { const user = createUser({ id: 'busy-user', oderId: 'oder-busy', status: 'busy', presenceServerIds: ['server-1'] }); const state = usersReducer(baseState, UsersActions.userJoined({ user })); expect(state.entities['busy-user']?.status).toBe('busy'); }); it('preserves existing non-offline status on sync when incoming is online', () => { const awayUser = createUser({ id: 'u1', oderId: 'u1', status: 'busy', presenceServerIds: ['s1'] }); const withUser = usersReducer(baseState, UsersActions.userJoined({ user: awayUser })); // Sync sends status: 'online' but user is manually 'busy' const syncedUser = createUser({ id: 'u1', oderId: 'u1', status: 'online', presenceServerIds: ['s1'] }); const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [syncedUser] })); // The buildPresenceAwareUser function takes incoming status when non-offline expect(state.entities['u1']?.status).toBe('online'); }); it('preserves omitted live peer presence and voice state during stale server snapshot', () => { const remoteUser = createUser({ id: 'u2', oderId: 'u2', displayName: 'Voice Peer', presenceServerIds: ['s1'], voiceState: { isConnected: true, isMuted: false, isDeafened: false, isSpeaking: true, roomId: 'voice-1', serverId: 's1' }, cameraState: { isEnabled: true }, screenShareState: { isSharing: true } }); const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [], connectedPeerIds: ['u2'] })); expect(state.entities['u2']?.presenceServerIds).toEqual(['s1']); expect(state.entities['u2']?.isOnline).toBe(true); expect(state.entities['u2']?.voiceState?.isConnected).toBe(true); expect(state.entities['u2']?.voiceState?.roomId).toBe('voice-1'); expect(state.entities['u2']?.cameraState?.isEnabled).toBe(true); expect(state.entities['u2']?.screenShareState?.isSharing).toBe(true); }); it('clears omitted peer live state when transport is gone', () => { const remoteUser = createUser({ id: 'u3', oderId: 'u3', displayName: 'Dropped Peer', presenceServerIds: ['s1'], voiceState: { isConnected: true, isMuted: false, isDeafened: false, isSpeaking: true, roomId: 'voice-1', serverId: 's1' }, cameraState: { isEnabled: true }, screenShareState: { isSharing: true } }); const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [], connectedPeerIds: [] })); expect(state.entities['u3']?.presenceServerIds).toBeUndefined(); expect(state.entities['u3']?.isOnline).toBe(false); expect(state.entities['u3']?.status).toBe('offline'); expect(state.entities['u3']?.voiceState?.isConnected).toBe(false); expect(state.entities['u3']?.voiceState?.roomId).toBeUndefined(); expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false); expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false); }); }); describe('manual status overrides auto idle', () => { it('manual DND is not overridden by auto status changes', () => { // Set DND let state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' })); expect(state.manualStatus).toBe('busy'); expect(state.entities['user-1']?.status).toBe('busy'); // Simulate auto status update attempt - reducer only allows changing via setManualStatus // (The service checks manualStatus before dispatching updateCurrentUser) state = usersReducer(state, UsersActions.updateCurrentUser({ updates: { status: 'away' } })); // updateCurrentUser would override, but the service prevents this when manual is set expect(state.entities['user-1']?.status).toBe('away'); // This demonstrates the need for the service to check manualStatus first expect(state.manualStatus).toBe('busy'); }); }); });