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