Skip to content

Commit 2ed2cbd

Browse files
committed
Refactor chat to chat service and chat plugin
Signed-off-by: Anan Zhuang <[email protected]>
1 parent a47de88 commit 2ed2cbd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2065
-789
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { BehaviorSubject } from 'rxjs';
7+
import { ChatServiceSetup, ChatServiceStart } from './types';
8+
9+
const createSetupContractMock = (): jest.Mocked<ChatServiceSetup> => ({
10+
setImplementation: jest.fn(),
11+
setSuggestedActionsService: jest.fn(),
12+
suggestedActionsService: undefined,
13+
});
14+
15+
const createStartContractMock = (): jest.Mocked<ChatServiceStart> => ({
16+
isAvailable: jest.fn().mockReturnValue(false),
17+
isWindowOpen: jest.fn().mockReturnValue(false),
18+
getThreadId$: jest.fn().mockReturnValue(new BehaviorSubject<string>('')),
19+
getThreadId: jest.fn().mockReturnValue(''),
20+
openWindow: jest.fn(),
21+
closeWindow: jest.fn(),
22+
sendMessage: jest.fn().mockResolvedValue({
23+
observable: null,
24+
userMessage: { id: 'mock-id', role: 'user', content: 'mock-content' },
25+
}),
26+
sendMessageWithWindow: jest.fn().mockResolvedValue({
27+
observable: null,
28+
userMessage: { id: 'mock-id', role: 'user', content: 'mock-content' },
29+
}),
30+
getWindowState$: jest.fn().mockReturnValue(
31+
new BehaviorSubject({
32+
isWindowOpen: false,
33+
windowMode: 'sidecar',
34+
paddingSize: 400,
35+
})
36+
),
37+
onWindowOpen: jest.fn().mockReturnValue(() => {}),
38+
onWindowClose: jest.fn().mockReturnValue(() => {}),
39+
suggestedActionsService: undefined,
40+
});
41+
42+
export const coreChatServiceMock = {
43+
createSetupContract: createSetupContractMock,
44+
createStartContract: createStartContractMock,
45+
};
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { BehaviorSubject, Observable } from 'rxjs';
7+
import { ChatService } from './chat_service';
8+
import { ChatImplementationFunctions, ChatWindowState } from './types';
9+
10+
describe('ChatService', () => {
11+
let service: ChatService;
12+
let mockImplementation: ChatImplementationFunctions;
13+
let mockThreadId$: BehaviorSubject<string>;
14+
let mockWindowState$: BehaviorSubject<ChatWindowState>;
15+
16+
beforeEach(() => {
17+
service = new ChatService();
18+
mockThreadId$ = new BehaviorSubject<string>('test-thread-id');
19+
mockWindowState$ = new BehaviorSubject<ChatWindowState>({
20+
isWindowOpen: false,
21+
windowMode: 'sidecar',
22+
paddingSize: 400,
23+
});
24+
25+
// Create complete mock implementation
26+
mockImplementation = {
27+
sendMessage: jest.fn().mockResolvedValue({
28+
observable: null,
29+
userMessage: { id: '1', role: 'user', content: 'test' },
30+
}),
31+
sendMessageWithWindow: jest.fn().mockResolvedValue({
32+
observable: null,
33+
userMessage: { id: '1', role: 'user', content: 'test' },
34+
}),
35+
getThreadId: jest.fn().mockReturnValue('test-thread-id'),
36+
getThreadId$: jest.fn().mockReturnValue(mockThreadId$.asObservable()),
37+
isWindowOpen: jest.fn().mockReturnValue(false),
38+
openWindow: jest.fn().mockResolvedValue(undefined),
39+
closeWindow: jest.fn().mockResolvedValue(undefined),
40+
getWindowState: jest.fn().mockReturnValue({
41+
isWindowOpen: false,
42+
windowMode: 'sidecar',
43+
paddingSize: 400,
44+
}),
45+
getWindowState$: jest.fn().mockReturnValue(mockWindowState$.asObservable()),
46+
onWindowOpen: jest.fn().mockReturnValue(() => {}),
47+
onWindowClose: jest.fn().mockReturnValue(() => {}),
48+
};
49+
});
50+
51+
describe('setup', () => {
52+
it('returns valid setup contract', () => {
53+
const setupContract = service.setup();
54+
55+
expect(setupContract).toEqual({
56+
setImplementation: expect.any(Function),
57+
setSuggestedActionsService: expect.any(Function),
58+
suggestedActionsService: undefined,
59+
});
60+
});
61+
62+
it('setImplementation stores the implementation functions', () => {
63+
const setupContract = service.setup();
64+
65+
expect(() => setupContract.setImplementation(mockImplementation)).not.toThrow();
66+
});
67+
});
68+
69+
describe('start', () => {
70+
describe('availability logic', () => {
71+
it('should be unavailable when no implementation is registered', () => {
72+
const startContract = service.start();
73+
expect(startContract.isAvailable()).toBe(false);
74+
});
75+
76+
it('should be available after implementation is registered', () => {
77+
const setupContract = service.setup();
78+
setupContract.setImplementation(mockImplementation);
79+
const startContract = service.start();
80+
expect(startContract.isAvailable()).toBe(true);
81+
});
82+
});
83+
84+
describe('service interface', () => {
85+
let startContract: any;
86+
87+
beforeEach(() => {
88+
startContract = service.start();
89+
});
90+
91+
it('provides all required interface methods', () => {
92+
expect(startContract).toEqual({
93+
isAvailable: expect.any(Function),
94+
isWindowOpen: expect.any(Function),
95+
getThreadId$: expect.any(Function),
96+
getThreadId: expect.any(Function),
97+
openWindow: expect.any(Function),
98+
closeWindow: expect.any(Function),
99+
sendMessage: expect.any(Function),
100+
sendMessageWithWindow: expect.any(Function),
101+
getWindowState: expect.any(Function),
102+
getWindowState$: expect.any(Function),
103+
onWindowOpen: expect.any(Function),
104+
onWindowClose: expect.any(Function),
105+
suggestedActionsService: undefined,
106+
});
107+
});
108+
109+
describe('window state management', () => {
110+
beforeEach(() => {
111+
// Set up implementation for these tests
112+
const setupContract = service.setup();
113+
setupContract.setImplementation(mockImplementation);
114+
startContract = service.start();
115+
});
116+
117+
it('should delegate to implementation for window state', () => {
118+
expect(startContract.isWindowOpen()).toBe(false);
119+
expect(mockImplementation.isWindowOpen).toHaveBeenCalled();
120+
});
121+
122+
it('should delegate window open/close to implementation', async () => {
123+
await startContract.openWindow();
124+
expect(mockImplementation.openWindow).toHaveBeenCalled();
125+
126+
await startContract.closeWindow();
127+
expect(mockImplementation.closeWindow).toHaveBeenCalled();
128+
});
129+
130+
it('should provide window state via implementation', () => {
131+
const state = startContract.getWindowState();
132+
expect(mockImplementation.getWindowState).toHaveBeenCalled();
133+
expect(state).toEqual({
134+
isWindowOpen: false,
135+
windowMode: 'sidecar',
136+
paddingSize: 400,
137+
});
138+
});
139+
140+
it('should provide window state observable via implementation', (done) => {
141+
const state$ = startContract.getWindowState$();
142+
expect(mockImplementation.getWindowState$).toHaveBeenCalled();
143+
144+
state$
145+
.subscribe((state: any) => {
146+
expect(state).toEqual({
147+
isWindowOpen: false,
148+
windowMode: 'sidecar',
149+
paddingSize: 400,
150+
});
151+
done();
152+
})
153+
.unsubscribe();
154+
});
155+
156+
it('should delegate window callbacks to implementation', () => {
157+
const openCallback = jest.fn();
158+
const closeCallback = jest.fn();
159+
160+
startContract.onWindowOpen(openCallback);
161+
startContract.onWindowClose(closeCallback);
162+
163+
expect(mockImplementation.onWindowOpen).toHaveBeenCalledWith(openCallback);
164+
expect(mockImplementation.onWindowClose).toHaveBeenCalledWith(closeCallback);
165+
});
166+
});
167+
168+
describe('thread ID management', () => {
169+
beforeEach(() => {
170+
// Set up implementation for these tests
171+
const setupContract = service.setup();
172+
setupContract.setImplementation(mockImplementation);
173+
startContract = service.start();
174+
});
175+
176+
it('should delegate thread ID to implementation', () => {
177+
const threadId = startContract.getThreadId();
178+
expect(mockImplementation.getThreadId).toHaveBeenCalled();
179+
expect(threadId).toBe('test-thread-id');
180+
});
181+
182+
it('should provide thread ID observable via implementation', (done) => {
183+
const threadId$ = startContract.getThreadId$();
184+
expect(mockImplementation.getThreadId$).toHaveBeenCalled();
185+
186+
threadId$
187+
.subscribe((threadId: string) => {
188+
expect(threadId).toBe('test-thread-id');
189+
done();
190+
})
191+
.unsubscribe();
192+
});
193+
});
194+
195+
describe('message handling', () => {
196+
it('should throw error when no implementation is set', async () => {
197+
// Use fresh service without implementation
198+
const freshService = new ChatService();
199+
const freshStartContract = freshService.start();
200+
201+
await expect(freshStartContract.sendMessage('test message', [])).rejects.toThrow(
202+
'Chat service is not available'
203+
);
204+
205+
await expect(
206+
freshStartContract.sendMessageWithWindow('test message', [])
207+
).rejects.toThrow('Chat service is not available');
208+
});
209+
210+
it('should delegate to implementation when set', async () => {
211+
const setupContract = service.setup();
212+
setupContract.setImplementation(mockImplementation);
213+
const serviceWithImpl = service.start();
214+
215+
const testMessages = [{ id: '1', role: 'user', content: 'previous message' }];
216+
217+
await serviceWithImpl.sendMessage('test message', testMessages);
218+
expect(mockImplementation.sendMessage).toHaveBeenCalledWith('test message', testMessages);
219+
220+
await serviceWithImpl.sendMessageWithWindow('test message with window', testMessages, {
221+
clearConversation: true,
222+
});
223+
expect(mockImplementation.sendMessageWithWindow).toHaveBeenCalledWith(
224+
'test message with window',
225+
testMessages,
226+
{
227+
clearConversation: true,
228+
}
229+
);
230+
});
231+
});
232+
});
233+
234+
describe('delegation without implementation', () => {
235+
it('should return empty thread ID when no implementation', () => {
236+
const startContract = service.start();
237+
const threadId = startContract.getThreadId();
238+
expect(threadId).toBe('');
239+
});
240+
241+
it('should throw for observables when no implementation', () => {
242+
const startContract = service.start();
243+
expect(() => startContract.getThreadId$()).toThrow('Chat service is not available');
244+
expect(() => startContract.getWindowState$()).toThrow('Chat service is not available');
245+
});
246+
247+
it('should return default window state when no implementation', () => {
248+
const startContract = service.start();
249+
const state = startContract.getWindowState();
250+
expect(state).toEqual({
251+
isWindowOpen: false,
252+
windowMode: 'sidecar',
253+
paddingSize: 400,
254+
});
255+
});
256+
});
257+
});
258+
259+
describe('stop', () => {
260+
it('should not throw when called', () => {
261+
expect(() => service.stop()).not.toThrow();
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)