gabriel / musehub public
test_search_section17.py python
712 lines 25.3 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 17 — Search: 7-layer test suite.
2
3 Covers gaps in the existing 26 search tests:
4
5 Layer 1 Unit:
6 - _tokenize returns lowercase tokens, ignores punctuation
7 - _tokenize empty string → empty set
8 - _overlap_score full match → 1.0
9 - _overlap_score partial match
10 - _overlap_score no match → 0.0
11 - _overlap_score empty query → 0.0
12 - _commit_to_match round-trips all fields, rounds score to 4dp
13 - _DEFAULT_LIMIT == 20
14 - _STOP_WORDS excludes common words
15
16 Layer 2 Integration:
17 - search_by_keyword with matching commit
18 - search_by_keyword no match → empty matches
19 - search_by_keyword threshold filters low-scoring commits
20 - search_by_ask strips stop-words before scoring
21 - search_by_ask no useful tokens → all commits included
22 - search_by_pattern message match preferred over branch match
23 - search_by_pattern case-insensitive
24 - search_by_property returns empty matches (stub)
25 - _fetch_candidates respects since/until filters
26 - _fetch_candidates caps at 5000
27
28 Layer 3 E2E:
29 - GET /api/v1/search?q=foo returns GlobalSearchResult JSON
30 - GET /api/v1/search missing q → 422
31 - GET /{repo_id}/search/commits?q=foo&mode=keyword → SearchResponse
32 - GET /{repo_id}/search/commits invalid mode → 422
33 - GET /{repo_id}/search/commits unknown repo → 404
34 - GET /{repo_id}/search/commits private repo no auth → 401
35
36 Layer 4 Stress:
37 - 200 commits, keyword search returns at most limit=10
38 - 5 concurrent search_by_keyword calls, all succeed
39
40 Layer 5 Data Integrity:
41 - keyword match_source == "message", score in [0,1]
42 - pattern message match_source == "message"
43 - pattern branch match_source == "branch"
44 - ask mode scores commits with matching tokens higher
45
46 Layer 6 Security:
47 - SQL injection pattern in q param handled safely
48 - XSS probe in q echoed as plain text in JSON (not rendered)
49 - Very long query (10k chars) doesn't crash
50 - null bytes in query handled gracefully
51
52 Layer 7 Performance:
53 - 1000x _tokenize calls in <100ms
54 - search_by_keyword over 500 commits completes in <500ms
55 """
56 from __future__ import annotations
57
58 import asyncio
59 import time
60 import uuid
61 from datetime import datetime, timezone, timedelta
62
63 import pytest
64 from httpx import AsyncClient
65 from sqlalchemy.ext.asyncio import AsyncSession
66
67 from musehub.db.musehub_models import MusehubRepo, MusehubCommit
68 from musehub.muse_cli.models import MuseCliCommit, MuseCliSnapshot
69 from musehub.muse_contracts.json_types import StrDict
70 from musehub.services.musehub_search import (
71 _tokenize,
72 _overlap_score,
73 _commit_to_match,
74 _DEFAULT_LIMIT,
75 _STOP_WORDS,
76 search_by_ask,
77 search_by_keyword,
78 search_by_pattern,
79 search_by_property,
80 )
81
82
83 # ── Shared helpers ────────────────────────────────────────────────────────────
84
85
86 def _uid() -> str:
87 return str(uuid.uuid4()).replace("-", "")
88
89
90 async def _repo(
91 session: AsyncSession,
92 *,
93 visibility: str = "public",
94 name: str | None = None,
95 ) -> str:
96 name = name or f"repo-{_uid()[:8]}"
97 slug = name[:64]
98 r = MusehubRepo(
99 name=name,
100 owner="testuser",
101 slug=slug,
102 visibility=visibility,
103 owner_user_id="test-owner",
104 )
105 session.add(r)
106 await session.flush()
107 return str(r.repo_id)
108
109
110 async def _snap(session: AsyncSession) -> str:
111 snap_id = f"snap-{_uid()[:16]}"
112 session.add(MuseCliSnapshot(snapshot_id=snap_id, manifest={}))
113 await session.flush()
114 return snap_id
115
116
117 async def _commit(
118 session: AsyncSession,
119 repo_id: str,
120 *,
121 message: str = "test commit",
122 branch: str = "main",
123 author: str = "alice",
124 committed_at: datetime | None = None,
125 ) -> MuseCliCommit:
126 snap_id = await _snap(session)
127 c = MuseCliCommit(
128 commit_id=_uid(),
129 repo_id=repo_id,
130 branch=branch,
131 snapshot_id=snap_id,
132 message=message,
133 author=author,
134 committed_at=committed_at or datetime.now(timezone.utc),
135 )
136 session.add(c)
137 await session.flush()
138 return c
139
140
141 async def _hub_commit(
142 session: AsyncSession,
143 repo_id: str,
144 *,
145 message: str = "hub commit",
146 branch: str = "main",
147 ) -> None:
148 c = MusehubCommit(
149 commit_id=_uid(),
150 repo_id=repo_id,
151 branch=branch,
152 parent_ids=[],
153 message=message,
154 author="testuser",
155 timestamp=datetime.now(tz=timezone.utc),
156 )
157 session.add(c)
158 await session.flush()
159
160
161 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
162
163
164 class TestUnitTokenize:
165 def test_returns_lowercase_tokens(self) -> None:
166 result = _tokenize("Hello World")
167 assert result == {"hello", "world"}
168
169 def test_ignores_punctuation(self) -> None:
170 result = _tokenize("hello, world! foo-bar")
171 assert "hello" in result
172 assert "world" in result
173 assert "foo" in result
174 assert "bar" in result
175
176 def test_empty_string_returns_empty_set(self) -> None:
177 assert _tokenize("") == set()
178
179 def test_alphanumeric_tokens(self) -> None:
180 result = _tokenize("feat123 fix456")
181 assert "feat123" in result
182 assert "fix456" in result
183
184 def test_deduplicates_tokens(self) -> None:
185 result = _tokenize("foo foo foo")
186 assert result == {"foo"}
187
188
189 class TestUnitOverlapScore:
190 def test_full_match_returns_one(self) -> None:
191 score = _overlap_score({"hello", "world"}, "hello world extra")
192 assert score == 1.0
193
194 def test_partial_match(self) -> None:
195 score = _overlap_score({"hello", "world"}, "hello extra")
196 assert score == 0.5
197
198 def test_no_match_returns_zero(self) -> None:
199 score = _overlap_score({"hello"}, "goodbye universe")
200 assert score == 0.0
201
202 def test_empty_query_returns_zero(self) -> None:
203 score = _overlap_score(set(), "anything goes")
204 assert score == 0.0
205
206 def test_single_token_match(self) -> None:
207 score = _overlap_score({"jazz"}, "jazz fusion bassline")
208 assert score == 1.0
209
210
211 class TestUnitCommitToMatch:
212 def test_round_trips_all_fields(self) -> None:
213 from musehub.models.musehub import SearchCommitMatch
214
215 c = MuseCliCommit(
216 commit_id="abc123",
217 repo_id="repo-1",
218 branch="main",
219 snapshot_id="snap-1",
220 message="add harmony voice",
221 author="alice",
222 committed_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
223 )
224 match = _commit_to_match(c, score=0.12345678, match_source="message")
225 assert isinstance(match, SearchCommitMatch)
226 assert match.commit_id == "abc123"
227 assert match.branch == "main"
228 assert match.message == "add harmony voice"
229 assert match.author == "alice"
230 assert match.score == round(0.12345678, 4)
231 assert match.match_source == "message"
232
233 def test_default_score_is_one(self) -> None:
234 c = MuseCliCommit(
235 commit_id="x",
236 repo_id="r",
237 branch="b",
238 snapshot_id="s",
239 message="m",
240 author="a",
241 committed_at=datetime.now(timezone.utc),
242 )
243 match = _commit_to_match(c)
244 assert match.score == 1.0
245
246 def test_score_rounded_to_4dp(self) -> None:
247 c = MuseCliCommit(
248 commit_id="x",
249 repo_id="r",
250 branch="b",
251 snapshot_id="s",
252 message="m",
253 author="a",
254 committed_at=datetime.now(timezone.utc),
255 )
256 match = _commit_to_match(c, score=1 / 3)
257 assert match.score == round(1 / 3, 4)
258
259
260 class TestUnitConstants:
261 def test_default_limit_is_20(self) -> None:
262 assert _DEFAULT_LIMIT == 20
263
264 def test_stop_words_contains_common_words(self) -> None:
265 for word in ("the", "a", "is", "and", "or", "in", "to"):
266 assert word in _STOP_WORDS
267
268 def test_stop_words_does_not_contain_jazz(self) -> None:
269 assert "jazz" not in _STOP_WORDS
270
271 def test_stop_words_is_frozenset(self) -> None:
272 assert isinstance(_STOP_WORDS, frozenset)
273
274
275 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
276
277
278 class TestIntegrationKeyword:
279 @pytest.mark.anyio
280 async def test_matching_commit_returned(self, db_session: AsyncSession) -> None:
281 repo_id = await _repo(db_session)
282 await _commit(db_session, repo_id, message="add harmony voice to the mix")
283 await db_session.commit()
284
285 result = await search_by_keyword(db_session, repo_id=repo_id, keyword="harmony")
286 assert len(result.matches) == 1
287 assert "harmony" in result.matches[0].message
288
289 @pytest.mark.anyio
290 async def test_no_match_returns_empty(self, db_session: AsyncSession) -> None:
291 repo_id = await _repo(db_session)
292 await _commit(db_session, repo_id, message="bassline groove")
293 await db_session.commit()
294
295 result = await search_by_keyword(db_session, repo_id=repo_id, keyword="trumpet")
296 assert result.matches == []
297 assert result.mode == "keyword"
298
299 @pytest.mark.anyio
300 async def test_threshold_filters_low_scores(self, db_session: AsyncSession) -> None:
301 repo_id = await _repo(db_session)
302 # "jazz rhythm" → keyword="jazz rhythm" → tokens={jazz,rhythm}
303 # commit has only "jazz" → overlap = 0.5
304 await _commit(db_session, repo_id, message="jazz improvisation")
305 await db_session.commit()
306
307 # With threshold=0.8, score=0.5 commit should be excluded.
308 result = await search_by_keyword(
309 db_session, repo_id=repo_id, keyword="jazz rhythm", threshold=0.8
310 )
311 assert result.matches == []
312
313 @pytest.mark.anyio
314 async def test_mode_field_is_keyword(self, db_session: AsyncSession) -> None:
315 repo_id = await _repo(db_session)
316 await db_session.commit()
317 result = await search_by_keyword(db_session, repo_id=repo_id, keyword="anything")
318 assert result.mode == "keyword"
319
320
321 class TestIntegrationAsk:
322 @pytest.mark.anyio
323 async def test_strips_stop_words_before_scoring(self, db_session: AsyncSession) -> None:
324 repo_id = await _repo(db_session)
325 # "the jazz" → stop-word "the" removed → keyword "jazz" scored
326 await _commit(db_session, repo_id, message="jazz fusion experiment")
327 await _commit(db_session, repo_id, message="rock anthem beats")
328 await db_session.commit()
329
330 result = await search_by_ask(db_session, repo_id=repo_id, question="the jazz")
331 matched_messages = [m.message for m in result.matches]
332 assert any("jazz" in msg for msg in matched_messages)
333 assert result.mode == "ask"
334
335 @pytest.mark.anyio
336 async def test_all_stop_words_includes_all_commits(self, db_session: AsyncSession) -> None:
337 repo_id = await _repo(db_session)
338 await _commit(db_session, repo_id, message="first commit message")
339 await _commit(db_session, repo_id, message="second commit message")
340 await db_session.commit()
341
342 # Query made entirely of stop-words → no keywords → score 1.0 for all
343 result = await search_by_ask(
344 db_session, repo_id=repo_id, question="the a is and or"
345 )
346 assert len(result.matches) == 2
347
348
349 class TestIntegrationPattern:
350 @pytest.mark.anyio
351 async def test_message_match_preferred_over_branch_match(
352 self, db_session: AsyncSession
353 ) -> None:
354 repo_id = await _repo(db_session)
355 # Two commits: one with "jazz" in message, one with "jazz" in branch
356 await _commit(db_session, repo_id, message="jazz fusion", branch="main")
357 await _commit(db_session, repo_id, message="unrelated", branch="jazz-experiment")
358 await db_session.commit()
359
360 result = await search_by_pattern(db_session, repo_id=repo_id, pattern="jazz")
361 assert len(result.matches) == 2
362 # Message match must come first.
363 assert result.matches[0].match_source == "message"
364 assert result.matches[1].match_source == "branch"
365
366 @pytest.mark.anyio
367 async def test_case_insensitive(self, db_session: AsyncSession) -> None:
368 repo_id = await _repo(db_session)
369 await _commit(db_session, repo_id, message="JAZZ FUSION")
370 await db_session.commit()
371
372 result = await search_by_pattern(db_session, repo_id=repo_id, pattern="jazz")
373 assert len(result.matches) == 1
374
375 @pytest.mark.anyio
376 async def test_mode_field_is_pattern(self, db_session: AsyncSession) -> None:
377 repo_id = await _repo(db_session)
378 await db_session.commit()
379 result = await search_by_pattern(db_session, repo_id=repo_id, pattern="x")
380 assert result.mode == "pattern"
381
382
383 class TestIntegrationProperty:
384 @pytest.mark.anyio
385 async def test_returns_empty_matches_stub(self, db_session: AsyncSession) -> None:
386 repo_id = await _repo(db_session)
387 await _commit(db_session, repo_id, message="some commit")
388 await db_session.commit()
389
390 result = await search_by_property(
391 db_session, repo_id=repo_id, harmony="Fmin"
392 )
393 # property mode is a stub — always returns empty matches
394 assert result.matches == []
395 assert result.mode == "property"
396
397
398 class TestIntegrationFetchCandidates:
399 @pytest.mark.anyio
400 async def test_since_filter(self, db_session: AsyncSession) -> None:
401 repo_id = await _repo(db_session)
402 old = datetime(2020, 1, 1, tzinfo=timezone.utc)
403 new = datetime(2025, 1, 1, tzinfo=timezone.utc)
404 await _commit(db_session, repo_id, message="old commit", committed_at=old)
405 await _commit(db_session, repo_id, message="new commit", committed_at=new)
406 await db_session.commit()
407
408 cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
409 result = await search_by_keyword(
410 db_session, repo_id=repo_id, keyword="commit", since=cutoff
411 )
412 assert len(result.matches) == 1
413 assert "new" in result.matches[0].message
414
415 @pytest.mark.anyio
416 async def test_until_filter(self, db_session: AsyncSession) -> None:
417 repo_id = await _repo(db_session)
418 old = datetime(2020, 1, 1, tzinfo=timezone.utc)
419 new = datetime(2025, 1, 1, tzinfo=timezone.utc)
420 await _commit(db_session, repo_id, message="old commit", committed_at=old)
421 await _commit(db_session, repo_id, message="new commit", committed_at=new)
422 await db_session.commit()
423
424 cutoff = datetime(2022, 1, 1, tzinfo=timezone.utc)
425 result = await search_by_keyword(
426 db_session, repo_id=repo_id, keyword="commit", until=cutoff
427 )
428 assert len(result.matches) == 1
429 assert "old" in result.matches[0].message
430
431
432 # ── Layer 3 — E2E ────────────────────────────────────────────────────────────
433
434
435 class TestE2EApiSearch:
436 @pytest.mark.anyio
437 async def test_api_search_returns_global_search_result(
438 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
439 ) -> None:
440 repo_id = await _repo(db_session)
441 await _hub_commit(db_session, repo_id, message="melody jazz bassline")
442 await db_session.commit()
443
444 resp = await client.get("/api/search?q=jazz", headers=auth_headers)
445 assert resp.status_code == 200
446 body = resp.json()
447 assert "groups" in body
448
449 @pytest.mark.anyio
450 async def test_api_search_missing_q_returns_422(
451 self, client: AsyncClient, auth_headers: StrDict
452 ) -> None:
453 resp = await client.get("/api/search", headers=auth_headers)
454 assert resp.status_code == 422
455
456 @pytest.mark.anyio
457 async def test_musehub_search_keyword_mode(
458 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
459 ) -> None:
460 repo_id = await _repo(db_session, visibility="private")
461 await _commit(db_session, repo_id, message="jazz harmony voice")
462 await db_session.commit()
463
464 resp = await client.get(
465 f"/api/repos/{repo_id}/search?q=jazz&mode=keyword",
466 headers=auth_headers,
467 )
468 assert resp.status_code == 200
469 body = resp.json()
470 assert body["mode"] == "keyword"
471 assert len(body["matches"]) >= 1
472
473 @pytest.mark.anyio
474 async def test_musehub_search_invalid_mode_returns_422(
475 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
476 ) -> None:
477 repo_id = await _repo(db_session)
478 await db_session.commit()
479
480 resp = await client.get(
481 f"/api/repos/{repo_id}/search?q=foo&mode=badmode",
482 headers=auth_headers,
483 )
484 assert resp.status_code == 422
485
486 @pytest.mark.anyio
487 async def test_musehub_search_unknown_repo_returns_404(
488 self, client: AsyncClient, auth_headers: StrDict
489 ) -> None:
490 fake_id = str(uuid.uuid4())
491 resp = await client.get(
492 f"/api/repos/{fake_id}/search?q=jazz&mode=keyword",
493 headers=auth_headers,
494 )
495 assert resp.status_code == 404
496
497 @pytest.mark.anyio
498 async def test_musehub_search_private_repo_no_auth_returns_401(
499 self, client: AsyncClient, db_session: AsyncSession
500 ) -> None:
501 repo_id = await _repo(db_session, visibility="private")
502 await db_session.commit()
503
504 resp = await client.get(
505 f"/api/repos/{repo_id}/search?q=jazz&mode=keyword",
506 )
507 assert resp.status_code == 401
508
509
510 # ── Layer 4 — Stress ─────────────────────────────────────────────────────────
511
512
513 class TestStressSearch:
514 @pytest.mark.anyio
515 async def test_200_commits_keyword_respects_limit(
516 self, db_session: AsyncSession
517 ) -> None:
518 repo_id = await _repo(db_session)
519 for i in range(200):
520 await _commit(db_session, repo_id, message=f"jazz groove {i}")
521 await db_session.commit()
522
523 result = await search_by_keyword(
524 db_session, repo_id=repo_id, keyword="jazz", limit=10
525 )
526 assert len(result.matches) <= 10
527 assert result.total_scanned >= 200
528
529 @pytest.mark.anyio
530 async def test_5_concurrent_keyword_searches(
531 self, db_session: AsyncSession
532 ) -> None:
533 repo_id = await _repo(db_session)
534 for i in range(20):
535 await _commit(db_session, repo_id, message=f"harmony beat {i}")
536 await db_session.commit()
537
538 results = [
539 await search_by_keyword(db_session, repo_id=repo_id, keyword="harmony")
540 for _ in range(5)
541 ]
542 assert all(len(r.matches) > 0 for r in results)
543
544
545 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
546
547
548 class TestDataIntegritySearch:
549 @pytest.mark.anyio
550 async def test_keyword_match_source_is_message(
551 self, db_session: AsyncSession
552 ) -> None:
553 repo_id = await _repo(db_session)
554 await _commit(db_session, repo_id, message="jazz fusion experiment")
555 await db_session.commit()
556
557 result = await search_by_keyword(db_session, repo_id=repo_id, keyword="jazz")
558 assert all(m.match_source == "message" for m in result.matches)
559
560 @pytest.mark.anyio
561 async def test_keyword_score_in_zero_to_one(
562 self, db_session: AsyncSession
563 ) -> None:
564 repo_id = await _repo(db_session)
565 await _commit(db_session, repo_id, message="jazz harmony groove")
566 await db_session.commit()
567
568 result = await search_by_keyword(db_session, repo_id=repo_id, keyword="jazz rhythm harmony")
569 for m in result.matches:
570 assert 0.0 <= m.score <= 1.0
571
572 @pytest.mark.anyio
573 async def test_pattern_message_match_source(self, db_session: AsyncSession) -> None:
574 repo_id = await _repo(db_session)
575 await _commit(db_session, repo_id, message="jazz fusion")
576 await db_session.commit()
577
578 result = await search_by_pattern(db_session, repo_id=repo_id, pattern="jazz")
579 assert result.matches[0].match_source == "message"
580
581 @pytest.mark.anyio
582 async def test_pattern_branch_match_source(self, db_session: AsyncSession) -> None:
583 repo_id = await _repo(db_session)
584 await _commit(
585 db_session, repo_id, message="unrelated commit", branch="jazz-experiment"
586 )
587 await db_session.commit()
588
589 result = await search_by_pattern(db_session, repo_id=repo_id, pattern="jazz")
590 assert result.matches[0].match_source == "branch"
591
592 @pytest.mark.anyio
593 async def test_ask_higher_overlap_scores_higher(
594 self, db_session: AsyncSession
595 ) -> None:
596 repo_id = await _repo(db_session)
597 # high-match commit has both "jazz" and "harmony"
598 await _commit(db_session, repo_id, message="jazz harmony fusion")
599 # low-match commit has only "jazz"
600 await _commit(db_session, repo_id, message="jazz rock experiment")
601 await db_session.commit()
602
603 result = await search_by_ask(
604 db_session, repo_id=repo_id, question="jazz harmony"
605 )
606 assert len(result.matches) >= 2
607 # First result should have the higher-scoring commit
608 assert result.matches[0].score >= result.matches[1].score
609
610
611 # ── Layer 6 — Security ────────────────────────────────────────────────────────
612
613
614 class TestSecuritySearch:
615 @pytest.mark.anyio
616 async def test_sql_injection_in_pattern_handled_safely(
617 self, db_session: AsyncSession
618 ) -> None:
619 repo_id = await _repo(db_session)
620 await _commit(db_session, repo_id, message="innocent commit")
621 await db_session.commit()
622
623 # SQL injection attempt — should return 0 matches, not crash or return all rows.
624 result = await search_by_pattern(
625 db_session,
626 repo_id=repo_id,
627 pattern="'; DROP TABLE musecli_commits; --",
628 )
629 assert result.matches == []
630
631 @pytest.mark.anyio
632 async def test_xss_in_query_echoed_in_json_not_rendered(
633 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
634 ) -> None:
635 repo_id = await _repo(db_session, visibility="private")
636 await db_session.commit()
637
638 xss = "<script>alert(1)</script>"
639 resp = await client.get(
640 f"/api/repos/{repo_id}/search?q={xss}&mode=keyword",
641 headers=auth_headers,
642 )
643 assert resp.status_code == 200
644 body = resp.json()
645 # The query is echoed back but must be in JSON (string), not HTML.
646 assert body["query"] == xss
647 assert resp.headers["content-type"].startswith("application/json")
648
649 @pytest.mark.anyio
650 async def test_very_long_query_does_not_crash(
651 self, db_session: AsyncSession
652 ) -> None:
653 repo_id = await _repo(db_session)
654 await db_session.commit()
655
656 long_query = "jazz " * 2000 # 10k chars
657 result = await search_by_keyword(
658 db_session, repo_id=repo_id, keyword=long_query
659 )
660 assert result.matches == []
661
662 @pytest.mark.anyio
663 async def test_global_search_route_max_length_enforced(
664 self, client: AsyncClient, auth_headers: StrDict
665 ) -> None:
666 # GET /api/search/repos has max_length=500 on q — over that → 422.
667 long_q = "x" * 501
668 resp = await client.get(f"/api/search/repos?q={long_q}", headers=auth_headers)
669 assert resp.status_code == 422
670
671 @pytest.mark.anyio
672 async def test_null_byte_in_pattern_handled(
673 self, db_session: AsyncSession
674 ) -> None:
675 repo_id = await _repo(db_session)
676 await db_session.commit()
677
678 # Null bytes must not cause a crash.
679 result = await search_by_pattern(
680 db_session, repo_id=repo_id, pattern="foo\x00bar"
681 )
682 assert isinstance(result.matches, list)
683
684
685 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
686
687
688 class TestPerformanceSearch:
689 def test_1000_tokenize_calls_under_100ms(self) -> None:
690 texts = [f"add harmony voice to track {i}" for i in range(1000)]
691 start = time.perf_counter()
692 for t in texts:
693 _tokenize(t)
694 elapsed = time.perf_counter() - start
695 assert elapsed < 0.1, f"1000 _tokenize calls took {elapsed:.3f}s (expected <0.1s)"
696
697 @pytest.mark.anyio
698 async def test_keyword_search_500_commits_under_500ms(
699 self, db_session: AsyncSession
700 ) -> None:
701 repo_id = await _repo(db_session)
702 for i in range(500):
703 await _commit(db_session, repo_id, message=f"jazz groove rhythm {i}")
704 await db_session.commit()
705
706 start = time.perf_counter()
707 result = await search_by_keyword(
708 db_session, repo_id=repo_id, keyword="jazz"
709 )
710 elapsed = time.perf_counter() - start
711 assert elapsed < 0.5, f"search over 500 commits took {elapsed:.3f}s (expected <0.5s)"
712 assert len(result.matches) > 0
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago