loop-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. app/__init__.py +0 -0
  2. app/agents/__init__.py +0 -0
  3. app/agents/graph.py +40 -0
  4. app/agents/nodes.py +245 -0
  5. app/agents/state.py +19 -0
  6. app/api/__init__.py +0 -0
  7. app/api/candidates.py +49 -0
  8. app/api/dashboard.py +7 -0
  9. app/api/outreach.py +7 -0
  10. app/api/pipelines.py +58 -0
  11. app/api/positions.py +76 -0
  12. app/api/router.py +14 -0
  13. app/api/scheduler.py +7 -0
  14. app/api/skills.py +7 -0
  15. app/api/system.py +7 -0
  16. app/core/__init__.py +0 -0
  17. app/core/config.py +18 -0
  18. app/core/exception_handler.py +91 -0
  19. app/core/exceptions.py +33 -0
  20. app/core/logging.py +19 -0
  21. app/database/__init__.py +0 -0
  22. app/database/base.py +4 -0
  23. app/database/session.py +20 -0
  24. app/main.py +72 -0
  25. app/models/__init__.py +0 -0
  26. app/models/agent_run.py +18 -0
  27. app/models/candidate.py +28 -0
  28. app/models/node_log.py +18 -0
  29. app/models/outreach_log.py +16 -0
  30. app/models/pipeline.py +21 -0
  31. app/models/position.py +22 -0
  32. app/models/scheduler_job.py +16 -0
  33. app/models/skill.py +13 -0
  34. app/models/system_config.py +12 -0
  35. app/repositories/__init__.py +0 -0
  36. app/repositories/agent_run.py +74 -0
  37. app/repositories/candidate.py +84 -0
  38. app/repositories/node_log.py +57 -0
  39. app/repositories/outreach_log.py +60 -0
  40. app/repositories/pipeline.py +80 -0
  41. app/repositories/position.py +67 -0
  42. app/repositories/scheduler_job.py +74 -0
  43. app/schemas/__init__.py +0 -0
  44. app/schemas/agent_run.py +32 -0
  45. app/schemas/candidate.py +58 -0
  46. app/schemas/node_log.py +31 -0
  47. app/schemas/outreach_log.py +28 -0
  48. app/schemas/pipeline.py +34 -0
  49. app/schemas/position.py +49 -0
  50. app/schemas/scheduler_job.py +29 -0
  51. app/services/__init__.py +0 -0
  52. app/services/candidate.py +58 -0
  53. app/services/dashboard.py +230 -0
  54. app/services/email.py +116 -0
  55. app/services/health.py +105 -0
  56. app/services/pipeline.py +75 -0
  57. app/services/position.py +36 -0
  58. app/services/runner.py +292 -0
  59. app/services/scheduler.py +174 -0
  60. app/services/score.py +155 -0
  61. app/services/search.py +92 -0
  62. app/skills/base.py +30 -0
  63. app/skills/github.py +106 -0
  64. app/skills/registry.py +51 -0
  65. app/tests/__init__.py +3 -0
  66. app/tests/conftest.py +96 -0
  67. app/tests/generate_report.py +144 -0
  68. app/tests/test_candidates.py +158 -0
  69. app/tests/test_dashboard.py +27 -0
  70. app/tests/test_outreach.py +15 -0
  71. app/tests/test_pipelines.py +249 -0
  72. app/tests/test_positions.py +183 -0
  73. app/tests/test_scheduler.py +15 -0
  74. app/tests/test_skills.py +15 -0
  75. app/tests/test_system.py +35 -0
  76. app/utils/__init__.py +0 -0
  77. loop_agent_cli/__init__.py +5 -0
  78. loop_agent_cli/cli.py +728 -0
  79. loop_agent_cli/container.py +191 -0
  80. loop_agent_cli-0.1.0.dist-info/METADATA +202 -0
  81. loop_agent_cli-0.1.0.dist-info/RECORD +84 -0
  82. loop_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  83. loop_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  84. loop_agent_cli-0.1.0.dist-info/top_level.txt +2 -0
app/services/search.py ADDED
@@ -0,0 +1,92 @@
1
+ from typing import List, Dict, Any
2
+ from app.skills.registry import skill_registry
3
+ from app.services.candidate import CandidateService
4
+ from app.repositories.candidate import CandidateRepository
5
+ from app.models.candidate import Candidate
6
+ from app.core.exceptions import GitHubAPIException
7
+ import asyncio
8
+
9
+
10
+ class SearchService:
11
+ """
12
+ Service for handling candidate searches across different platforms
13
+ """
14
+
15
+ def __init__(self, candidate_service: CandidateService):
16
+ self.candidate_service = candidate_service
17
+ self.skill_registry = skill_registry
18
+
19
+ async def search_candidates(self, position_id: str, keywords: List[str], max_results: int = 100) -> List[Candidate]:
20
+ """
21
+ Search for candidates using various skills
22
+ """
23
+ all_candidates = []
24
+
25
+ # Use GitHub skill to search for candidates
26
+ try:
27
+ github_skill = self.skill_registry.get_skill("github_search")
28
+ github_results = await github_skill.execute(keywords, max_results)
29
+
30
+ # Convert results to candidate objects
31
+ for candidate_data in github_results:
32
+ # Create or update candidate in the database
33
+ candidate_create_data = {
34
+ "source": candidate_data["source"],
35
+ "source_id": candidate_data["source_id"],
36
+ "github_login": candidate_data["github_login"],
37
+ "name": candidate_data["name"],
38
+ "email": candidate_data["email"],
39
+ "location": candidate_data["location"],
40
+ "company": candidate_data["company"],
41
+ "title": candidate_data["title"],
42
+ "bio": candidate_data["bio"],
43
+ "followers": candidate_data["followers"],
44
+ "public_repos": candidate_data["public_repos"],
45
+ "profile_url": candidate_data["profile_url"],
46
+ "avatar_url": candidate_data["avatar_url"],
47
+ "skills": candidate_data["skills"],
48
+ "search_keywords": candidate_data["search_keywords"]
49
+ }
50
+
51
+ # Create candidate (this will handle deduplication)
52
+ candidate = await self.candidate_service.create_candidate(candidate_create_data)
53
+ all_candidates.append(candidate)
54
+
55
+ except GitHubAPIException as e:
56
+ # Log the error but continue with other skills if available
57
+ print(f"GitHub search failed: {str(e)}")
58
+ except Exception as e:
59
+ print(f"Unexpected error during search: {str(e)}")
60
+
61
+ return all_candidates
62
+
63
+ async def generate_search_keywords(self, position_title: str, required_skills: List[str], search_keywords: List[str]) -> List[str]:
64
+ """
65
+ Generate comprehensive search keywords based on position requirements
66
+ """
67
+ keywords = []
68
+
69
+ # Add position title keywords
70
+ if position_title:
71
+ keywords.extend([position_title.lower(), position_title.replace(" ", "+")])
72
+
73
+ # Add required skills
74
+ if required_skills:
75
+ keywords.extend(required_skills)
76
+ # Combine skills for more specific searches
77
+ for i in range(len(required_skills)):
78
+ for j in range(i+1, len(required_skills)):
79
+ combined = f"{required_skills[i]}+{required_skills[j]}"
80
+ keywords.append(combined)
81
+
82
+ # Add custom search keywords
83
+ if search_keywords:
84
+ keywords.extend(search_keywords)
85
+
86
+ # Remove duplicates while preserving order
87
+ unique_keywords = []
88
+ for kw in keywords:
89
+ if kw not in unique_keywords:
90
+ unique_keywords.append(kw)
91
+
92
+ return unique_keywords
app/skills/base.py ADDED
@@ -0,0 +1,30 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Dict, Any
3
+ from app.models.candidate import Candidate
4
+
5
+
6
+ class BaseSkill(ABC):
7
+ """
8
+ Base class for all skills in the Recruiting Loop Agent
9
+ """
10
+
11
+ @abstractmethod
12
+ async def execute(self, *args, **kwargs) -> List[Dict[str, Any]]:
13
+ """
14
+ Execute the skill and return a list of candidate data
15
+ """
16
+ pass
17
+
18
+ @abstractmethod
19
+ def name(self) -> str:
20
+ """
21
+ Return the name of the skill
22
+ """
23
+ pass
24
+
25
+ @abstractmethod
26
+ def description(self) -> str:
27
+ """
28
+ Return the description of the skill
29
+ """
30
+ pass
app/skills/github.py ADDED
@@ -0,0 +1,106 @@
1
+ import httpx
2
+ from typing import List, Dict, Any
3
+ from app.skills.base import BaseSkill
4
+ from app.core.config import settings
5
+ from app.core.exceptions import GitHubAPIException
6
+ import asyncio
7
+
8
+ class GitHubSkill(BaseSkill):
9
+ """
10
+ GitHub search skill for the Recruiting Loop Agent
11
+ """
12
+
13
+ def __init__(self):
14
+ self.github_token = settings.github_token
15
+ self.headers = {
16
+ "Accept": "application/vnd.github.v3+json",
17
+ "User-Agent": "Recruiting-Loop-Agent"
18
+ }
19
+ if self.github_token:
20
+ self.headers["Authorization"] = f"token {self.github_token}"
21
+
22
+ async def execute(self, keywords: List[str], max_results: int = 100) -> List[Dict[str, Any]]:
23
+ """
24
+ Execute GitHub search and return a list of candidate data
25
+ """
26
+ all_candidates = []
27
+
28
+ for keyword in keywords:
29
+ try:
30
+ # Search users based on keyword
31
+ search_url = f"https://api.github.com/search/users?q={keyword}&per_page=30"
32
+
33
+ async with httpx.AsyncClient() as client:
34
+ response = await client.get(search_url, headers=self.headers)
35
+
36
+ if response.status_code == 200:
37
+ data = response.json()
38
+ users = data.get("items", [])
39
+
40
+ # Get detailed user information for each user
41
+ for user in users[:min(len(users), max_results//len(keywords))]:
42
+ user_details = await self._get_user_details(user["login"])
43
+ if user_details:
44
+ all_candidates.append(user_details)
45
+
46
+ # Respect rate limits
47
+ await asyncio.sleep(1)
48
+ elif response.status_code == 403:
49
+ # Handle rate limiting
50
+ reset_time = int(response.headers.get("X-RateLimit-Reset", 0))
51
+ current_time = int(asyncio.get_event_loop().time())
52
+ sleep_time = max(reset_time - current_time, 60)
53
+ await asyncio.sleep(sleep_time)
54
+ continue
55
+ else:
56
+ raise GitHubAPIException(f"GitHub API returned status code {response.status_code}")
57
+
58
+ except Exception as e:
59
+ raise GitHubAPIException(f"Error searching GitHub for keyword '{keyword}': {str(e)}")
60
+
61
+ return all_candidates
62
+
63
+ async def _get_user_details(self, username: str) -> Dict[str, Any]:
64
+ """
65
+ Get detailed information about a GitHub user
66
+ """
67
+ try:
68
+ user_url = f"https://api.github.com/users/{username}"
69
+
70
+ async with httpx.AsyncClient() as client:
71
+ response = await client.get(user_url, headers=self.headers)
72
+
73
+ if response.status_code == 200:
74
+ user_data = response.json()
75
+
76
+ # Format the user data to match our candidate model
77
+ candidate_data = {
78
+ "source": "github",
79
+ "source_id": str(user_data.get("id", "")),
80
+ "github_login": user_data.get("login", ""),
81
+ "name": user_data.get("name", ""),
82
+ "email": user_data.get("email", ""),
83
+ "location": user_data.get("location", ""),
84
+ "company": user_data.get("company", ""),
85
+ "bio": user_data.get("bio", ""),
86
+ "followers": user_data.get("followers", 0),
87
+ "public_repos": user_data.get("public_repos", 0),
88
+ "profile_url": user_data.get("html_url", ""),
89
+ "avatar_url": user_data.get("avatar_url", ""),
90
+ "title": "", # GitHub doesn't have a direct title field
91
+ "skills": [], # Skills would need to be inferred from repos
92
+ "search_keywords": [] # Will be populated later
93
+ }
94
+
95
+ return candidate_data
96
+ else:
97
+ return None
98
+
99
+ except Exception as e:
100
+ raise GitHubAPIException(f"Error fetching details for user '{username}': {str(e)}")
101
+
102
+ def name(self) -> str:
103
+ return "github_search"
104
+
105
+ def description(self) -> str:
106
+ return "Searches GitHub for potential candidates based on keywords"
app/skills/registry.py ADDED
@@ -0,0 +1,51 @@
1
+ from typing import Dict, Type, List
2
+ from app.skills.base import BaseSkill
3
+ from app.skills.github import GitHubSkill
4
+
5
+
6
+ class SkillRegistry:
7
+ """
8
+ Registry for all available skills in the Recruiting Loop Agent
9
+ """
10
+
11
+ def __init__(self):
12
+ self._skills: Dict[str, BaseSkill] = {}
13
+ self._skill_classes: Dict[str, Type[BaseSkill]] = {
14
+ "github_search": GitHubSkill
15
+ }
16
+
17
+ def register_skill(self, skill_name: str, skill_instance: BaseSkill):
18
+ """
19
+ Register a new skill instance
20
+ """
21
+ self._skills[skill_name] = skill_instance
22
+
23
+ def get_skill(self, skill_name: str) -> BaseSkill:
24
+ """
25
+ Get a skill instance by name
26
+ """
27
+ if skill_name not in self._skills:
28
+ if skill_name in self._skill_classes:
29
+ # Create and register the skill if it's not already registered
30
+ skill_instance = self._skill_classes[skill_name]()
31
+ self.register_skill(skill_name, skill_instance)
32
+ else:
33
+ raise ValueError(f"Skill '{skill_name}' not found in registry")
34
+
35
+ return self._skills[skill_name]
36
+
37
+ def get_all_skills(self) -> List[BaseSkill]:
38
+ """
39
+ Get all available skills
40
+ """
41
+ return list(self._skills.values())
42
+
43
+ def get_available_skill_names(self) -> List[str]:
44
+ """
45
+ Get names of all available skills
46
+ """
47
+ return list(self._skill_classes.keys())
48
+
49
+
50
+ # Global skill registry instance
51
+ skill_registry = SkillRegistry()
app/tests/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ Recruit Loop Agent API Tests
3
+ """
app/tests/conftest.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Test configuration and fixtures
3
+ """
4
+ import pytest
5
+ import pytest_asyncio
6
+ from httpx import AsyncClient, ASGITransport
7
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
8
+ from sqlalchemy.orm import sessionmaker
9
+ from app.database.base import Base
10
+ from app.database.session import get_db
11
+ from app.main import app
12
+
13
+ # Use in-memory SQLite for tests
14
+ TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
15
+
16
+ engine = create_async_engine(TEST_DATABASE_URL, echo=False)
17
+ AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
18
+
19
+
20
+ @pytest_asyncio.fixture
21
+ async def db_session():
22
+ """Create a fresh database session for each test"""
23
+ async with engine.begin() as conn:
24
+ await conn.run_sync(Base.metadata.create_all)
25
+
26
+ async with AsyncSessionLocal() as session:
27
+ yield session
28
+
29
+ async with engine.begin() as conn:
30
+ await conn.run_sync(Base.metadata.drop_all)
31
+
32
+
33
+ @pytest_asyncio.fixture
34
+ async def client(db_session):
35
+ """Create a test client with overridden database dependency"""
36
+ async def override_get_db():
37
+ yield db_session
38
+
39
+ app.dependency_overrides[get_db] = override_get_db
40
+
41
+ transport = ASGITransport(app=app)
42
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
43
+ yield ac
44
+
45
+ app.dependency_overrides.clear()
46
+
47
+
48
+ @pytest.fixture
49
+ def sample_position_data():
50
+ """Sample position data for testing"""
51
+ return {
52
+ "title": "Senior Software Engineer",
53
+ "company": "Tech Corp",
54
+ "description": "Looking for an experienced backend developer",
55
+ "location": "San Francisco, CA",
56
+ "required_skills": ["Python", "FastAPI", "PostgreSQL"],
57
+ "search_keywords": ["backend", "python", "developer"],
58
+ "loop_interval": 60
59
+ }
60
+
61
+
62
+ @pytest.fixture
63
+ def sample_candidate_data():
64
+ """Sample candidate data for testing"""
65
+ return {
66
+ "source": "github",
67
+ "source_id": "12345",
68
+ "github_login": "johndoe",
69
+ "name": "John Doe",
70
+ "email": "john@example.com",
71
+ "location": "San Francisco, CA",
72
+ "company": "Current Corp",
73
+ "title": "Software Engineer",
74
+ "bio": "Experienced Python developer",
75
+ "followers": 100,
76
+ "public_repos": 25,
77
+ "skills": ["Python", "Django", "PostgreSQL"],
78
+ "profile_url": "https://github.com/johndoe",
79
+ "avatar_url": "https://github.com/johndoe.png",
80
+ "search_keywords": ["python", "backend"]
81
+ }
82
+
83
+
84
+ @pytest.fixture
85
+ def sample_pipeline_data():
86
+ """Sample pipeline data for testing"""
87
+ import uuid
88
+ return {
89
+ "position_id": str(uuid.uuid4()),
90
+ "candidate_id": str(uuid.uuid4()),
91
+ "status": "discovered",
92
+ "score": 85.5,
93
+ "contact_count": 0,
94
+ "candidate_interest": "high",
95
+ "notes": "Strong candidate"
96
+ }
@@ -0,0 +1,144 @@
1
+ """
2
+ Test report generator
3
+ Generates a Markdown report from pytest results
4
+ """
5
+ import subprocess
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+
11
+ def run_tests_and_generate_report():
12
+ """Run pytest and generate a Markdown test report"""
13
+
14
+ # Run pytest with verbose output
15
+ project_root = Path(__file__).parent.parent.parent
16
+ result = subprocess.run(
17
+ ["pytest", "app/tests/", "-v", "--tb=short"],
18
+ capture_output=True,
19
+ text=True,
20
+ cwd=str(project_root)
21
+ )
22
+
23
+ # Parse test results
24
+ test_results = []
25
+
26
+ for line in result.stdout.split('\n'):
27
+ line = line.strip()
28
+ if '::test_' in line and ('PASSED' in line or 'FAILED' in line or 'SKIPPED' in line):
29
+ # Parse test line like: app/tests/test_positions.py::test_create_position PASSED [ 59%]
30
+ parts = line.split('::')
31
+ if len(parts) >= 2:
32
+ file_part = parts[0]
33
+ module = file_part.replace('app/tests/test_', '').replace('.py', '')
34
+
35
+ # The last part contains test name and status
36
+ last_part = parts[-1]
37
+ test_name = last_part.split(' ')[0]
38
+
39
+ if 'PASSED' in line:
40
+ status = 'PASSED'
41
+ elif 'FAILED' in line:
42
+ status = 'FAILED'
43
+ else:
44
+ status = 'SKIPPED'
45
+
46
+ test_results.append({
47
+ 'module': module,
48
+ 'test_name': test_name,
49
+ 'status': status,
50
+ 'line': line
51
+ })
52
+
53
+ # Generate Markdown report
54
+ report = []
55
+ report.append("# Recruit Loop Agent API 测试报告")
56
+ report.append("")
57
+ report.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
58
+ report.append("")
59
+
60
+ # Test environment
61
+ report.append("## 测试环境说明")
62
+ report.append("")
63
+ report.append("- **测试框架**: pytest + pytest-asyncio")
64
+ report.append("- **HTTP 客户端**: httpx.AsyncClient")
65
+ report.append("- **数据库**: SQLite (内存模式)")
66
+ report.append("- **测试目标**: FastAPI 应用所有 API 接口")
67
+ report.append("")
68
+
69
+ # Summary
70
+ total = len(test_results)
71
+ passed = sum(1 for t in test_results if t['status'] == 'PASSED')
72
+ failed = sum(1 for t in test_results if t['status'] == 'FAILED')
73
+ skipped = sum(1 for t in test_results if t['status'] == 'SKIPPED')
74
+
75
+ report.append("## 测试执行摘要")
76
+ report.append("")
77
+ report.append(f"- **总测试用例数**: {total}")
78
+ report.append(f"- **通过**: {passed}")
79
+ report.append(f"- **失败**: {failed}")
80
+ report.append(f"- **跳过**: {skipped}")
81
+ report.append(f"- **通过率**: {(passed/total*100) if total > 0 else 0:.2f}%")
82
+ report.append("")
83
+
84
+ # Module breakdown
85
+ report.append("## 各模块测试详情")
86
+ report.append("")
87
+
88
+ modules = {}
89
+ for test in test_results:
90
+ module = test['module']
91
+ if module not in modules:
92
+ modules[module] = []
93
+ modules[module].append(test)
94
+
95
+ for module, tests in sorted(modules.items()):
96
+ report.append(f"### {module.upper()} 模块")
97
+ report.append("")
98
+ report.append("| 测试用例 | 状态 |")
99
+ report.append("|---------|------|")
100
+
101
+ for test in tests:
102
+ status_icon = "✅" if test['status'] == 'PASSED' else "❌" if test['status'] == 'FAILED' else "⏭️"
103
+ report.append(f"| {test['test_name']} | {status_icon} {test['status']} |")
104
+
105
+ report.append("")
106
+
107
+ # Failed tests details
108
+ if failed > 0:
109
+ report.append("## 失败用例详情")
110
+ report.append("")
111
+
112
+ for test in test_results:
113
+ if test['status'] == 'FAILED':
114
+ report.append(f"### {test['module']}::{test['test_name']}")
115
+ report.append("")
116
+ report.append("```")
117
+ # Extract error from pytest output
118
+ report.append(test['line'])
119
+ report.append("```")
120
+ report.append("")
121
+
122
+ # Full output
123
+ report.append("## 完整测试输出")
124
+ report.append("")
125
+ report.append("```")
126
+ report.append(result.stdout)
127
+ report.append("```")
128
+ report.append("")
129
+
130
+ # Write report to file
131
+ project_root = Path(__file__).parent.parent.parent
132
+ report_path = project_root / "docs" / "api_test_report.md"
133
+ report_path.parent.mkdir(parents=True, exist_ok=True)
134
+
135
+ with open(report_path, 'w', encoding='utf-8') as f:
136
+ f.write('\n'.join(report))
137
+
138
+ print(f"测试报告已生成: {report_path}")
139
+
140
+ return result.returncode
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(run_tests_and_generate_report())
@@ -0,0 +1,158 @@
1
+ """
2
+ Candidates API tests
3
+ """
4
+ import pytest
5
+ import uuid
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_create_candidate(client, sample_candidate_data):
10
+ """Test creating a new candidate"""
11
+ response = await client.post("/api/v1/candidates", json=sample_candidate_data)
12
+
13
+ assert response.status_code == 200
14
+ data = response.json()
15
+
16
+ assert data["source"] == sample_candidate_data["source"]
17
+ assert data["source_id"] == sample_candidate_data["source_id"]
18
+ assert data["github_login"] == sample_candidate_data["github_login"]
19
+ assert data["name"] == sample_candidate_data["name"]
20
+ assert "id" in data
21
+ assert "created_at" in data
22
+ assert "updated_at" in data
23
+ assert data["appearance_count"] == 1
24
+ assert data["source_weight"] == 1.0
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_create_duplicate_candidate(client, sample_candidate_data):
29
+ """Test creating a duplicate candidate increments appearance count"""
30
+ # Create first time
31
+ response1 = await client.post("/api/v1/candidates", json=sample_candidate_data)
32
+ assert response1.status_code == 200
33
+
34
+ # Create duplicate
35
+ response2 = await client.post("/api/v1/candidates", json=sample_candidate_data)
36
+ assert response2.status_code == 200
37
+
38
+ data = response2.json()
39
+ assert data["appearance_count"] == 2
40
+ assert data["source_weight"] == 2.0
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_get_candidates(client, sample_candidate_data):
45
+ """Test getting all candidates"""
46
+ await client.post("/api/v1/candidates", json=sample_candidate_data)
47
+
48
+ response = await client.get("/api/v1/candidates")
49
+
50
+ assert response.status_code == 200
51
+ data = response.json()
52
+
53
+ assert isinstance(data, list)
54
+ assert len(data) >= 1
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_get_candidates_with_pagination(client, sample_candidate_data):
59
+ """Test getting candidates with pagination"""
60
+ # Create multiple candidates
61
+ for i in range(3):
62
+ data = sample_candidate_data.copy()
63
+ data["source_id"] = str(10000 + i)
64
+ data["github_login"] = f"user{i}"
65
+ await client.post("/api/v1/candidates", json=data)
66
+
67
+ response = await client.get("/api/v1/candidates?skip=0&limit=2")
68
+
69
+ assert response.status_code == 200
70
+ data = response.json()
71
+ assert len(data) == 2
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_get_candidates_with_keyword_filter(client, sample_candidate_data):
76
+ """Test getting candidates with keyword filter"""
77
+ await client.post("/api/v1/candidates", json=sample_candidate_data)
78
+
79
+ response = await client.get("/api/v1/candidates?keyword=John")
80
+
81
+ assert response.status_code == 200
82
+ data = response.json()
83
+ assert len(data) >= 1
84
+ assert any("John" in c.get("name", "") for c in data)
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_get_candidate_by_id(client, sample_candidate_data):
89
+ """Test getting a specific candidate by ID"""
90
+ create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
91
+ candidate_id = create_response.json()["id"]
92
+
93
+ response = await client.get(f"/api/v1/candidates/{candidate_id}")
94
+
95
+ assert response.status_code == 200
96
+ data = response.json()
97
+ assert data["id"] == candidate_id
98
+ assert data["name"] == sample_candidate_data["name"]
99
+
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_get_candidate_not_found(client):
103
+ """Test getting a non-existent candidate"""
104
+ fake_id = str(uuid.uuid4())
105
+ response = await client.get(f"/api/v1/candidates/{fake_id}")
106
+
107
+ assert response.status_code == 404
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_update_candidate(client, sample_candidate_data):
112
+ """Test updating a candidate"""
113
+ create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
114
+ candidate_id = create_response.json()["id"]
115
+
116
+ update_data = {"name": "Jane Doe", "title": "Senior Developer"}
117
+ response = await client.put(f"/api/v1/candidates/{candidate_id}", json=update_data)
118
+
119
+ assert response.status_code == 200
120
+ data = response.json()
121
+ assert data["name"] == "Jane Doe"
122
+ assert data["title"] == "Senior Developer"
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_update_candidate_not_found(client):
127
+ """Test updating a non-existent candidate"""
128
+ fake_id = str(uuid.uuid4())
129
+ update_data = {"name": "Updated Name"}
130
+ response = await client.put(f"/api/v1/candidates/{fake_id}", json=update_data)
131
+
132
+ assert response.status_code == 404
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_delete_candidate(client, sample_candidate_data):
137
+ """Test deleting a candidate"""
138
+ create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
139
+ candidate_id = create_response.json()["id"]
140
+
141
+ response = await client.delete(f"/api/v1/candidates/{candidate_id}")
142
+
143
+ assert response.status_code == 200
144
+ data = response.json()
145
+ assert data["message"] == "Candidate deleted successfully"
146
+
147
+ # Verify it's deleted
148
+ get_response = await client.get(f"/api/v1/candidates/{candidate_id}")
149
+ assert get_response.status_code == 404
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_delete_candidate_not_found(client):
154
+ """Test deleting a non-existent candidate"""
155
+ fake_id = str(uuid.uuid4())
156
+ response = await client.delete(f"/api/v1/candidates/{fake_id}")
157
+
158
+ assert response.status_code == 404