Skip to content

Commit 45d92bd

Browse files
lethemanhlethemanh
authored andcommitted
feat: Implement unit test ✅
1 parent e51e5c2 commit 45d92bd

7 files changed

Lines changed: 629 additions & 2 deletions

File tree

packages/cozy-search/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"directory": "packages/cozy-search"
8484
},
8585
"scripts": {
86-
"build": "yarn build:clean && yarn build:types && babel --extensions .ts,.tsx,.js,.jsx --ignore '**/*.spec.tsx','**/*.spec.ts','**/*.d.ts' ./src -d ./dist --copy-files",
86+
"build": "yarn build:clean && yarn build:types && babel --extensions .ts,.tsx,.js,.jsx --ignore '**/*.spec.tsx','**/*.spec.ts','**/*.spec.js','**/*.spec.jsx','**/*.d.ts' ./src -d ./dist --copy-files",
8787
"build:clean": "rm -rf ./dist",
8888
"build:types": "tsc -p tsconfig-build.json",
8989
"build:watch": "yarn build --watch",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { groupConversationsByDate } from './helpers'
2+
3+
describe('groupConversationsByDate', () => {
4+
let OriginalDate
5+
6+
beforeEach(() => {
7+
OriginalDate = global.Date
8+
global.Date = class extends OriginalDate {
9+
constructor(...args) {
10+
if (args.length) return new OriginalDate(...args)
11+
return new OriginalDate('2023-11-20T12:00:00Z')
12+
}
13+
}
14+
global.Date.now = jest.fn(() =>
15+
new OriginalDate('2023-11-20T12:00:00Z').getTime()
16+
)
17+
})
18+
19+
afterEach(() => {
20+
global.Date = OriginalDate
21+
})
22+
23+
it('returns empty groups for null or undefined input', () => {
24+
expect(groupConversationsByDate(null)).toEqual({ today: [], older: [] })
25+
expect(groupConversationsByDate(undefined)).toEqual({
26+
today: [],
27+
older: []
28+
})
29+
})
30+
31+
it('groups conversations into today and older', () => {
32+
const mockConversations = [
33+
{
34+
id: '1',
35+
cozyMetadata: {
36+
updatedAt: new OriginalDate(2023, 10, 20, 14, 0).toISOString()
37+
}
38+
}, // Today
39+
{
40+
id: '2',
41+
cozyMetadata: {
42+
updatedAt: new OriginalDate(2023, 10, 20, 8, 0).toISOString()
43+
}
44+
}, // Today
45+
{
46+
id: '3',
47+
cozyMetadata: {
48+
updatedAt: new OriginalDate(2023, 10, 19, 23, 59).toISOString()
49+
}
50+
}, // Older
51+
{
52+
id: '4',
53+
cozyMetadata: {
54+
updatedAt: new OriginalDate(2022, 0, 1, 12, 0).toISOString()
55+
}
56+
}, // Older
57+
{ id: '5' } // Missing cozyMetadata (Date.now() fallback -> Today)
58+
]
59+
60+
const result = groupConversationsByDate(mockConversations)
61+
62+
expect(result.today).toHaveLength(3)
63+
expect(result.today[0].id).toBe('1')
64+
expect(result.today[1].id).toBe('2')
65+
expect(result.today[2].id).toBe('5')
66+
67+
expect(result.older).toHaveLength(2)
68+
expect(result.older[0].id).toBe('3')
69+
expect(result.older[1].id).toBe('4')
70+
})
71+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { StreamBridge } from './StreamBridge'
2+
3+
describe('StreamBridge', () => {
4+
let bridge: StreamBridge
5+
6+
beforeEach(() => {
7+
bridge = new StreamBridge()
8+
})
9+
10+
it('should create an async iterable iterator', () => {
11+
const iterator = bridge.createStream('convo_1')
12+
expect(typeof iterator.next).toBe('function')
13+
expect(iterator[Symbol.asyncIterator]).toBeDefined()
14+
})
15+
16+
it('should push deltas and yield them from the iterator', async () => {
17+
const iterator = bridge.createStream('convo_1')
18+
19+
bridge.onDelta('convo_1', 'Hello ')
20+
bridge.onDelta('convo_1', 'world!')
21+
22+
const first = await iterator.next()
23+
expect(first).toEqual({ value: 'Hello ', done: false })
24+
25+
const second = await iterator.next()
26+
expect(second).toEqual({ value: 'world!', done: false })
27+
})
28+
29+
it('should mark the stream as done when complete is called', async () => {
30+
const iterator = bridge.createStream('convo_1')
31+
32+
bridge.onDelta('convo_1', 'done chunk')
33+
bridge.onDone('convo_1')
34+
35+
const first = await iterator.next()
36+
expect(first).toEqual({ value: 'done chunk', done: false })
37+
38+
const second = await iterator.next()
39+
expect(second.done).toBe(true)
40+
})
41+
42+
it('should reject the iterator when an error occurs', async () => {
43+
const iterator = bridge.createStream('convo_1')
44+
const error = new Error('Socket disconnected')
45+
46+
bridge.onError('convo_1', error)
47+
48+
await expect(iterator.next()).rejects.toThrow('Socket disconnected')
49+
})
50+
51+
it('should call the cleanup callback and mark the stream complete on cleanup', async () => {
52+
const cleanupSpy = jest.fn()
53+
bridge.setCleanupCallback(cleanupSpy)
54+
55+
const iterator = bridge.createStream('convo_1')
56+
bridge.cleanup('convo_1')
57+
58+
expect(cleanupSpy).toHaveBeenCalledTimes(1)
59+
expect(bridge.hasStream('convo_1')).toBe(false)
60+
61+
// The iterator should be marked done
62+
const result = await iterator.next()
63+
expect(result.done).toBe(true)
64+
})
65+
66+
it('multiple unresolved next calls should reject to prevent concurrency issues', async () => {
67+
const iterator = bridge.createStream('convo_1')
68+
69+
// Call next twice concurrently
70+
const p1 = iterator.next()
71+
const p2 = iterator.next()
72+
73+
await expect(p2).rejects.toThrow(
74+
'StreamBridge: concurrent next() calls are not supported'
75+
)
76+
77+
// Fulfill the first one
78+
bridge.onDelta('convo_1', 'ok')
79+
const res1 = await p1
80+
expect(res1).toEqual({ value: 'ok', done: false })
81+
})
82+
})

packages/cozy-search/src/components/helpers.spec.js

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { sanitizeChatContent } from './helpers'
1+
import {
2+
sanitizeChatContent,
3+
formatConversationDate,
4+
getNameOfConversation,
5+
getDescriptionOfConversation
6+
} from './helpers'
7+
8+
jest.mock('cozy-flags', () => jest.fn(() => true), { virtual: true })
29

310
describe('sanitizeChatContent', () => {
411
it('should return empty string for empty content', () => {
@@ -41,3 +48,92 @@ describe('sanitizeChatContent', () => {
4148
expect(sanitizeChatContent(text)).toBe(text)
4249
})
4350
})
51+
52+
describe('formatConversationDate', () => {
53+
const mockT = jest.fn(key => key)
54+
const mockDate = new Date('2023-11-20T12:00:00Z')
55+
let OriginalDate
56+
let dateSpy
57+
58+
beforeEach(() => {
59+
OriginalDate = global.Date
60+
dateSpy = jest.spyOn(global, 'Date').mockImplementation(function (...args) {
61+
if (args.length) {
62+
return new OriginalDate(...args)
63+
}
64+
return mockDate
65+
})
66+
dateSpy.now = jest.fn(() => mockDate.getTime())
67+
})
68+
69+
afterEach(() => {
70+
dateSpy.mockRestore()
71+
mockT.mockClear()
72+
})
73+
74+
it('returns empty string for invalid dates', () => {
75+
expect(formatConversationDate(null, mockT, 'en-US')).toBe('')
76+
expect(formatConversationDate('not date', mockT, 'en-US')).toBe('')
77+
})
78+
79+
it('formats today as "Today, HH:mm"', () => {
80+
const today = new Date('2023-11-20T08:30:00Z').toISOString()
81+
const result = formatConversationDate(today, mockT, 'en-US')
82+
83+
expect(result).toMatch(/assistant\.time\.today/)
84+
expect(result).toMatch(/\d{1,2}:\d{2}/)
85+
})
86+
87+
it('formats yesterday as "Yesterday, HH:mm"', () => {
88+
const yesterday = new Date('2023-11-19T14:45:00Z').toISOString()
89+
const result = formatConversationDate(yesterday, mockT, 'en-US')
90+
91+
expect(result).toMatch(/assistant\.time\.yesterday/)
92+
expect(result).toMatch(/\d{1,2}:\d{2}/)
93+
})
94+
95+
it('formats older dates as formatted short date strings', () => {
96+
const older = new Date('2022-01-05T10:00:00Z').toISOString()
97+
const result = formatConversationDate(older, mockT, 'en-US')
98+
99+
expect(result).toContain('2022')
100+
expect(result).toContain('Jan')
101+
})
102+
})
103+
104+
describe('getNameOfConversation', () => {
105+
it('returns undefined if messages array is empty or missing', () => {
106+
expect(getNameOfConversation({})).toBeUndefined()
107+
expect(getNameOfConversation({ messages: [] })).toBeUndefined()
108+
expect(
109+
getNameOfConversation({ messages: [{ content: 'Hi' }] })
110+
).toBeUndefined()
111+
})
112+
113+
it('returns the content of the second to last message', () => {
114+
const convo = {
115+
messages: [
116+
{ role: 'user', content: 'What is the sum?' },
117+
{ role: 'assistant', content: 'It is 4' }
118+
]
119+
}
120+
expect(getNameOfConversation(convo)).toBe('What is the sum?')
121+
})
122+
})
123+
124+
describe('getDescriptionOfConversation', () => {
125+
it('returns undefined if messages array is empty or missing', () => {
126+
expect(getDescriptionOfConversation({})).toBeUndefined()
127+
expect(getDescriptionOfConversation({ messages: [] })).toBeUndefined()
128+
})
129+
130+
it('returns the content of the last message', () => {
131+
const convo = {
132+
messages: [
133+
{ role: 'user', content: 'What is the sum?' },
134+
{ role: 'assistant', content: 'It is 4' }
135+
]
136+
}
137+
expect(getDescriptionOfConversation(convo)).toBe('It is 4')
138+
})
139+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { renderHook, act } from '@testing-library/react-hooks'
2+
3+
import useConversation from './useConversation'
4+
5+
const mockNavigate = jest.fn()
6+
let mockLocation = {
7+
pathname: '/',
8+
search: '',
9+
hash: ''
10+
}
11+
12+
jest.mock('react-router-dom', () => ({
13+
useNavigate: () => mockNavigate,
14+
useLocation: () => mockLocation
15+
}))
16+
17+
const mockSetIsOpenSearchConversation = jest.fn()
18+
jest.mock('../components/AssistantProvider', () => ({
19+
useAssistant: () => ({
20+
setIsOpenSearchConversation: mockSetIsOpenSearchConversation
21+
})
22+
}))
23+
24+
jest.mock('../components/helpers', () => ({
25+
makeConversationId: () => 'mock-id-123'
26+
}))
27+
28+
describe('useConversation', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks()
31+
mockLocation = {
32+
pathname: '/',
33+
search: '',
34+
hash: ''
35+
}
36+
})
37+
38+
describe('goToConversation', () => {
39+
it('appends /assistant/id to a base url', () => {
40+
mockLocation.pathname = '/drive/folders/123'
41+
const { result } = renderHook(() => useConversation())
42+
43+
act(() => {
44+
result.current.goToConversation('convo-456')
45+
})
46+
47+
expect(mockSetIsOpenSearchConversation).toHaveBeenCalledWith(false)
48+
expect(mockNavigate).toHaveBeenCalledWith({
49+
pathname: '/drive/folders/123/assistant/convo-456',
50+
search: '',
51+
hash: ''
52+
})
53+
})
54+
55+
it('replaces existing /assistant/... path intelligently', () => {
56+
mockLocation.pathname = '/drive/folders/123/assistant/old-convo-789'
57+
const { result } = renderHook(() => useConversation())
58+
59+
act(() => {
60+
result.current.goToConversation('new-convo-000')
61+
})
62+
63+
expect(mockNavigate).toHaveBeenCalledWith({
64+
pathname: '/drive/folders/123/assistant/new-convo-000',
65+
search: '',
66+
hash: ''
67+
})
68+
})
69+
70+
it('handles trailing slashes on base path correctly', () => {
71+
mockLocation.pathname = '/drive/folders/123/'
72+
const { result } = renderHook(() => useConversation())
73+
74+
act(() => {
75+
result.current.goToConversation('convo-456')
76+
})
77+
78+
expect(mockNavigate).toHaveBeenCalledWith({
79+
pathname: '/drive/folders/123/assistant/convo-456',
80+
search: '',
81+
hash: ''
82+
})
83+
})
84+
85+
it('preserves search query and hash fragments', () => {
86+
mockLocation = {
87+
pathname: '/base',
88+
search: '?foo=bar',
89+
hash: '#section1'
90+
}
91+
const { result } = renderHook(() => useConversation())
92+
93+
act(() => {
94+
result.current.goToConversation('convo-1')
95+
})
96+
97+
expect(mockNavigate).toHaveBeenCalledWith({
98+
pathname: '/base/assistant/convo-1',
99+
search: '?foo=bar',
100+
hash: '#section1'
101+
})
102+
})
103+
})
104+
105+
describe('createNewConversation', () => {
106+
it('creates a new conversation using makeConversationId', () => {
107+
mockLocation.pathname = '/docs'
108+
const { result } = renderHook(() => useConversation())
109+
110+
act(() => {
111+
result.current.createNewConversation()
112+
})
113+
114+
expect(mockNavigate).toHaveBeenCalledWith({
115+
pathname: '/docs/assistant/mock-id-123',
116+
search: '',
117+
hash: ''
118+
})
119+
})
120+
})
121+
})

0 commit comments

Comments
 (0)