KrishnaCosmic commited on
Commit
5eebd59
ยท
1 Parent(s): a1b1c31

checking changes

Browse files
README.md CHANGED
@@ -1,79 +1,95 @@
1
- ---
2
- title: OpenTriage AI Engine
3
- emoji: ๐Ÿง 
4
- colorFrom: purple
5
- colorTo: pink
6
- sdk: docker
7
- app_port: 7860
8
- pinned: false
9
- ---
10
 
11
- # ๐Ÿง  OpenTriage AI Engine (Microservice)
12
 
13
- This is the **AI Brain** of the OpenTriage platform. It is a specialized Python microservice acting as a "Sidecar" to the main TypeScript backend.
14
 
15
- It hosts the heavy-lifting AI logic, including RAG (Retrieval-Augmented Generation), Issue Triage, and Mentor Matching, keeping the main application fast and lightweight.
 
 
 
 
16
 
17
- ## ๐Ÿš€ Features
18
 
19
- * **๐Ÿ” AI Triage:** Automatically classifies GitHub issues by complexity and type.
20
- * **๐Ÿ“š RAG Chatbot:** "Chat with Repo" functionality using vector search.
21
- * **๐Ÿค Mentor Match:** Connects contributors to mentors based on tech stack analysis.
22
- * **๐ŸŽ‰ Hype Generator:** Generates celebratory messages for PR merges.
 
 
 
 
 
 
 
 
23
 
24
- ---
25
 
26
- ## ๐Ÿ”— How to Connect
27
-
28
- This service exposes a REST API via **FastAPI**. It is designed to be called by the Main Backend, not directly by users.
29
-
30
- ### Base URL
31
- Your API is live at:
32
- `https://[YOUR_USERNAME]-opentriage-ai-engine.hf.space`
33
-
34
- *(Check the "Embed this space" menu in the top right to get your exact direct URL)*
35
-
36
- ### API Endpoints
37
-
38
- | Method | Endpoint | Description |
39
- | :--- | :--- | :--- |
40
- | `GET` | `/health` | Server health check (returns 200 OK) |
41
- | `POST` | `/triage` | Classifies an issue description |
42
- | `POST` | `/chat` | General AI assistant response |
43
- | `POST` | `/rag/chat` | Context-aware repository Q&A |
44
- | `POST` | `/mentor-match` | Finds best matching mentors |
45
-
46
- ---
47
-
48
- ## ๐Ÿ›  Deployment Configuration
49
-
50
- ### 1. Environment Variables (Secrets)
51
- You must set these in the **Settings** tab of this Space under "Variables and secrets":
52
-
53
- | Secret Name | Required? | Description |
54
- | :--- | :--- | :--- |
55
- | `OPENROUTER_API_KEY` | **Yes** | Required for all AI generation |
56
- | `MONGO_URL` | Optional | If your RAG needs persistent vector storage |
57
-
58
- ### 2. Docker Configuration
59
- This Space uses a custom Dockerfile to ensure all Python scientific libraries (NumPy, Scikit-learn, LangChain) are installed in a compatible environment.
60
-
61
- * **Port:** 7860 (Standard HF Space port)
62
- * **User:** Runs as non-root user `user` (ID 1000) for security.
63
-
64
- ---
65
-
66
- ## ๐Ÿ’ป Local Development
67
-
68
- If you want to run this brain locally:
69
 
70
  ```bash
71
- # 1. Create venv
72
  python -m venv venv
73
  source venv/bin/activate
74
 
75
- # 2. Install
76
  pip install -r requirements.txt
77
 
78
- # 3. Run
79
- uvicorn main:app --reload --port 8000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenTriage AI Engine - Full Backend
 
 
 
 
 
 
 
 
2
 
3
+ Full-featured AI backend for OpenTriage, lifted directly from the original Python codebase.
4
 
5
+ ## Features
6
 
7
+ - **Issue Triage** - AI-powered classification and labeling
8
+ - **RAG Chatbot** - Repository-aware Q&A using retrieval-augmented generation
9
+ - **Mentor Matching** - Tech stack-based mentor recommendations
10
+ - **Hype Generator** - Celebration messages for PRs
11
+ - **AI Chat** - General assistance for contributors
12
 
13
+ ## API Endpoints
14
 
15
+ | Endpoint | Method | Description |
16
+ |----------|--------|-------------|
17
+ | `/health` | GET | Health check |
18
+ | `/triage` | POST | Issue classification |
19
+ | `/chat` | POST | AI chat assistant |
20
+ | `/rag/chat` | POST | RAG-based Q&A |
21
+ | `/rag/index` | POST | Index repository for RAG |
22
+ | `/rag/suggestions` | GET | Suggested questions |
23
+ | `/mentor-match` | POST | Find mentor matches |
24
+ | `/hype` | POST | Generate PR celebration |
25
+ | `/rag/prepare` | POST | Prepare RAG documents |
26
+ | `/rag/chunks` | GET | Get chunks for embedding |
27
 
28
+ ## Quick Start
29
 
30
+ ### Local Development
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  ```bash
33
+ # Create virtual environment
34
  python -m venv venv
35
  source venv/bin/activate
36
 
37
+ # Install dependencies
38
  pip install -r requirements.txt
39
 
40
+ # Set environment variables
41
+ export OPENROUTER_API_KEY=your_key_here
42
+
43
+ # Run the server
44
+ python main.py
45
+ ```
46
+
47
+ ### Docker
48
+
49
+ ```bash
50
+ docker build -t opentriage-ai-engine .
51
+ docker run -p 7860:7860 -e OPENROUTER_API_KEY=your_key opentriage-ai-engine
52
+ ```
53
+
54
+ ## Hugging Face Spaces Deployment
55
+
56
+ 1. Create a new Space (Docker SDK)
57
+ 2. Add secrets in Space settings:
58
+ - `OPENROUTER_API_KEY` (required)
59
+ - `MONGO_URL` (optional, for DB features)
60
+ 3. Push this folder to the Space
61
+
62
+ The Dockerfile is pre-configured for HF Spaces (non-root user, port 7860).
63
+
64
+ ## Environment Variables
65
+
66
+ | Variable | Required | Description |
67
+ |----------|----------|-------------|
68
+ | `OPENROUTER_API_KEY` | Yes | OpenRouter API key |
69
+ | `MONGO_URL` | No | MongoDB connection string |
70
+ | `FRONTEND_URL` | No | Frontend URL for CORS |
71
+ | `CORS_ORIGINS` | No | Comma-separated CORS origins |
72
+ | `ENVIRONMENT` | No | `development` or `production` |
73
+
74
+ ## Architecture
75
+
76
+ ```
77
+ ai-engine/
78
+ โ”œโ”€โ”€ main.py # FastAPI wrapper (new)
79
+ โ”œโ”€โ”€ Dockerfile # HF-compliant Docker config
80
+ โ”œโ”€โ”€ requirements.txt # Python dependencies
81
+ โ”œโ”€โ”€ config/
82
+ โ”‚ โ””โ”€โ”€ settings.py # Environment configuration
83
+ โ”œโ”€โ”€ models/ # Pydantic models (original)
84
+ โ”œโ”€โ”€ services/ # AI services (original, unchanged)
85
+ โ”‚ โ”œโ”€โ”€ ai_service.py
86
+ โ”‚ โ”œโ”€โ”€ rag_chatbot_service.py
87
+ โ”‚ โ”œโ”€โ”€ mentor_matching_service.py
88
+ โ”‚ โ”œโ”€โ”€ hype_generator_service.py
89
+ โ”‚ โ””โ”€โ”€ rag_data_prep.py
90
+ โ””โ”€โ”€ utils/ # Utilities (original)
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT - See parent repository LICENSE file.
config/redis.py CHANGED
@@ -1,225 +0,0 @@
1
- """
2
- Redis Connection Utility
3
-
4
- Provides Redis client with connection pooling and helper methods for caching.
5
- Falls back gracefully when Redis is unavailable.
6
- """
7
-
8
- import os
9
- import logging
10
- import hashlib
11
- import json
12
- from typing import Optional, Any
13
- from redis import Redis, ConnectionPool, RedisError
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- # Redis configuration from environment
18
- REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
19
- DEFAULT_TTL = 86400 # 24 hours in seconds
20
-
21
- # Global connection pool
22
- _redis_pool: Optional[ConnectionPool] = None
23
- _redis_client: Optional[Redis] = None
24
-
25
-
26
- def get_redis_client() -> Optional[Redis]:
27
- """
28
- Get Redis client with connection pooling.
29
- Returns None if Redis is unavailable.
30
- """
31
- global _redis_pool, _redis_client
32
-
33
- if _redis_client is not None:
34
- return _redis_client
35
-
36
- try:
37
- if _redis_pool is None:
38
- _redis_pool = ConnectionPool.from_url(
39
- REDIS_URL,
40
- max_connections=10,
41
- decode_responses=True,
42
- socket_timeout=5,
43
- socket_connect_timeout=5
44
- )
45
-
46
- _redis_client = Redis(connection_pool=_redis_pool)
47
-
48
- # Test connection
49
- _redis_client.ping()
50
- logger.info(f"Redis connected successfully at {REDIS_URL}")
51
- return _redis_client
52
-
53
- except RedisError as e:
54
- logger.warning(f"Redis connection failed: {e}. Caching will be disabled.")
55
- return None
56
- except Exception as e:
57
- logger.error(f"Unexpected error connecting to Redis: {e}")
58
- return None
59
-
60
-
61
- def generate_cache_key(prefix: str, data: dict) -> str:
62
- """
63
- Generate a consistent cache key from a data dictionary.
64
-
65
- Args:
66
- prefix: Key prefix (e.g., "triage", "prediction")
67
- data: Dictionary containing the data to hash
68
-
69
- Returns:
70
- Cache key string
71
- """
72
- # Create a stable JSON representation
73
- json_str = json.dumps(data, sort_keys=True, separators=(',', ':'))
74
-
75
- # Generate SHA256 hash
76
- hash_obj = hashlib.sha256(json_str.encode('utf-8'))
77
- hash_hex = hash_obj.hexdigest()[:16] # Use first 16 chars
78
-
79
- return f"{prefix}:{hash_hex}"
80
-
81
-
82
- def cache_get(key: str) -> Optional[Any]:
83
- """
84
- Get value from Redis cache.
85
-
86
- Args:
87
- key: Cache key
88
-
89
- Returns:
90
- Cached value (parsed from JSON) or None if not found or error
91
- """
92
- client = get_redis_client()
93
- if client is None:
94
- return None
95
-
96
- try:
97
- value = client.get(key)
98
- if value is None:
99
- return None
100
-
101
- # Parse JSON
102
- return json.loads(value)
103
-
104
- except RedisError as e:
105
- logger.warning(f"Redis get error for key '{key}': {e}")
106
- return None
107
- except json.JSONDecodeError as e:
108
- logger.error(f"Failed to decode cached value for key '{key}': {e}")
109
- return None
110
- except Exception as e:
111
- logger.error(f"Unexpected error getting cache key '{key}': {e}")
112
- return None
113
-
114
-
115
- def cache_set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> bool:
116
- """
117
- Set value in Redis cache with TTL.
118
-
119
- Args:
120
- key: Cache key
121
- value: Value to cache (will be JSON serialized)
122
- ttl: Time to live in seconds (default: 24 hours)
123
-
124
- Returns:
125
- True if successful, False otherwise
126
- """
127
- client = get_redis_client()
128
- if client is None:
129
- return False
130
-
131
- try:
132
- # Serialize to JSON
133
- json_value = json.dumps(value)
134
-
135
- # Set with expiration
136
- client.setex(key, ttl, json_value)
137
- logger.debug(f"Cached key '{key}' with TTL {ttl}s")
138
- return True
139
-
140
- except RedisError as e:
141
- logger.warning(f"Redis set error for key '{key}': {e}")
142
- return False
143
- except (TypeError, ValueError) as e:
144
- logger.error(f"Failed to serialize value for key '{key}': {e}")
145
- return False
146
- except Exception as e:
147
- logger.error(f"Unexpected error setting cache key '{key}': {e}")
148
- return False
149
-
150
-
151
- def cache_delete(key: str) -> bool:
152
- """
153
- Delete value from Redis cache.
154
-
155
- Args:
156
- key: Cache key
157
-
158
- Returns:
159
- True if successful, False otherwise
160
- """
161
- client = get_redis_client()
162
- if client is None:
163
- return False
164
-
165
- try:
166
- client.delete(key)
167
- logger.debug(f"Deleted cache key '{key}'")
168
- return True
169
-
170
- except RedisError as e:
171
- logger.warning(f"Redis delete error for key '{key}': {e}")
172
- return False
173
- except Exception as e:
174
- logger.error(f"Unexpected error deleting cache key '{key}': {e}")
175
- return False
176
-
177
-
178
- def cache_exists(key: str) -> bool:
179
- """
180
- Check if key exists in Redis cache.
181
-
182
- Args:
183
- key: Cache key
184
-
185
- Returns:
186
- True if key exists, False otherwise
187
- """
188
- client = get_redis_client()
189
- if client is None:
190
- return False
191
-
192
- try:
193
- return client.exists(key) > 0
194
-
195
- except RedisError as e:
196
- logger.warning(f"Redis exists error for key '{key}': {e}")
197
- return False
198
- except Exception as e:
199
- logger.error(f"Unexpected error checking cache key '{key}': {e}")
200
- return False
201
-
202
-
203
- def get_cache_stats() -> dict:
204
- """
205
- Get Redis cache statistics.
206
-
207
- Returns:
208
- Dictionary with cache stats or empty dict if unavailable
209
- """
210
- client = get_redis_client()
211
- if client is None:
212
- return {"status": "unavailable"}
213
-
214
- try:
215
- info = client.info("stats")
216
- return {
217
- "status": "connected",
218
- "total_keys": client.dbsize(),
219
- "hits": info.get("keyspace_hits", 0),
220
- "misses": info.get("keyspace_misses", 0),
221
- "evicted_keys": info.get("evicted_keys", 0),
222
- }
223
- except RedisError as e:
224
- logger.warning(f"Failed to get cache stats: {e}")
225
- return {"status": "error", "error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/turso.py CHANGED
@@ -99,18 +99,10 @@ class TursoDatabase:
99
  receiver_id TEXT NOT NULL,
100
  content TEXT NOT NULL,
101
  read INTEGER DEFAULT 0,
102
- timestamp TEXT NOT NULL,
103
- edited_at TEXT
104
  )
105
  """)
106
 
107
- # Add edited_at column if it doesn't exist (migration for existing tables)
108
- try:
109
- conn.execute("ALTER TABLE messages ADD COLUMN edited_at TEXT")
110
- conn.commit()
111
- except Exception:
112
- pass # Column already exists
113
-
114
  # Mentorships table
115
  conn.execute("""
116
  CREATE TABLE IF NOT EXISTS mentorships (
@@ -158,14 +150,10 @@ class TursoDatabase:
158
  if timestamp and not isinstance(timestamp, str):
159
  timestamp = timestamp.isoformat()
160
 
161
- edited_at = message.get('edited_at')
162
- if edited_at and not isinstance(edited_at, str):
163
- edited_at = edited_at.isoformat()
164
-
165
  conn.execute(
166
  """
167
- INSERT OR IGNORE INTO messages (id, sender_id, receiver_id, content, read, timestamp, edited_at)
168
- VALUES (?, ?, ?, ?, ?, ?, ?)
169
  """,
170
  (
171
  message.get('id'),
@@ -173,8 +161,7 @@ class TursoDatabase:
173
  message.get('receiver_id'),
174
  message.get('content'),
175
  1 if message.get('read') else 0,
176
- timestamp,
177
- edited_at
178
  )
179
  )
180
  conn.commit()
 
99
  receiver_id TEXT NOT NULL,
100
  content TEXT NOT NULL,
101
  read INTEGER DEFAULT 0,
102
+ timestamp TEXT NOT NULL
 
103
  )
104
  """)
105
 
 
 
 
 
 
 
 
106
  # Mentorships table
107
  conn.execute("""
108
  CREATE TABLE IF NOT EXISTS mentorships (
 
150
  if timestamp and not isinstance(timestamp, str):
151
  timestamp = timestamp.isoformat()
152
 
 
 
 
 
153
  conn.execute(
154
  """
155
+ INSERT OR IGNORE INTO messages (id, sender_id, receiver_id, content, read, timestamp)
156
+ VALUES (?, ?, ?, ?, ?, ?)
157
  """,
158
  (
159
  message.get('id'),
 
161
  message.get('receiver_id'),
162
  message.get('content'),
163
  1 if message.get('read') else 0,
164
+ timestamp
 
165
  )
166
  )
167
  conn.commit()
models/mentor_leaderboard.py CHANGED
@@ -2,7 +2,7 @@
2
  Mentor Leaderboard Models - AI-powered ranking with sentiment analysis.
3
  """
4
  from pydantic import BaseModel, Field
5
- from typing import Optional, List, Dict
6
  from datetime import datetime, timezone
7
  from enum import Enum
8
  import uuid
@@ -58,8 +58,8 @@ class LeaderboardEdit(BaseModel):
58
 
59
  # What was changed
60
  field: str # Which field was edited
61
- old_value: any
62
- new_value: any
63
  reason: Optional[str] = None
64
 
65
  timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
 
2
  Mentor Leaderboard Models - AI-powered ranking with sentiment analysis.
3
  """
4
  from pydantic import BaseModel, Field
5
+ from typing import Optional, List, Dict, Any
6
  from datetime import datetime, timezone
7
  from enum import Enum
8
  import uuid
 
58
 
59
  # What was changed
60
  field: str # Which field was edited
61
+ old_value: Any
62
+ new_value: Any
63
  reason: Optional[str] = None
64
 
65
  timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
pytest.ini ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_classes = Test*
5
+ python_functions = test_*
6
+ addopts = -v --tb=short
7
+ asyncio_mode = auto
tests/conftest.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration for pytest fixtures and shared test utilities.
3
+ """
4
+
5
+ import pytest
6
+ import os
7
+ import sys
8
+
9
+ # Add the project root to the path
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ # Set test environment variables
13
+ os.environ['TESTING'] = 'true'
14
+ os.environ['JWT_SECRET'] = 'test-jwt-secret-for-testing'
15
+ os.environ['ENVIRONMENT'] = 'test'
16
+
17
+
18
+ @pytest.fixture
19
+ def mock_user():
20
+ """Fixture providing a mock user object."""
21
+ return {
22
+ "id": "test-user-123",
23
+ "username": "testuser",
24
+ "role": "CONTRIBUTOR",
25
+ "avatarUrl": "https://github.com/test.png"
26
+ }
27
+
28
+
29
+ @pytest.fixture
30
+ def mock_maintainer():
31
+ """Fixture providing a mock maintainer user."""
32
+ return {
33
+ "id": "maintainer-123",
34
+ "username": "maintainer",
35
+ "role": "MAINTAINER",
36
+ "avatarUrl": "https://github.com/maintainer.png"
37
+ }
38
+
39
+
40
+ @pytest.fixture
41
+ def auth_headers(mock_user):
42
+ """Fixture providing authorization headers with a valid token."""
43
+ import jwt
44
+ import time
45
+
46
+ token = jwt.encode(
47
+ {
48
+ "user_id": mock_user["id"],
49
+ "role": mock_user["role"],
50
+ "exp": int(time.time()) + 3600
51
+ },
52
+ os.environ['JWT_SECRET'],
53
+ algorithm="HS256"
54
+ )
55
+ return {"Authorization": f"Bearer {token}"}
56
+
57
+
58
+ @pytest.fixture
59
+ def api_key_headers():
60
+ """Fixture providing API key headers for service-to-service calls."""
61
+ return {"X-API-Key": os.environ.get("AI_ENGINE_API_KEY", "")}
tests/test_auth.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for authentication middleware and helpers.
3
+ """
4
+
5
+ import pytest
6
+ import jwt
7
+ import time
8
+ import os
9
+
10
+
11
+ class TestMiddlewareAuth:
12
+ """Test cases for middleware authentication functions."""
13
+
14
+ def test_verify_api_key_valid(self):
15
+ """Test that valid API key is accepted."""
16
+ from middleware import verify_api_key
17
+
18
+ # When no API key is configured, all requests should pass
19
+ os.environ.pop("AI_ENGINE_API_KEY", None)
20
+ assert verify_api_key(None) is True
21
+
22
+ def test_verify_api_key_with_key_set(self):
23
+ """Test API key verification when key is set."""
24
+ import importlib
25
+ import middleware
26
+
27
+ # Set the API key and reload the module
28
+ os.environ["AI_ENGINE_API_KEY"] = "test-api-key"
29
+ importlib.reload(middleware)
30
+
31
+ # Valid key
32
+ assert middleware.verify_api_key("test-api-key") is True
33
+
34
+ # Invalid key
35
+ assert middleware.verify_api_key("wrong-key") is False
36
+
37
+ # No key provided
38
+ assert middleware.verify_api_key(None) is False
39
+
40
+ # Clean up and reload
41
+ os.environ.pop("AI_ENGINE_API_KEY", None)
42
+ importlib.reload(middleware)
43
+
44
+ def test_verify_jwt_token_valid(self):
45
+ """Test JWT token verification with valid token."""
46
+ from middleware import verify_jwt_token
47
+
48
+ token = jwt.encode(
49
+ {
50
+ "user_id": "user-123",
51
+ "role": "CONTRIBUTOR",
52
+ "exp": int(time.time()) + 3600
53
+ },
54
+ os.environ.get("JWT_SECRET", "test-secret"),
55
+ algorithm="HS256"
56
+ )
57
+
58
+ payload = verify_jwt_token(token)
59
+ assert payload["user_id"] == "user-123"
60
+ assert payload["role"] == "CONTRIBUTOR"
61
+
62
+ def test_verify_jwt_token_expired(self):
63
+ """Test JWT token verification with expired token."""
64
+ from middleware import verify_jwt_token
65
+ from fastapi import HTTPException
66
+
67
+ token = jwt.encode(
68
+ {
69
+ "user_id": "user-123",
70
+ "role": "CONTRIBUTOR",
71
+ "exp": int(time.time()) - 3600 # Expired 1 hour ago
72
+ },
73
+ os.environ.get("JWT_SECRET", "test-secret"),
74
+ algorithm="HS256"
75
+ )
76
+
77
+ with pytest.raises(HTTPException) as exc_info:
78
+ verify_jwt_token(token)
79
+ assert exc_info.value.status_code == 401
80
+ assert "expired" in str(exc_info.value.detail).lower()
81
+
82
+ def test_verify_jwt_token_invalid(self):
83
+ """Test JWT token verification with invalid token."""
84
+ from middleware import verify_jwt_token
85
+ from fastapi import HTTPException
86
+
87
+ with pytest.raises(HTTPException) as exc_info:
88
+ verify_jwt_token("invalid-token")
89
+ assert exc_info.value.status_code == 401
90
+
91
+
92
+ class TestOriginValidation:
93
+ """Test cases for origin validation."""
94
+
95
+ def test_validate_origin_allowed(self):
96
+ """Test that allowed origins pass validation."""
97
+ from middleware import ALLOWED_ORIGINS
98
+
99
+ allowed = [
100
+ "http://localhost:3000",
101
+ "http://localhost:5173",
102
+ "https://open-triage.vercel.app",
103
+ ]
104
+
105
+ for origin in allowed:
106
+ assert origin in ALLOWED_ORIGINS
107
+
108
+ def test_validate_origin_blocked(self):
109
+ """Test that non-allowed origins are blocked."""
110
+ from middleware import ALLOWED_ORIGINS
111
+
112
+ blocked = [
113
+ "http://malicious-site.com",
114
+ "https://fake-opentriage.com",
115
+ ]
116
+
117
+ for origin in blocked:
118
+ assert origin not in ALLOWED_ORIGINS
tests/test_endpoints.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for API endpoints.
3
+ """
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+ import os
8
+ import sys
9
+
10
+ # Add parent directory to path
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+
14
+ class TestHealthEndpoints:
15
+ """Test cases for health check endpoints."""
16
+
17
+ @pytest.fixture
18
+ def client(self):
19
+ """Create a test client."""
20
+ from main import app
21
+ return TestClient(app)
22
+
23
+ def test_health_check(self, client):
24
+ """Test the health check endpoint."""
25
+ response = client.get("/health")
26
+ assert response.status_code == 200
27
+ data = response.json()
28
+ assert data["status"] == "healthy"
29
+ assert "timestamp" in data
30
+
31
+ def test_root_endpoint(self, client):
32
+ """Test the root endpoint returns service info."""
33
+ response = client.get("/")
34
+ assert response.status_code == 200
35
+ data = response.json()
36
+ assert "service" in data
37
+ assert "version" in data
38
+ assert "endpoints" in data
39
+
40
+
41
+ class TestTriageEndpoint:
42
+ """Test cases for the triage endpoint."""
43
+
44
+ @pytest.fixture
45
+ def client(self):
46
+ """Create a test client."""
47
+ from main import app
48
+ return TestClient(app)
49
+
50
+ def test_triage_requires_auth(self, client):
51
+ """Test that triage endpoint requires authentication."""
52
+ response = client.post(
53
+ "/triage",
54
+ json={
55
+ "title": "Test Issue",
56
+ "body": "This is a test issue body",
57
+ }
58
+ )
59
+ # Should require auth
60
+ assert response.status_code in [401, 422] # 422 if validation runs first
61
+
62
+ def test_triage_with_auth(self, client, auth_headers):
63
+ """Test triage endpoint with valid authentication."""
64
+ response = client.post(
65
+ "/triage",
66
+ json={
67
+ "title": "Bug: Application crashes on startup",
68
+ "body": "The application crashes when I try to start it.",
69
+ "authorName": "testuser",
70
+ "isPR": False,
71
+ },
72
+ headers=auth_headers
73
+ )
74
+ # May fail due to AI service, but should not be 401
75
+ assert response.status_code != 401
76
+
77
+
78
+ class TestChatEndpoint:
79
+ """Test cases for the chat endpoint."""
80
+
81
+ @pytest.fixture
82
+ def client(self):
83
+ """Create a test client."""
84
+ from main import app
85
+ return TestClient(app)
86
+
87
+ def test_chat_requires_auth(self, client):
88
+ """Test that chat endpoint requires authentication."""
89
+ response = client.post(
90
+ "/chat",
91
+ json={
92
+ "message": "Hello, how can you help me?",
93
+ }
94
+ )
95
+ assert response.status_code in [401, 422]
96
+
97
+ def test_chat_with_auth(self, client, auth_headers):
98
+ """Test chat endpoint with valid authentication."""
99
+ response = client.post(
100
+ "/chat",
101
+ json={
102
+ "message": "Hello, how can you help me?",
103
+ },
104
+ headers=auth_headers
105
+ )
106
+ # May fail due to AI service, but should not be 401
107
+ assert response.status_code != 401
108
+
109
+
110
+ class TestRAGEndpoints:
111
+ """Test cases for RAG chatbot endpoints."""
112
+
113
+ @pytest.fixture
114
+ def client(self):
115
+ """Create a test client."""
116
+ from main import app
117
+ return TestClient(app)
118
+
119
+ def test_rag_chat_requires_auth(self, client):
120
+ """Test that RAG chat endpoint requires authentication."""
121
+ response = client.post(
122
+ "/rag/chat",
123
+ json={
124
+ "question": "How does the authentication work?",
125
+ }
126
+ )
127
+ assert response.status_code in [401, 422]
128
+
129
+ def test_rag_index_requires_auth(self, client):
130
+ """Test that RAG index endpoint requires authentication."""
131
+ response = client.post(
132
+ "/rag/index",
133
+ json={
134
+ "repo_name": "facebook/react",
135
+ }
136
+ )
137
+ assert response.status_code in [401, 422]
138
+
139
+ def test_rag_suggestions_public(self, client):
140
+ """Test that RAG suggestions endpoint is publicly accessible."""
141
+ response = client.get("/rag/suggestions")
142
+ # Should not require auth
143
+ assert response.status_code != 401
144
+
145
+
146
+ class TestMentorMatchEndpoint:
147
+ """Test cases for mentor matching endpoint."""
148
+
149
+ @pytest.fixture
150
+ def client(self):
151
+ """Create a test client."""
152
+ from main import app
153
+ return TestClient(app)
154
+
155
+ def test_mentor_match_requires_auth(self, client):
156
+ """Test that mentor match endpoint requires authentication."""
157
+ response = client.post(
158
+ "/mentor-match",
159
+ json={
160
+ "user_id": "user-123",
161
+ "username": "testuser",
162
+ }
163
+ )
164
+ assert response.status_code in [401, 422]
165
+
166
+
167
+ class TestHypeEndpoint:
168
+ """Test cases for hype generator endpoint."""
169
+
170
+ @pytest.fixture
171
+ def client(self):
172
+ """Create a test client."""
173
+ from main import app
174
+ return TestClient(app)
175
+
176
+ def test_hype_requires_auth(self, client):
177
+ """Test that hype endpoint requires authentication."""
178
+ response = client.post(
179
+ "/hype",
180
+ json={
181
+ "pr_title": "Add new feature",
182
+ "additions": 100,
183
+ "deletions": 10,
184
+ }
185
+ )
186
+ assert response.status_code in [401, 422]