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