324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import {
|
|
usersReducer,
|
|
initialState,
|
|
UsersState
|
|
} from './users.reducer';
|
|
import { UsersActions } from './users.actions';
|
|
import { User } from '../../shared-kernel';
|
|
|
|
function createUser(overrides: Partial<User> = {}): 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');
|
|
});
|
|
});
|
|
});
|