gabriel / musehub public

test_musehub_search.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Tests for MuseHub search endpoints.
2
3 Covers cross-repo global search:
4 - test_global_search_page_renders β€” GET /search returns 200 HTML
5 - test_global_search_results_grouped β€” JSON results are grouped by repo
6 - test_global_search_public_only β€” private repos are excluded
7 - test_global_search_json β€” JSON content-type returned
8 - test_global_search_empty_query_handled β€” graceful response for empty result set
9 - test_global_search_requires_auth β€” 401 without MSign auth
10 - test_global_search_keyword_mode β€” keyword mode matches across message terms
11 - test_global_search_pattern_mode β€” pattern mode uses SQL LIKE
12 - test_global_search_pagination β€” page/page_size params respected
13
14 Covers in-repo search:
15 - test_search_page_renders β€” GET /{repo_id}/search β†’ 200 HTML
16 - test_search_keyword_mode β€” keyword search returns matching commits
17 - test_search_keyword_empty_query β€” empty keyword query returns empty matches
18 - test_search_musical_property β€” musical property filter works
19 - test_search_natural_language β€” ask mode returns matching commits
20 - test_search_pattern_message β€” pattern matches commit message
21 - test_search_pattern_branch β€” pattern matches branch name
22 - test_search_json_response β€” JSON search endpoint returns SearchResponse shape
23 - test_search_date_range_since β€” since filter excludes old commits
24 - test_search_date_range_until β€” until filter excludes future commits
25 - test_search_invalid_mode β€” invalid mode returns 422
26 - test_search_unknown_repo β€” unknown repo_id returns 404
27 - test_search_requires_auth β€” unauthenticated request returns 401
28 - test_search_limit_respected β€” limit caps result count
29
30 All tests use the shared ``client`` and ``auth_headers`` fixtures from conftest.py.
31 """
32 from __future__ import annotations
33
34 import secrets
35 from datetime import datetime, timezone
36
37 import pytest
38 from httpx import AsyncClient
39 from sqlalchemy.ext.asyncio import AsyncSession
40
41 from muse.core.types import blob_id
42 from musehub.core.genesis import compute_identity_id, compute_repo_id
43 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
44 from musehub.muse_cli.models import MuseCliCommit, MuseCliSnapshot
45 from musehub.types.json_types import StrDict
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers β€” global search (uses MusehubCommit / MusehubRepo directly)
50 # ---------------------------------------------------------------------------
51
52
53 async def _make_repo(
54 db_session: AsyncSession,
55 *,
56 name: str = "test-repo",
57 visibility: str = "public",
58 owner: str = "test-owner",
59 ) -> str:
60 """Seed a MuseHub repo and return its repo_id."""
61 import re as _re
62 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
63 created_at = datetime.now(tz=timezone.utc)
64 owner_id = compute_identity_id(owner.encode())
65 repo = MusehubRepo(
66 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
67 name=name,
68 owner="testuser",
69 slug=slug,
70 visibility=visibility,
71 owner_user_id=owner_id,
72 created_at=created_at,
73 updated_at=created_at,
74 )
75 db_session.add(repo)
76 await db_session.commit()
77 await db_session.refresh(repo)
78 return str(repo.repo_id)
79
80
81 async def _make_commit(
82 db_session: AsyncSession,
83 repo_id: str,
84 *,
85 commit_id: str,
86 message: str,
87 author: str = "alice",
88 branch: str = "main",
89 ) -> None:
90 """Seed a MusehubCommit for global search tests."""
91 commit = MusehubCommit(
92 commit_id=commit_id,
93 branch=branch,
94 parent_ids=[],
95 message=message,
96 author=author,
97 timestamp=datetime.now(tz=timezone.utc),
98 )
99 db_session.add(commit)
100 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
101 await db_session.commit()
102
103
104 # ---------------------------------------------------------------------------
105 # Helpers β€” in-repo search (uses MuseCliCommit / MuseCliSnapshot)
106 # ---------------------------------------------------------------------------
107
108
109 async def _make_search_repo(db: AsyncSession) -> str:
110 """Seed a minimal MuseHub repo for in-repo search tests; return repo_id."""
111 created_at = datetime.now(tz=timezone.utc)
112 owner_id = compute_identity_id(b"testuser")
113 repo = MusehubRepo(
114 repo_id=compute_repo_id(owner_id, "search-test-repo", "code", created_at.isoformat()),
115 name="search-test-repo",
116 owner="testuser",
117 slug="search-test-repo",
118 visibility="private",
119 owner_user_id=owner_id,
120 created_at=created_at,
121 updated_at=created_at,
122 )
123 db.add(repo)
124 await db.commit()
125 await db.refresh(repo)
126 return str(repo.repo_id)
127
128
129 async def _make_snapshot(db: AsyncSession, snapshot_id: str) -> None:
130 """Seed a minimal snapshot so FK constraint on MuseCliCommit is satisfied."""
131 snap = MuseCliSnapshot(snapshot_id=snapshot_id, manifest={})
132 db.add(snap)
133 await db.flush()
134
135
136 async def _make_search_commit(
137 db: AsyncSession,
138 *,
139 repo_id: str,
140 message: str,
141 branch: str = "main",
142 author: str = "test-author",
143 committed_at: datetime | None = None,
144 ) -> MuseCliCommit:
145 """Seed a MuseCliCommit for in-repo search tests."""
146 snap_id = f"snap-{secrets.token_hex(8)}"
147 await _make_snapshot(db, snap_id)
148 commit = MuseCliCommit(
149 commit_id=blob_id(secrets.token_bytes(16)),
150 repo_id=repo_id,
151 branch=branch,
152 snapshot_id=snap_id,
153 message=message,
154 author=author,
155 committed_at=committed_at or datetime.now(timezone.utc),
156 )
157 db.add(commit)
158 await db.flush()
159 return commit
160
161
162 # ---------------------------------------------------------------------------
163 # Global search β€” UI page
164 # ---------------------------------------------------------------------------
165
166
167 async def test_global_search_page_renders(
168 client: AsyncClient,
169 db_session: AsyncSession,
170 ) -> None:
171 """GET /search returns 200 HTML with a search form (no auth required)."""
172 response = await client.get("/search")
173 assert response.status_code == 200
174 assert "text/html" in response.headers["content-type"]
175 body = response.text
176 assert "Global Search" in body
177 assert "MuseHub" in body
178 assert 'name="q"' in body
179 assert 'name="mode"' in body
180
181
182 async def test_global_search_page_pre_fills_query(
183 client: AsyncClient,
184 db_session: AsyncSession,
185 ) -> None:
186 """GET /search?q=jazz pre-fills the search form with 'jazz'."""
187 response = await client.get("/search?q=jazz&mode=keyword")
188 assert response.status_code == 200
189 body = response.text
190 assert "jazz" in body
191
192
193 # ---------------------------------------------------------------------------
194 # Global search β€” JSON API
195 # ---------------------------------------------------------------------------
196
197
198 async def test_global_search_accessible_without_auth(
199 client: AsyncClient,
200 db_session: AsyncSession,
201 ) -> None:
202 """GET /api/search returns 200 without authentication.
203
204 Global search is a public endpoint β€” uses optional_token, so unauthenticated
205 requests are allowed and return results for public repos.
206 """
207 response = await client.get("/api/search?q=jazz")
208 assert response.status_code == 200
209
210
211 async def test_global_search_json(
212 client: AsyncClient,
213 db_session: AsyncSession,
214 auth_headers: StrDict,
215 ) -> None:
216 """GET /api/search returns JSON with correct content-type."""
217 response = await client.get(
218 "/api/search?q=jazz",
219 headers=auth_headers,
220 )
221 assert response.status_code == 200
222 assert "application/json" in response.headers["content-type"]
223 data = response.json()
224 assert "groups" in data
225 assert "query" in data
226 assert data["query"] == "jazz"
227
228
229 async def test_global_search_public_only(
230 client: AsyncClient,
231 db_session: AsyncSession,
232 auth_headers: StrDict,
233 ) -> None:
234 """Private repos must not appear in global search results."""
235 public_id = await _make_repo(db_session, name="public-beats", visibility="public")
236 private_id = await _make_repo(db_session, name="secret-beats", visibility="private")
237
238 await _make_commit(
239 db_session, public_id, commit_id="pub001abc", message="jazz groove session"
240 )
241 await _make_commit(
242 db_session, private_id, commit_id="priv001abc", message="jazz private session"
243 )
244
245 response = await client.get(
246 "/api/search?q=jazz",
247 headers=auth_headers,
248 )
249 assert response.status_code == 200
250 data = response.json()
251 repo_ids_in_results = {g["repoId"] for g in data["groups"]}
252 assert public_id in repo_ids_in_results
253 assert private_id not in repo_ids_in_results
254
255
256 async def test_global_search_results_grouped(
257 client: AsyncClient,
258 db_session: AsyncSession,
259 auth_headers: StrDict,
260 ) -> None:
261 """Results are grouped by repo β€” each group has repoId, repoName, matches list."""
262 repo_a = await _make_repo(db_session, name="repo-alpha", visibility="public")
263 repo_b = await _make_repo(db_session, name="repo-beta", visibility="public")
264
265 await _make_commit(
266 db_session, repo_a, commit_id="a001abc123", message="bossa nova rhythm"
267 )
268 await _make_commit(
269 db_session, repo_a, commit_id="a002abc123", message="bossa nova variation"
270 )
271 await _make_commit(
272 db_session, repo_b, commit_id="b001abc123", message="bossa nova groove"
273 )
274
275 response = await client.get(
276 "/api/search?q=bossa+nova",
277 headers=auth_headers,
278 )
279 assert response.status_code == 200
280 data = response.json()
281 groups = data["groups"]
282
283 group_repo_ids = {g["repoId"] for g in groups}
284 assert repo_a in group_repo_ids
285 assert repo_b in group_repo_ids
286
287 for group in groups:
288 assert "repoId" in group
289 assert "repoName" in group
290 assert "repoOwner" in group
291 assert "repoSlug" in group # Proposal #282: slug required for UI link construction
292 assert "repoVisibility" in group
293 assert "matches" in group
294 assert "totalMatches" in group
295 assert isinstance(group["matches"], list)
296 assert isinstance(group["repoSlug"], str)
297 assert group["repoSlug"] != ""
298
299 group_a = next(g for g in groups if g["repoId"] == repo_a)
300 assert group_a["totalMatches"] == 2
301 assert len(group_a["matches"]) == 2
302
303
304 async def test_global_search_empty_query_handled(
305 client: AsyncClient,
306 db_session: AsyncSession,
307 auth_headers: StrDict,
308 ) -> None:
309 """A query that matches nothing returns empty groups and valid pagination metadata."""
310 await _make_repo(db_session, name="silent-repo", visibility="public")
311
312 response = await client.get(
313 "/api/search?q=zyxqwvutsr_no_match",
314 headers=auth_headers,
315 )
316 assert response.status_code == 200
317 data = response.json()
318 assert data["groups"] == []
319 assert data["nextCursor"] is None
320 assert "totalReposSearched" in data
321
322
323 async def test_global_search_keyword_mode(
324 client: AsyncClient,
325 db_session: AsyncSession,
326 auth_headers: StrDict,
327 ) -> None:
328 """Keyword mode matches any term in the query (OR logic, case-insensitive)."""
329 repo_id = await _make_repo(db_session, name="jazz-lab", visibility="public")
330 await _make_commit(
331 db_session, repo_id, commit_id="kw001abcde", message="Blues Shuffle in E"
332 )
333 await _make_commit(
334 db_session, repo_id, commit_id="kw002abcde", message="Jazz Waltz Trio"
335 )
336
337 response = await client.get(
338 "/api/search?q=blues&mode=keyword",
339 headers=auth_headers,
340 )
341 assert response.status_code == 200
342 data = response.json()
343 group = next((g for g in data["groups"] if g["repoId"] == repo_id), None)
344 assert group is not None
345 messages = [m["message"] for m in group["matches"]]
346 assert any("Blues" in msg for msg in messages)
347
348
349 async def test_global_search_pattern_mode(
350 client: AsyncClient,
351 db_session: AsyncSession,
352 auth_headers: StrDict,
353 ) -> None:
354 """Pattern mode applies a raw SQL LIKE pattern to commit messages."""
355 repo_id = await _make_repo(db_session, name="pattern-lab", visibility="public")
356 await _make_commit(
357 db_session, repo_id, commit_id="pt001abcde", message="minor pentatonic run"
358 )
359 await _make_commit(
360 db_session, repo_id, commit_id="pt002abcde", message="major scale exercise"
361 )
362
363 response = await client.get(
364 "/api/search?q=%25minor%25&mode=pattern",
365 headers=auth_headers,
366 )
367 assert response.status_code == 200
368 data = response.json()
369 group = next((g for g in data["groups"] if g["repoId"] == repo_id), None)
370 assert group is not None
371 assert group["totalMatches"] == 1
372 assert "minor" in group["matches"][0]["message"]
373
374
375 async def test_global_search_pagination(
376 client: AsyncClient,
377 db_session: AsyncSession,
378 auth_headers: StrDict,
379 ) -> None:
380 """cursor and limit parameters control repo-group cursor pagination."""
381 ids = []
382 for i in range(3):
383 rid = await _make_repo(
384 db_session, name=f"paged-repo-{i}", visibility="public", owner=f"owner-{i}"
385 )
386 ids.append(rid)
387 await _make_commit(
388 db_session, rid, commit_id=f"pg{i:03d}abcde", message="paginate funk groove"
389 )
390
391 response = await client.get(
392 "/api/search?q=paginate&limit=2",
393 headers=auth_headers,
394 )
395 assert response.status_code == 200
396 data = response.json()
397 assert len(data["groups"]) <= 2
398 assert data["nextCursor"] is not None
399 # Cursor must be a repo_id string, not an integer offset.
400 next_cursor = data["nextCursor"]
401 assert not next_cursor.isdigit(), "nextCursor must be a repo_id, not an integer offset"
402
403 response2 = await client.get(
404 f"/api/search?q=paginate&limit=2&cursor={next_cursor}",
405 headers=auth_headers,
406 )
407 assert response2.status_code == 200
408 data2 = response2.json()
409 # Second page has the remaining repo; no pages overlap.
410 assert len(data2["groups"]) >= 1
411 first_page_ids = {g["repoId"] for g in data["groups"]}
412 second_page_ids = {g["repoId"] for g in data2["groups"]}
413 assert first_page_ids.isdisjoint(second_page_ids), "Pages must not overlap"
414
415
416 async def test_global_search_match_contains_required_fields(
417 client: AsyncClient,
418 db_session: AsyncSession,
419 auth_headers: StrDict,
420 ) -> None:
421 """Each match entry contains commitId, message, author, branch, timestamp, repoId."""
422 repo_id = await _make_repo(db_session, name="fields-check", visibility="public")
423 await _make_commit(
424 db_session,
425 repo_id,
426 commit_id="fc001abcde",
427 message="swing feel experiment",
428 author="charlie",
429 branch="main",
430 )
431
432 response = await client.get(
433 "/api/search?q=swing",
434 headers=auth_headers,
435 )
436 assert response.status_code == 200
437 data = response.json()
438 group = next((g for g in data["groups"] if g["repoId"] == repo_id), None)
439 assert group is not None
440 match = group["matches"][0]
441 assert match["commitId"] == "fc001abcde"
442 assert match["message"] == "swing feel experiment"
443 assert match["author"] == "charlie"
444 assert match["branch"] == "main"
445 assert "timestamp" in match
446 assert match["repoId"] == repo_id
447
448
449
450 # ---------------------------------------------------------------------------
451 # In-repo search β€” authentication
452 # ---------------------------------------------------------------------------
453
454
455 async def test_search_requires_auth(
456 client: AsyncClient,
457 db_session: AsyncSession,
458 ) -> None:
459 """GET /api/repos/{repo_id}/search returns 401 without a token."""
460 repo_id = await _make_search_repo(db_session)
461 response = await client.get(f"/api/repos/{repo_id}/search?mode=keyword&q=jazz")
462 assert response.status_code == 401
463
464
465 async def test_search_unknown_repo(
466 client: AsyncClient,
467 db_session: AsyncSession,
468 auth_headers: StrDict,
469 ) -> None:
470 """GET /api/repos/{unknown}/search returns 404."""
471 response = await client.get(
472 "/api/repos/does-not-exist/search?mode=keyword&q=test",
473 headers=auth_headers,
474 )
475 assert response.status_code == 404
476
477
478 async def test_search_invalid_mode(
479 client: AsyncClient,
480 db_session: AsyncSession,
481 auth_headers: StrDict,
482 ) -> None:
483 """GET search with an unknown mode returns 422."""
484 repo_id = await _make_search_repo(db_session)
485 response = await client.get(
486 f"/api/repos/{repo_id}/search?mode=badmode&q=x",
487 headers=auth_headers,
488 )
489 assert response.status_code == 422
490
491
492 # ---------------------------------------------------------------------------
493 # In-repo search β€” keyword mode
494 # ---------------------------------------------------------------------------
495
496
497 async def test_search_keyword_mode(
498 client: AsyncClient,
499 db_session: AsyncSession,
500 auth_headers: StrDict,
501 ) -> None:
502 """Keyword search returns commits whose messages overlap with the query."""
503 repo_id = await _make_search_repo(db_session)
504 await db_session.commit()
505
506 await _make_search_commit(db_session, repo_id=repo_id, message="dark jazz bassline in Dm")
507 await _make_search_commit(db_session, repo_id=repo_id, message="classical piano intro section")
508 await _make_search_commit(db_session, repo_id=repo_id, message="hip hop drum fill pattern")
509 await db_session.commit()
510
511 response = await client.get(
512 f"/api/repos/{repo_id}/search?mode=keyword&q=jazz+bassline",
513 headers=auth_headers,
514 )
515 assert response.status_code == 200
516 data = response.json()
517 assert data["mode"] == "keyword"
518 assert data["query"] == "jazz bassline"
519 assert any("jazz" in m["message"].lower() for m in data["matches"])
520
521
522 async def test_search_keyword_empty_query(
523 client: AsyncClient,
524 db_session: AsyncSession,
525 auth_headers: StrDict,
526 ) -> None:
527 """Empty keyword query returns empty matches (no tokens β†’ no overlap)."""
528 repo_id = await _make_search_repo(db_session)
529 await db_session.commit()
530 await _make_search_commit(db_session, repo_id=repo_id, message="some commit")
531 await db_session.commit()
532
533 response = await client.get(
534 f"/api/repos/{repo_id}/search?mode=keyword&q=",
535 headers=auth_headers,
536 )
537 assert response.status_code == 200
538 data = response.json()
539 assert data["mode"] == "keyword"
540 assert data["matches"] == []
541
542
543 async def test_search_json_response(
544 client: AsyncClient,
545 db_session: AsyncSession,
546 auth_headers: StrDict,
547 ) -> None:
548 """Search response has the expected SearchResponse JSON shape."""
549 repo_id = await _make_search_repo(db_session)
550 await db_session.commit()
551 await _make_search_commit(db_session, repo_id=repo_id, message="piano chord progression F Bb Eb")
552 await db_session.commit()
553
554 response = await client.get(
555 f"/api/repos/{repo_id}/search?mode=keyword&q=piano",
556 headers=auth_headers,
557 )
558 assert response.status_code == 200
559 data = response.json()
560
561 assert "mode" in data
562 assert "query" in data
563 assert "matches" in data
564 assert "totalScanned" in data
565 assert "limit" in data
566
567 if data["matches"]:
568 m = data["matches"][0]
569 assert "commitId" in m
570 assert "branch" in m
571 assert "message" in m
572 assert "author" in m
573 assert "timestamp" in m
574 assert "score" in m
575 assert "matchSource" in m
576
577
578 # ---------------------------------------------------------------------------
579 # In-repo search β€” musical property mode
580 # ---------------------------------------------------------------------------
581
582
583 async def test_search_musical_property(
584 client: AsyncClient,
585 db_session: AsyncSession,
586 auth_headers: StrDict,
587 ) -> None:
588 """Property mode returns a valid response (muse-extraction may be unavailable in test)."""
589 repo_id = await _make_search_repo(db_session)
590 await db_session.commit()
591
592 await _make_search_commit(db_session, repo_id=repo_id, message="add harmony=Eb bridge section")
593 await _make_search_commit(db_session, repo_id=repo_id, message="drum groove tweak no harmony")
594 await db_session.commit()
595
596 response = await client.get(
597 f"/api/repos/{repo_id}/search?mode=property&harmony=Eb",
598 headers=auth_headers,
599 )
600 assert response.status_code == 200
601 data = response.json()
602 assert data["mode"] == "property"
603 assert "matches" in data
604 assert isinstance(data["matches"], list)
605
606
607 # ---------------------------------------------------------------------------
608 # In-repo search β€” natural language (ask) mode
609 # ---------------------------------------------------------------------------
610
611
612 async def test_search_natural_language(
613 client: AsyncClient,
614 db_session: AsyncSession,
615 auth_headers: StrDict,
616 ) -> None:
617 """Ask mode extracts keywords and returns relevant commits."""
618 repo_id = await _make_search_repo(db_session)
619 await db_session.commit()
620
621 await _make_search_commit(db_session, repo_id=repo_id, message="switched tempo to 140bpm for drop")
622 await _make_search_commit(db_session, repo_id=repo_id, message="piano melody in minor key")
623 await db_session.commit()
624
625 response = await client.get(
626 f"/api/repos/{repo_id}/search?mode=ask&q=what+tempo+changes+did+I+make",
627 headers=auth_headers,
628 )
629 assert response.status_code == 200
630 data = response.json()
631 assert data["mode"] == "ask"
632 assert any("tempo" in m["message"].lower() for m in data["matches"])
633
634
635 # ---------------------------------------------------------------------------
636 # In-repo search β€” pattern mode
637 # ---------------------------------------------------------------------------
638
639
640 async def test_search_pattern_message(
641 client: AsyncClient,
642 db_session: AsyncSession,
643 auth_headers: StrDict,
644 ) -> None:
645 """Pattern mode matches substring in commit message."""
646 repo_id = await _make_search_repo(db_session)
647 await db_session.commit()
648
649 await _make_search_commit(db_session, repo_id=repo_id, message="add Cm7 chord voicing in bridge")
650 await _make_search_commit(db_session, repo_id=repo_id, message="fix timing on verse drums")
651 await db_session.commit()
652
653 response = await client.get(
654 f"/api/repos/{repo_id}/search?mode=pattern&q=Cm7",
655 headers=auth_headers,
656 )
657 assert response.status_code == 200
658 data = response.json()
659 assert data["mode"] == "pattern"
660 assert len(data["matches"]) == 1
661 assert "Cm7" in data["matches"][0]["message"]
662 assert data["matches"][0]["matchSource"] == "message"
663
664
665 async def test_search_pattern_branch(
666 client: AsyncClient,
667 db_session: AsyncSession,
668 auth_headers: StrDict,
669 ) -> None:
670 """Pattern mode matches substring in branch name when message doesn't match."""
671 repo_id = await _make_search_repo(db_session)
672 await db_session.commit()
673
674 await _make_search_commit(
675 db_session,
676 repo_id=repo_id,
677 message="rough cut",
678 branch="feature/hip-hop-session",
679 )
680 await db_session.commit()
681
682 response = await client.get(
683 f"/api/repos/{repo_id}/search?mode=pattern&q=hip-hop",
684 headers=auth_headers,
685 )
686 assert response.status_code == 200
687 data = response.json()
688 assert data["mode"] == "pattern"
689 assert len(data["matches"]) == 1
690 assert data["matches"][0]["matchSource"] == "branch"
691
692
693 # ---------------------------------------------------------------------------
694 # In-repo search β€” date range filters
695 # ---------------------------------------------------------------------------
696
697
698 async def test_search_date_range_since(
699 client: AsyncClient,
700 db_session: AsyncSession,
701 auth_headers: StrDict,
702 ) -> None:
703 """since filter excludes commits committed before the given datetime."""
704 repo_id = await _make_search_repo(db_session)
705 await db_session.commit()
706
707 old_ts = datetime(2024, 1, 1, tzinfo=timezone.utc)
708 new_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
709
710 await _make_search_commit(db_session, repo_id=repo_id, message="old jazz commit", committed_at=old_ts)
711 await _make_search_commit(db_session, repo_id=repo_id, message="new jazz commit", committed_at=new_ts)
712 await db_session.commit()
713
714 response = await client.get(
715 f"/api/repos/{repo_id}/search?mode=keyword&q=jazz&since=2025-06-01T00:00:00Z",
716 headers=auth_headers,
717 )
718 assert response.status_code == 200
719 data = response.json()
720 assert all(m["message"] != "old jazz commit" for m in data["matches"])
721 assert any(m["message"] == "new jazz commit" for m in data["matches"])
722
723
724 async def test_search_date_range_until(
725 client: AsyncClient,
726 db_session: AsyncSession,
727 auth_headers: StrDict,
728 ) -> None:
729 """until filter excludes commits committed after the given datetime."""
730 repo_id = await _make_search_repo(db_session)
731 await db_session.commit()
732
733 old_ts = datetime(2024, 1, 1, tzinfo=timezone.utc)
734 new_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
735
736 await _make_search_commit(db_session, repo_id=repo_id, message="old piano commit", committed_at=old_ts)
737 await _make_search_commit(db_session, repo_id=repo_id, message="new piano commit", committed_at=new_ts)
738 await db_session.commit()
739
740 response = await client.get(
741 f"/api/repos/{repo_id}/search?mode=keyword&q=piano&until=2025-06-01T00:00:00Z",
742 headers=auth_headers,
743 )
744 assert response.status_code == 200
745 data = response.json()
746 assert any(m["message"] == "old piano commit" for m in data["matches"])
747 assert all(m["message"] != "new piano commit" for m in data["matches"])
748
749
750 # ---------------------------------------------------------------------------
751 # In-repo search β€” limit
752 # ---------------------------------------------------------------------------
753
754
755 async def test_search_limit_respected(
756 client: AsyncClient,
757 db_session: AsyncSession,
758 auth_headers: StrDict,
759 ) -> None:
760 """The limit parameter caps the number of results returned."""
761 repo_id = await _make_search_repo(db_session)
762 await db_session.commit()
763
764 for i in range(10):
765 await _make_search_commit(db_session, repo_id=repo_id, message=f"bass groove iteration {i}")
766 await db_session.commit()
767
768 response = await client.get(
769 f"/api/repos/{repo_id}/search?mode=keyword&q=bass&limit=3",
770 headers=auth_headers,
771 )
772 assert response.status_code == 200
773 data = response.json()
774 assert len(data["matches"]) <= 3
775 assert data["limit"] == 3