1+ /**
2+ * tests/integration/search.test.js
3+ *
4+ * Tests search routes:
5+ * GET /search/:q (global search across all channels)
6+ * GET /search/:channel/:q (search within a specific channel)
7+ */
8+
9+ import { describe , it , expect , beforeEach } from 'vitest'
10+ import request from 'supertest'
11+ import { setup , resetDb , getApp , insertItem } from '../helpers/setup.js'
12+
13+ setup ( )
14+
15+ beforeEach ( async ( ) => {
16+ await resetDb ( )
17+ } )
18+
19+ // ─────────────────────────────────────────────────────────────────────────────
20+ // GET /search/:q (global)
21+ // ─────────────────────────────────────────────────────────────────────────────
22+
23+ describe ( 'GET /search/:q (global)' , ( ) => {
24+ it ( 'returns an empty array when nothing matches' , async ( ) => {
25+ const res = await request ( getApp ( ) ) . get ( '/search/zzznomatch' )
26+
27+ expect ( res . status ) . toBe ( 200 )
28+ expect ( res . body ) . toEqual ( [ ] )
29+ } )
30+
31+ it ( 'finds an item by content' , async ( ) => {
32+ await insertItem ( { content : 'hello world' , channel : 'general' } )
33+
34+ const res = await request ( getApp ( ) ) . get ( '/search/hello' )
35+
36+ expect ( res . status ) . toBe ( 200 )
37+ expect ( res . body ) . toHaveLength ( 1 )
38+ expect ( res . body [ 0 ] . content ) . toBe ( 'hello world' )
39+ } )
40+
41+ it ( 'is case insensitive' , async ( ) => {
42+ await insertItem ( { content : 'Hello World' , channel : 'general' } )
43+
44+ const res = await request ( getApp ( ) ) . get ( '/search/hello' )
45+ expect ( res . body ) . toHaveLength ( 1 )
46+ } )
47+
48+ it ( 'matches partial words' , async ( ) => {
49+ await insertItem ( { content : 'deployment pipeline' , channel : 'general' } )
50+
51+ const res = await request ( getApp ( ) ) . get ( '/search/deploy' )
52+ expect ( res . body ) . toHaveLength ( 1 )
53+ } )
54+
55+ it ( 'searches across all channels' , async ( ) => {
56+ await insertItem ( { content : 'result one' , channel : 'general' } )
57+ await insertItem ( { content : 'result two' , channel : 'projects' } )
58+ await insertItem ( { content : 'result three' , channel : 'assets' } )
59+
60+ const res = await request ( getApp ( ) ) . get ( '/search/result' )
61+ expect ( res . body ) . toHaveLength ( 3 )
62+ } )
63+
64+ it ( 'does not return items that do not match' , async ( ) => {
65+ await insertItem ( { content : 'matching item' , channel : 'general' } )
66+ await insertItem ( { content : 'unrelated item' , channel : 'general' } )
67+
68+ const res = await request ( getApp ( ) ) . get ( '/search/matching' )
69+ expect ( res . body ) . toHaveLength ( 1 )
70+ expect ( res . body [ 0 ] . content ) . toBe ( 'matching item' )
71+ } )
72+
73+ it ( 'returns results ordered by channel then pinned then newest' , async ( ) => {
74+ await insertItem ( { content : 'alpha result' , channel : 'projects' , created_at : 1000 } )
75+ await insertItem ( { content : 'beta result' , channel : 'general' , created_at : 2000 } )
76+ await insertItem ( { content : 'gamma result' , channel : 'general' , created_at : 3000 , pinned : 1 } )
77+
78+ const res = await request ( getApp ( ) ) . get ( '/search/result' )
79+ const items = res . body
80+
81+ // general comes before projects alphabetically
82+ // within general: pinned first, then newest
83+ expect ( items [ 0 ] . content ) . toBe ( 'gamma result' ) // general, pinned
84+ expect ( items [ 1 ] . content ) . toBe ( 'beta result' ) // general, unpinned
85+ expect ( items [ 2 ] . content ) . toBe ( 'alpha result' ) // projects
86+ } )
87+
88+ it ( 'finds items by filename' , async ( ) => {
89+ await insertItem ( {
90+ type : 'file' ,
91+ filename : 'report-2024.pdf' ,
92+ channel : 'assets'
93+ } )
94+
95+ const res = await request ( getApp ( ) ) . get ( '/search/report' )
96+ expect ( res . body ) . toHaveLength ( 1 )
97+ expect ( res . body [ 0 ] . filename ) . toBe ( 'report-2024.pdf' )
98+ } )
99+
100+ it ( 'returns empty array when db has no items' , async ( ) => {
101+ const res = await request ( getApp ( ) ) . get ( '/search/anything' )
102+ expect ( res . body ) . toEqual ( [ ] )
103+ } )
104+ } )
105+
106+ // ─────────────────────────────────────────────────────────────────────────────
107+ // GET /search/:channel/:q (channel-scoped)
108+ // ─────────────────────────────────────────────────────────────────────────────
109+
110+ describe ( 'GET /search/:channel/:q (channel-scoped)' , ( ) => {
111+ it ( 'returns an empty array when nothing matches' , async ( ) => {
112+ const res = await request ( getApp ( ) ) . get ( '/search/general/zzznomatch' )
113+
114+ expect ( res . status ) . toBe ( 200 )
115+ expect ( res . body ) . toEqual ( [ ] )
116+ } )
117+
118+ it ( 'finds an item by content within the channel' , async ( ) => {
119+ await insertItem ( { content : 'scoped result' , channel : 'general' } )
120+
121+ const res = await request ( getApp ( ) ) . get ( '/search/general/scoped' )
122+ expect ( res . body ) . toHaveLength ( 1 )
123+ expect ( res . body [ 0 ] . content ) . toBe ( 'scoped result' )
124+ } )
125+
126+ it ( 'does not return items from other channels' , async ( ) => {
127+ await insertItem ( { content : 'shared keyword' , channel : 'general' } )
128+ await insertItem ( { content : 'shared keyword' , channel : 'projects' } )
129+
130+ const res = await request ( getApp ( ) ) . get ( '/search/general/shared' )
131+ expect ( res . body ) . toHaveLength ( 1 )
132+ expect ( res . body [ 0 ] . channel ) . toBe ( 'general' )
133+ } )
134+
135+ it ( 'finds items by filename within the channel' , async ( ) => {
136+ await insertItem ( {
137+ type : 'file' ,
138+ filename : 'budget.xlsx' ,
139+ channel : 'projects'
140+ } )
141+ await insertItem ( {
142+ type : 'file' ,
143+ filename : 'budget.xlsx' ,
144+ channel : 'general'
145+ } )
146+
147+ const res = await request ( getApp ( ) ) . get ( '/search/projects/budget' )
148+ expect ( res . body ) . toHaveLength ( 1 )
149+ expect ( res . body [ 0 ] . channel ) . toBe ( 'projects' )
150+ } )
151+
152+ it ( 'returns pinned items before unpinned items' , async ( ) => {
153+ await insertItem ( { content : 'keyword unpinned' , channel : 'general' , pinned : 0 , created_at : 9000 } )
154+ await insertItem ( { content : 'keyword pinned' , channel : 'general' , pinned : 1 , created_at : 1000 } )
155+
156+ const res = await request ( getApp ( ) ) . get ( '/search/general/keyword' )
157+ expect ( res . body [ 0 ] . content ) . toBe ( 'keyword pinned' )
158+ } )
159+
160+ it ( 'is case insensitive' , async ( ) => {
161+ await insertItem ( { content : 'Deploy Script' , channel : 'general' } )
162+
163+ const res = await request ( getApp ( ) ) . get ( '/search/general/deploy' )
164+ expect ( res . body ) . toHaveLength ( 1 )
165+ } )
166+
167+ it ( 'returns empty array for a channel with no items' , async ( ) => {
168+ const res = await request ( getApp ( ) ) . get ( '/search/assets/anything' )
169+ expect ( res . body ) . toEqual ( [ ] )
170+ } )
171+ } )
0 commit comments