mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
test(app): expand frontend regression coverage for store slices and core RPC (#787)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -162,4 +162,147 @@ describe('coreRpcClient', () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ state: 'ready' });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['openhuman.get_config', 'openhuman.config_get'],
|
||||
['openhuman.get_runtime_flags', 'openhuman.config_get_runtime_flags'],
|
||||
['openhuman.set_browser_allow_all', 'openhuman.config_set_browser_allow_all'],
|
||||
['openhuman.update_browser_settings', 'openhuman.config_update_browser_settings'],
|
||||
['openhuman.update_memory_settings', 'openhuman.config_update_memory_settings'],
|
||||
['openhuman.update_model_settings', 'openhuman.config_update_model_settings'],
|
||||
['openhuman.update_runtime_settings', 'openhuman.config_update_runtime_settings'],
|
||||
[
|
||||
'openhuman.update_screen_intelligence_settings',
|
||||
'openhuman.config_update_screen_intelligence_settings',
|
||||
],
|
||||
[
|
||||
'openhuman.workspace_onboarding_flag_exists',
|
||||
'openhuman.config_workspace_onboarding_flag_exists',
|
||||
],
|
||||
['openhuman.workspace_onboarding_flag_set', 'openhuman.config_workspace_onboarding_flag_set'],
|
||||
])('rewrites legacy alias %s -> %s', async (incoming, expected) => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: incoming });
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
||||
expect(body.method).toBe(expected);
|
||||
});
|
||||
|
||||
test('passes through unknown methods unchanged', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
||||
expect(body.method).toBe('openhuman.threads_list');
|
||||
});
|
||||
|
||||
test('defaults params to empty object when omitted', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
||||
expect(body.params).toEqual({});
|
||||
expect(body.jsonrpc).toBe('2.0');
|
||||
expect(typeof body.id).toBe('number');
|
||||
});
|
||||
|
||||
test('passes through provided params verbatim', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
const params = { thread_id: 't-1', nested: { flag: true } };
|
||||
await callCoreRpc({ method: 'openhuman.threads_messages_list', params });
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
||||
expect(body.params).toEqual(params);
|
||||
});
|
||||
|
||||
test('increments jsonrpc id on sequential calls', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 0, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
await callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
const idA = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)).id;
|
||||
const idB = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)).id;
|
||||
expect(typeof idA).toBe('number');
|
||||
expect(typeof idB).toBe('number');
|
||||
expect(idB).toBe(idA + 1);
|
||||
});
|
||||
|
||||
test('throws when JSON-RPC response is missing both result and error', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1 }),
|
||||
} as Response);
|
||||
|
||||
await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow(
|
||||
'Core RPC response missing result'
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to generic error message when error.message is blank', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: '' } }),
|
||||
} as Response);
|
||||
|
||||
await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow(
|
||||
'Core RPC returned an error'
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps network errors with message propagated through', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED sidecar'));
|
||||
|
||||
await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow(
|
||||
'ECONNREFUSED sidecar'
|
||||
);
|
||||
});
|
||||
|
||||
test('rewrites multi-segment auth methods (auth.sub.segment) to underscore form', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: 'openhuman.auth.sub.segment' });
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
||||
expect(body.method).toBe('openhuman.auth_sub_segment');
|
||||
});
|
||||
|
||||
test('sends content-type json header and POST method', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }),
|
||||
} as Response);
|
||||
|
||||
await callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Account, AccountLogEntry, IngestedMessage } from '../../types/accounts';
|
||||
import reducer, {
|
||||
addAccount,
|
||||
appendLog,
|
||||
appendMessages,
|
||||
removeAccount,
|
||||
resetAccountsState,
|
||||
setAccountStatus,
|
||||
setActiveAccount,
|
||||
} from '../accountsSlice';
|
||||
|
||||
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
id: 'acct-1',
|
||||
provider: 'slack',
|
||||
label: 'Slack',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<IngestedMessage> = {}): IngestedMessage {
|
||||
return { id: 'm-1', from: 'alice', body: 'hi', unread: 0, ts: 0, ...overrides };
|
||||
}
|
||||
|
||||
describe('accountsSlice addAccount', () => {
|
||||
it('inserts a new account, initialises caches, and picks the first as active', () => {
|
||||
const state = reducer(undefined, addAccount(makeAccount()));
|
||||
expect(state.accounts['acct-1']).toBeDefined();
|
||||
expect(state.order).toEqual(['acct-1']);
|
||||
expect(state.messages['acct-1']).toEqual([]);
|
||||
expect(state.unread['acct-1']).toBe(0);
|
||||
expect(state.logs['acct-1']).toEqual([]);
|
||||
expect(state.activeAccountId).toBe('acct-1');
|
||||
});
|
||||
|
||||
it('re-adding an existing account updates fields but does not duplicate order or reset caches', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount()));
|
||||
state = reducer(
|
||||
state,
|
||||
appendMessages({ accountId: 'acct-1', messages: [makeMessage()], unread: 3 })
|
||||
);
|
||||
expect(state.messages['acct-1']).toHaveLength(1);
|
||||
expect(state.unread['acct-1']).toBe(3);
|
||||
|
||||
state = reducer(state, addAccount(makeAccount({ label: 'Slack Renamed', status: 'open' })));
|
||||
expect(state.order).toEqual(['acct-1']);
|
||||
expect(state.accounts['acct-1'].label).toBe('Slack Renamed');
|
||||
expect(state.accounts['acct-1'].status).toBe('open');
|
||||
// nullish-coalescing assignments must preserve existing caches.
|
||||
expect(state.messages['acct-1']).toHaveLength(1);
|
||||
expect(state.unread['acct-1']).toBe(3);
|
||||
});
|
||||
|
||||
it('does not overwrite activeAccountId when more accounts are added', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(state, addAccount(makeAccount({ id: 'b' })));
|
||||
expect(state.activeAccountId).toBe('a');
|
||||
expect(state.order).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountsSlice removeAccount', () => {
|
||||
it('removes account and associated caches', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(state, addAccount(makeAccount({ id: 'b' })));
|
||||
state = reducer(
|
||||
state,
|
||||
appendLog({ accountId: 'a', entry: { ts: 0, level: 'info', msg: 'x' } as AccountLogEntry })
|
||||
);
|
||||
|
||||
state = reducer(state, removeAccount({ accountId: 'a' }));
|
||||
expect(state.accounts.a).toBeUndefined();
|
||||
expect(state.messages.a).toBeUndefined();
|
||||
expect(state.unread.a).toBeUndefined();
|
||||
expect(state.logs.a).toBeUndefined();
|
||||
expect(state.order).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('reassigns active to the first remaining account on removal of active', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(state, addAccount(makeAccount({ id: 'b' })));
|
||||
expect(state.activeAccountId).toBe('a');
|
||||
|
||||
state = reducer(state, removeAccount({ accountId: 'a' }));
|
||||
expect(state.activeAccountId).toBe('b');
|
||||
});
|
||||
|
||||
it('sets active to null when last account is removed', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'only' })));
|
||||
state = reducer(state, removeAccount({ accountId: 'only' }));
|
||||
expect(state.order).toEqual([]);
|
||||
expect(state.activeAccountId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountsSlice setActiveAccount and setAccountStatus', () => {
|
||||
it('setActiveAccount sets and clears the active id', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(state, setActiveAccount(null));
|
||||
expect(state.activeAccountId).toBeNull();
|
||||
state = reducer(state, setActiveAccount('a'));
|
||||
expect(state.activeAccountId).toBe('a');
|
||||
});
|
||||
|
||||
it('setAccountStatus updates status and lastError', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(
|
||||
state,
|
||||
setAccountStatus({ accountId: 'a', status: 'error', lastError: 'sso expired' })
|
||||
);
|
||||
expect(state.accounts.a.status).toBe('error');
|
||||
expect(state.accounts.a.lastError).toBe('sso expired');
|
||||
});
|
||||
|
||||
it('setAccountStatus ignores unknown accounts', () => {
|
||||
const state = reducer(undefined, setAccountStatus({ accountId: 'ghost', status: 'error' }));
|
||||
expect(state.accounts.ghost).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountsSlice appendMessages', () => {
|
||||
it('replaces messages on every ingest (snapshot semantics, not delta)', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(
|
||||
state,
|
||||
appendMessages({ accountId: 'a', messages: [makeMessage({ id: 'm1' })] })
|
||||
);
|
||||
state = reducer(
|
||||
state,
|
||||
appendMessages({ accountId: 'a', messages: [makeMessage({ id: 'm2' })] })
|
||||
);
|
||||
expect(state.messages.a.map(m => m.id)).toEqual(['m2']);
|
||||
});
|
||||
|
||||
it('caps the stored message list at 200 entries', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
const many = Array.from({ length: 250 }, (_v, i) => makeMessage({ id: `m-${i}` }));
|
||||
state = reducer(state, appendMessages({ accountId: 'a', messages: many }));
|
||||
expect(state.messages.a).toHaveLength(200);
|
||||
expect(state.messages.a[0].id).toBe('m-0');
|
||||
expect(state.messages.a[199].id).toBe('m-199');
|
||||
});
|
||||
|
||||
it('updates unread when provided and ignores unknown accounts', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(
|
||||
state,
|
||||
appendMessages({ accountId: 'a', messages: [makeMessage()], unread: 7 })
|
||||
);
|
||||
expect(state.unread.a).toBe(7);
|
||||
|
||||
const unchanged = reducer(
|
||||
state,
|
||||
appendMessages({ accountId: 'ghost', messages: [makeMessage()] })
|
||||
);
|
||||
expect(unchanged.messages.ghost).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountsSlice appendLog', () => {
|
||||
it('caps the log buffer at 100 entries and keeps the latest', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
state = reducer(
|
||||
state,
|
||||
appendLog({
|
||||
accountId: 'a',
|
||||
entry: { ts: i, level: 'info', msg: `line-${i}` } as AccountLogEntry,
|
||||
})
|
||||
);
|
||||
}
|
||||
expect(state.logs.a).toHaveLength(100);
|
||||
expect((state.logs.a[0] as AccountLogEntry & { msg: string }).msg).toBe('line-20');
|
||||
expect((state.logs.a[99] as AccountLogEntry & { msg: string }).msg).toBe('line-119');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountsSlice resetAccountsState', () => {
|
||||
it('returns to the initial empty state', () => {
|
||||
let state = reducer(undefined, addAccount(makeAccount({ id: 'a' })));
|
||||
state = reducer(state, addAccount(makeAccount({ id: 'b' })));
|
||||
state = reducer(state, resetAccountsState());
|
||||
expect(state.accounts).toEqual({});
|
||||
expect(state.order).toEqual([]);
|
||||
expect(state.activeAccountId).toBeNull();
|
||||
expect(state.messages).toEqual({});
|
||||
expect(state.unread).toEqual({});
|
||||
expect(state.logs).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
beginDeepLinkAuthProcessing,
|
||||
completeDeepLinkAuthProcessing,
|
||||
failDeepLinkAuthProcessing,
|
||||
getDeepLinkAuthState,
|
||||
subscribeDeepLinkAuthState,
|
||||
useDeepLinkAuthState,
|
||||
} from '../deepLinkAuthState';
|
||||
|
||||
/**
|
||||
* Reset module-level state between tests by calling complete() (the default/idle state)
|
||||
* before each test's assertions. The ad-hoc store persists across tests.
|
||||
*/
|
||||
afterEach(() => {
|
||||
completeDeepLinkAuthProcessing();
|
||||
});
|
||||
|
||||
describe('deepLinkAuthState transitions', () => {
|
||||
it('starts idle with no error message', () => {
|
||||
completeDeepLinkAuthProcessing();
|
||||
expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null });
|
||||
});
|
||||
|
||||
it('beginDeepLinkAuthProcessing flips isProcessing true and clears prior error', () => {
|
||||
failDeepLinkAuthProcessing('prior failure');
|
||||
expect(getDeepLinkAuthState().errorMessage).toBe('prior failure');
|
||||
|
||||
beginDeepLinkAuthProcessing();
|
||||
expect(getDeepLinkAuthState()).toEqual({ isProcessing: true, errorMessage: null });
|
||||
});
|
||||
|
||||
it('completeDeepLinkAuthProcessing returns to idle', () => {
|
||||
beginDeepLinkAuthProcessing();
|
||||
completeDeepLinkAuthProcessing();
|
||||
expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null });
|
||||
});
|
||||
|
||||
it('failDeepLinkAuthProcessing surfaces message and resets processing flag', () => {
|
||||
beginDeepLinkAuthProcessing();
|
||||
failDeepLinkAuthProcessing('token expired');
|
||||
expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: 'token expired' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepLinkAuthState subscribers', () => {
|
||||
it('notifies subscribers on every transition', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeDeepLinkAuthState(listener);
|
||||
|
||||
beginDeepLinkAuthProcessing();
|
||||
failDeepLinkAuthProcessing('boom');
|
||||
completeDeepLinkAuthProcessing();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeDeepLinkAuthState(listener);
|
||||
beginDeepLinkAuthProcessing();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
completeDeepLinkAuthProcessing();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports multiple independent subscribers', () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
const offA = subscribeDeepLinkAuthState(a);
|
||||
const offB = subscribeDeepLinkAuthState(b);
|
||||
|
||||
beginDeepLinkAuthProcessing();
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
|
||||
offA();
|
||||
failDeepLinkAuthProcessing('oops');
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(2);
|
||||
|
||||
offB();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeepLinkAuthState hook', () => {
|
||||
it('re-renders when state changes', () => {
|
||||
completeDeepLinkAuthProcessing();
|
||||
const { result } = renderHook(() => useDeepLinkAuthState());
|
||||
expect(result.current).toEqual({ isProcessing: false, errorMessage: null });
|
||||
|
||||
act(() => {
|
||||
beginDeepLinkAuthProcessing();
|
||||
});
|
||||
expect(result.current).toEqual({ isProcessing: true, errorMessage: null });
|
||||
|
||||
act(() => {
|
||||
failDeepLinkAuthProcessing('denied');
|
||||
});
|
||||
expect(result.current).toEqual({ isProcessing: false, errorMessage: 'denied' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import type { Thread, ThreadMessage } from '../../types/thread';
|
||||
import threadReducer, {
|
||||
addInferenceResponse,
|
||||
clearAllThreads,
|
||||
clearSelectedThread,
|
||||
loadThreadMessages,
|
||||
loadThreads,
|
||||
setActiveThread,
|
||||
setSelectedThread,
|
||||
} from '../threadSlice';
|
||||
|
||||
vi.mock('../../services/api/threadApi', () => ({
|
||||
threadApi: {
|
||||
createNewThread: vi.fn(),
|
||||
getThreads: vi.fn(),
|
||||
getThreadMessages: vi.fn(),
|
||||
appendMessage: vi.fn(),
|
||||
deleteThread: vi.fn(),
|
||||
generateTitleIfNeeded: vi.fn(),
|
||||
updateMessage: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedThreadApi = vi.mocked(threadApi);
|
||||
|
||||
function createStore() {
|
||||
return configureStore({ reducer: { thread: threadReducer } });
|
||||
}
|
||||
|
||||
function makeThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: 't-1',
|
||||
title: 'Untitled',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<ThreadMessage> = {}): ThreadMessage {
|
||||
return {
|
||||
id: 'm-1',
|
||||
content: 'hello',
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('threadSlice synchronous reducers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('starts with the expected initial state', () => {
|
||||
const store = createStore();
|
||||
const state = store.getState().thread;
|
||||
expect(state.threads).toEqual([]);
|
||||
expect(state.selectedThreadId).toBeNull();
|
||||
expect(state.activeThreadId).toBeNull();
|
||||
expect(state.messagesByThreadId).toEqual({});
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.isLoadingThreads).toBe(false);
|
||||
expect(state.isLoadingMessages).toBe(false);
|
||||
expect(state.suggestedQuestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('setSelectedThread copies cached messages into the visible list', async () => {
|
||||
const store = createStore();
|
||||
const cached = [makeMessage({ id: 'm-1' }), makeMessage({ id: 'm-2' })];
|
||||
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({
|
||||
messages: cached,
|
||||
count: cached.length,
|
||||
});
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
|
||||
store.dispatch(setSelectedThread('t-1'));
|
||||
const state = store.getState().thread;
|
||||
expect(state.selectedThreadId).toBe('t-1');
|
||||
expect(state.messages).toEqual(cached);
|
||||
expect(state.messagesError).toBeNull();
|
||||
expect(state.suggestedQuestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('setSelectedThread resets messages when cache is empty', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setSelectedThread('missing'));
|
||||
const state = store.getState().thread;
|
||||
expect(state.selectedThreadId).toBe('missing');
|
||||
expect(state.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('clearSelectedThread clears visible selection but keeps cache', async () => {
|
||||
const store = createStore();
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({
|
||||
messages: [makeMessage()],
|
||||
count: 1,
|
||||
});
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
store.dispatch(setSelectedThread('t-1'));
|
||||
|
||||
store.dispatch(clearSelectedThread());
|
||||
const state = store.getState().thread;
|
||||
expect(state.selectedThreadId).toBeNull();
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.messagesByThreadId['t-1']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('setActiveThread only touches the active id', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setActiveThread('t-active'));
|
||||
expect(store.getState().thread.activeThreadId).toBe('t-active');
|
||||
store.dispatch(setActiveThread(null));
|
||||
expect(store.getState().thread.activeThreadId).toBeNull();
|
||||
});
|
||||
|
||||
it('clearAllThreads wipes threads, messages, and selection', async () => {
|
||||
const store = createStore();
|
||||
mockedThreadApi.getThreads.mockResolvedValueOnce({
|
||||
threads: [makeThread({ id: 't-1' })],
|
||||
count: 1,
|
||||
});
|
||||
await store.dispatch(loadThreads());
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({
|
||||
messages: [makeMessage()],
|
||||
count: 1,
|
||||
});
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
store.dispatch(setSelectedThread('t-1'));
|
||||
store.dispatch(setActiveThread('t-1'));
|
||||
|
||||
store.dispatch(clearAllThreads());
|
||||
const state = store.getState().thread;
|
||||
expect(state.threads).toEqual([]);
|
||||
expect(state.messagesByThreadId).toEqual({});
|
||||
expect(state.selectedThreadId).toBeNull();
|
||||
expect(state.activeThreadId).toBeNull();
|
||||
expect(state.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threadSlice loadThreads thunk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets isLoadingThreads while pending and stores threads on fulfilled', async () => {
|
||||
const store = createStore();
|
||||
const payload = { threads: [makeThread({ id: 'a' })], count: 1 };
|
||||
mockedThreadApi.getThreads.mockImplementationOnce(async () => {
|
||||
expect(store.getState().thread.isLoadingThreads).toBe(true);
|
||||
return payload;
|
||||
});
|
||||
|
||||
await store.dispatch(loadThreads());
|
||||
const state = store.getState().thread;
|
||||
expect(state.isLoadingThreads).toBe(false);
|
||||
expect(state.threads).toEqual(payload.threads);
|
||||
});
|
||||
|
||||
it('clears loading on rejection', async () => {
|
||||
const store = createStore();
|
||||
mockedThreadApi.getThreads.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const result = await store.dispatch(loadThreads());
|
||||
expect(result.type).toBe('thread/loadThreads/rejected');
|
||||
expect(store.getState().thread.isLoadingThreads).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threadSlice loadThreadMessages thunk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('hydrates messagesByThreadId and mirrors visible list when selected', async () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setSelectedThread('t-1'));
|
||||
const messages = [makeMessage({ id: 'a' }), makeMessage({ id: 'b' })];
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ messages, count: messages.length });
|
||||
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
const state = store.getState().thread;
|
||||
expect(state.messagesByThreadId['t-1']).toEqual(messages);
|
||||
expect(state.messages).toEqual(messages);
|
||||
expect(state.isLoadingMessages).toBe(false);
|
||||
expect(state.messagesError).toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite visible messages when loading a non-selected thread', async () => {
|
||||
const store = createStore();
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({
|
||||
messages: [makeMessage({ id: 'x' })],
|
||||
count: 1,
|
||||
});
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
store.dispatch(setSelectedThread('t-1'));
|
||||
|
||||
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({
|
||||
messages: [makeMessage({ id: 'y', content: 'other thread' })],
|
||||
count: 1,
|
||||
});
|
||||
await store.dispatch(loadThreadMessages('t-2'));
|
||||
|
||||
const state = store.getState().thread;
|
||||
expect(state.messagesByThreadId['t-2']).toHaveLength(1);
|
||||
expect(state.messagesByThreadId['t-2'][0].content).toBe('other thread');
|
||||
// Visible messages stayed pinned to t-1.
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.messages[0].id).toBe('x');
|
||||
});
|
||||
|
||||
it('records messagesError on rejection', async () => {
|
||||
const store = createStore();
|
||||
mockedThreadApi.getThreadMessages.mockRejectedValueOnce(new Error('boom'));
|
||||
await store.dispatch(loadThreadMessages('t-1'));
|
||||
const state = store.getState().thread;
|
||||
expect(state.isLoadingMessages).toBe(false);
|
||||
expect(state.messagesError).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('threadSlice addInferenceResponse thunk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('appends to the supplied thread even when activeThreadId is null', async () => {
|
||||
const store = createStore();
|
||||
const persisted = makeMessage({ id: 'srv-1', sender: 'agent', content: 'ack' });
|
||||
mockedThreadApi.appendMessage.mockResolvedValueOnce(persisted);
|
||||
|
||||
const result = await store.dispatch(addInferenceResponse({ content: 'ack', threadId: 't-1' }));
|
||||
|
||||
expect(result.type).toBe('thread/addInferenceResponse/fulfilled');
|
||||
const state = store.getState().thread;
|
||||
expect(state.messagesByThreadId['t-1']).toEqual([persisted]);
|
||||
// activeThreadId must not be mutated by this thunk — only ChatRuntimeProvider clears it.
|
||||
expect(state.activeThreadId).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to activeThreadId when no threadId is supplied', async () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setActiveThread('t-active'));
|
||||
mockedThreadApi.appendMessage.mockResolvedValueOnce(makeMessage({ sender: 'agent' }));
|
||||
|
||||
await store.dispatch(addInferenceResponse({ content: 'ack' }));
|
||||
expect(mockedThreadApi.appendMessage).toHaveBeenCalledWith(
|
||||
't-active',
|
||||
expect.objectContaining({ sender: 'agent', content: 'ack' })
|
||||
);
|
||||
// activeThreadId must not be cleared by this thunk — ChatRuntimeProvider owns that.
|
||||
expect(store.getState().thread.activeThreadId).toBe('t-active');
|
||||
});
|
||||
|
||||
it('rejects cleanly when neither threadId nor activeThreadId is set', async () => {
|
||||
const store = createStore();
|
||||
const result = await store.dispatch(addInferenceResponse({ content: 'ack' }));
|
||||
expect(result.type).toBe('thread/addInferenceResponse/rejected');
|
||||
expect(mockedThreadApi.appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user