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