Spaces:
Running
Running
Commit Β·
7611990
1
Parent(s): 79ca9ba
test(performance): Add comprehensive test suite for performance optimization
Browse files- Add multiple test files for performance testing
- Create pytest configuration and test runner
- Implement test cases for API endpoints, database, and integration scenarios
- Add performance, regression, and security test suites
- Remove legacy performance documentation files
- Establish structured testing framework for application
Rationale: Improve test coverage and validate performance optimization strategies across different system components
- PERFORMANCE_OPTIMIZATION.md +0 -410
- SECURITY_IMPROVEMENTS.md +0 -274
- app/tests/README.md +296 -0
- app/tests/conftest.py +162 -0
- app/tests/pytest.ini +60 -0
- app/tests/run_tests.py +143 -0
- app/tests/test_api_endpoints.py +318 -0
- app/tests/test_database.py +444 -0
- app/tests/test_integration.py +495 -0
- app/tests/test_performance.py +493 -0
- app/tests/test_regression_suite.py +519 -0
- app/tests/test_security.py +473 -0
- app/tests/test_services.py +439 -0
PERFORMANCE_OPTIMIZATION.md
DELETED
|
@@ -1,410 +0,0 @@
|
|
| 1 |
-
# π Performance Optimization Implementation - ALL ISSUES RESOLVED
|
| 2 |
-
|
| 3 |
-
## π **PERFORMANCE ISSUES FULLY ADDRESSED**
|
| 4 |
-
|
| 5 |
-
All identified performance bottlenecks have been comprehensively resolved with enterprise-grade optimizations.
|
| 6 |
-
|
| 7 |
-
---
|
| 8 |
-
|
| 9 |
-
## β
**RESOLVED PERFORMANCE ISSUES**
|
| 10 |
-
|
| 11 |
-
### 1. **β
Inefficient Database Queries - COMPLETE**
|
| 12 |
-
|
| 13 |
-
- **Issue**: Complex aggregation pipelines without proper indexing strategy
|
| 14 |
-
- **Impact**: High - slow query execution times
|
| 15 |
-
- **Solution**: Comprehensive indexing strategy and query optimization
|
| 16 |
-
- **Status**: **FULLY IMPLEMENTED & TESTED**
|
| 17 |
-
|
| 18 |
-
**Implementation:**
|
| 19 |
-
|
| 20 |
-
- 15+ compound indexes for optimal query performance
|
| 21 |
-
- Automatic pipeline stage reordering ($match first)
|
| 22 |
-
- Index hints for complex queries
|
| 23 |
-
- Query complexity analysis and recommendations
|
| 24 |
-
|
| 25 |
-
### 2. **β
Memory-Intensive Operations - COMPLETE**
|
| 26 |
-
|
| 27 |
-
- **Issue**: Large result sets loaded into memory without streaming
|
| 28 |
-
- **Impact**: High - memory exhaustion risk
|
| 29 |
-
- **Solution**: Cursor-based pagination and streaming aggregation
|
| 30 |
-
- **Status**: **FULLY IMPLEMENTED & TESTED**
|
| 31 |
-
|
| 32 |
-
**Implementation:**
|
| 33 |
-
|
| 34 |
-
- Cursor-based pagination for large result sets
|
| 35 |
-
- Streaming aggregation with configurable batch sizes
|
| 36 |
-
- Memory usage monitoring and limits
|
| 37 |
-
- Automatic fallback for memory-intensive operations
|
| 38 |
-
|
| 39 |
-
### 3. **β
Synchronous Operations in Async Context - COMPLETE**
|
| 40 |
-
|
| 41 |
-
- **Issue**: Blocking operations in async functions
|
| 42 |
-
- **Impact**: Medium - reduced concurrency
|
| 43 |
-
- **Solution**: Proper async patterns with thread pool management
|
| 44 |
-
- **Status**: **FULLY IMPLEMENTED & TESTED**
|
| 45 |
-
|
| 46 |
-
**Implementation:**
|
| 47 |
-
|
| 48 |
-
- Async spaCy model loading with caching
|
| 49 |
-
- Thread pool executor with proper resource management
|
| 50 |
-
- Timeout handling for long-running operations
|
| 51 |
-
- Graceful shutdown and cleanup procedures
|
| 52 |
-
|
| 53 |
-
### 4. **β
Inefficient Caching Strategy - COMPLETE**
|
| 54 |
-
|
| 55 |
-
- **Issue**: Cache keys not optimized, potential cache stampede
|
| 56 |
-
- **Impact**: Medium - reduced cache effectiveness
|
| 57 |
-
- **Solution**: Multi-level caching with warming and optimization
|
| 58 |
-
- **Status**: **FULLY IMPLEMENTED & TESTED**
|
| 59 |
-
|
| 60 |
-
**Implementation:**
|
| 61 |
-
|
| 62 |
-
- L1 (memory) + L2 (Redis) caching architecture
|
| 63 |
-
- Automatic cache warming before expiry
|
| 64 |
-
- Optimized cache key generation with hashing
|
| 65 |
-
- Cache performance monitoring and statistics
|
| 66 |
-
|
| 67 |
-
### 5. **β
Resource Leaks - COMPLETE**
|
| 68 |
-
|
| 69 |
-
- **Issue**: Database connections and thread pools not properly managed
|
| 70 |
-
- **Impact**: Medium - resource exhaustion over time
|
| 71 |
-
- **Solution**: Comprehensive resource management and cleanup
|
| 72 |
-
- **Status**: **FULLY IMPLEMENTED & TESTED**
|
| 73 |
-
|
| 74 |
-
**Implementation:**
|
| 75 |
-
|
| 76 |
-
- Proper thread pool executor shutdown
|
| 77 |
-
- Database connection pooling and health checks
|
| 78 |
-
- Automatic resource cleanup on application shutdown
|
| 79 |
-
- Memory leak prevention and monitoring
|
| 80 |
-
|
| 81 |
-
---
|
| 82 |
-
|
| 83 |
-
## π‘οΈ **COMPREHENSIVE PERFORMANCE FEATURES**
|
| 84 |
-
|
| 85 |
-
### **Database Optimization:**
|
| 86 |
-
|
| 87 |
-
```python
|
| 88 |
-
β
15+ compound indexes for optimal performance
|
| 89 |
-
β
Automatic query pipeline optimization
|
| 90 |
-
β
Index usage statistics and monitoring
|
| 91 |
-
β
Collection-specific optimization recommendations
|
| 92 |
-
β
Query complexity analysis and hints
|
| 93 |
-
β
Memory-efficient aggregation operations
|
| 94 |
-
```
|
| 95 |
-
|
| 96 |
-
### **Caching Optimization:**
|
| 97 |
-
|
| 98 |
-
```python
|
| 99 |
-
β
Multi-level L1/L2 caching architecture
|
| 100 |
-
β
Automatic cache warming and preloading
|
| 101 |
-
β
Optimized cache key generation
|
| 102 |
-
β
Cache performance monitoring
|
| 103 |
-
β
Pattern-based cache invalidation
|
| 104 |
-
β
Memory-efficient local cache with LRU eviction
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
### **Memory Management:**
|
| 108 |
-
|
| 109 |
-
```python
|
| 110 |
-
β
Cursor-based pagination for large datasets
|
| 111 |
-
β
Streaming aggregation with batch processing
|
| 112 |
-
β
Memory usage monitoring and limits
|
| 113 |
-
β
Automatic garbage collection optimization
|
| 114 |
-
β
Resource leak prevention
|
| 115 |
-
β
Configurable memory thresholds
|
| 116 |
-
```
|
| 117 |
-
|
| 118 |
-
### **Async Optimization:**
|
| 119 |
-
|
| 120 |
-
```python
|
| 121 |
-
β
Proper async/await patterns throughout
|
| 122 |
-
β
Thread pool management with timeouts
|
| 123 |
-
β
Non-blocking model loading and caching
|
| 124 |
-
β
Concurrent task execution where possible
|
| 125 |
-
β
Graceful error handling and recovery
|
| 126 |
-
β
Resource cleanup and shutdown procedures
|
| 127 |
-
```
|
| 128 |
-
|
| 129 |
-
---
|
| 130 |
-
|
| 131 |
-
## π§ͺ **PERFORMANCE IMPROVEMENTS ACHIEVED**
|
| 132 |
-
|
| 133 |
-
### **Database Query Performance:**
|
| 134 |
-
|
| 135 |
-
```
|
| 136 |
-
Before: Average query time 2.5s, 60% slow queries
|
| 137 |
-
After: Average query time 0.3s, 5% slow queries
|
| 138 |
-
Improvement: 8x faster queries, 92% reduction in slow queries
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
### **Memory Usage:**
|
| 142 |
-
|
| 143 |
-
```
|
| 144 |
-
Before: 500MB+ memory usage, frequent OOM errors
|
| 145 |
-
After: 150MB average usage, no memory issues
|
| 146 |
-
Improvement: 70% memory reduction, 100% stability
|
| 147 |
-
```
|
| 148 |
-
|
| 149 |
-
### **Cache Performance:**
|
| 150 |
-
|
| 151 |
-
```
|
| 152 |
-
Before: 30% hit rate, no warming, frequent misses
|
| 153 |
-
After: 85% hit rate, automatic warming, optimized keys
|
| 154 |
-
Improvement: 183% hit rate increase, 60% faster responses
|
| 155 |
-
```
|
| 156 |
-
|
| 157 |
-
### **Concurrency:**
|
| 158 |
-
|
| 159 |
-
```
|
| 160 |
-
Before: Blocking operations, reduced throughput
|
| 161 |
-
After: Full async support, 5x concurrent requests
|
| 162 |
-
Improvement: 500% throughput increase
|
| 163 |
-
```
|
| 164 |
-
|
| 165 |
-
---
|
| 166 |
-
|
| 167 |
-
## π **PERFORMANCE METRICS**
|
| 168 |
-
|
| 169 |
-
| Performance Aspect | Before | After | Improvement |
|
| 170 |
-
| ------------------- | -------- | --------- | --------------- |
|
| 171 |
-
| Query Speed | 2.5s avg | 0.3s avg | 8x faster |
|
| 172 |
-
| Memory Usage | 500MB+ | 150MB avg | 70% reduction |
|
| 173 |
-
| Cache Hit Rate | 30% | 85% | 183% increase |
|
| 174 |
-
| Concurrent Requests | 10/s | 50/s | 500% increase |
|
| 175 |
-
| Error Rate | 15% | <1% | 94% reduction |
|
| 176 |
-
| Resource Leaks | Frequent | None | 100% eliminated |
|
| 177 |
-
|
| 178 |
-
**Overall Performance Score: 95/100** βββββ
|
| 179 |
-
|
| 180 |
-
---
|
| 181 |
-
|
| 182 |
-
## π§ **IMPLEMENTATION DETAILS**
|
| 183 |
-
|
| 184 |
-
### **1. Database Indexing Strategy:**
|
| 185 |
-
|
| 186 |
-
```python
|
| 187 |
-
# Compound indexes for optimal performance
|
| 188 |
-
{
|
| 189 |
-
"keys": [("location_id", 1), ("merchant_category", 1), ("go_live_from", -1)],
|
| 190 |
-
"name": "location_category_golive_idx"
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
# Geospatial index for location queries
|
| 194 |
-
{
|
| 195 |
-
"keys": [("address.location", "2dsphere")],
|
| 196 |
-
"name": "geo_location_idx"
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
# Rating and popularity indexes
|
| 200 |
-
{
|
| 201 |
-
"keys": [("average_rating.value", -1), ("stats.total_bookings", -1)],
|
| 202 |
-
"name": "popularity_rating_idx"
|
| 203 |
-
}
|
| 204 |
-
```
|
| 205 |
-
|
| 206 |
-
### **2. Query Optimization:**
|
| 207 |
-
|
| 208 |
-
```python
|
| 209 |
-
# Automatic pipeline optimization
|
| 210 |
-
def optimize_pipeline(pipeline):
|
| 211 |
-
# Move $match stages to beginning
|
| 212 |
-
# Combine multiple $match stages
|
| 213 |
-
# Add index hints for complex queries
|
| 214 |
-
# Optimize stage ordering
|
| 215 |
-
return optimized_pipeline
|
| 216 |
-
|
| 217 |
-
# Memory-efficient execution
|
| 218 |
-
async def execute_with_cursor(collection, pipeline, limit):
|
| 219 |
-
cursor = collection.aggregate(pipeline, batchSize=100)
|
| 220 |
-
results = []
|
| 221 |
-
async for doc in cursor:
|
| 222 |
-
results.append(doc)
|
| 223 |
-
if len(results) >= limit:
|
| 224 |
-
break
|
| 225 |
-
return results
|
| 226 |
-
```
|
| 227 |
-
|
| 228 |
-
### **3. Multi-Level Caching:**
|
| 229 |
-
|
| 230 |
-
```python
|
| 231 |
-
# L1 (Memory) + L2 (Redis) architecture
|
| 232 |
-
class OptimizedCacheManager:
|
| 233 |
-
def __init__(self):
|
| 234 |
-
self.local_cache = {} # L1 cache
|
| 235 |
-
self.redis_client = redis_client # L2 cache
|
| 236 |
-
|
| 237 |
-
async def get_or_set_cache(self, key, fetch_func):
|
| 238 |
-
# Check L1 cache first
|
| 239 |
-
if key in self.local_cache:
|
| 240 |
-
return self.local_cache[key]
|
| 241 |
-
|
| 242 |
-
# Check L2 cache
|
| 243 |
-
cached = await self.redis_client.get(key)
|
| 244 |
-
if cached:
|
| 245 |
-
data = json.loads(cached)
|
| 246 |
-
self.local_cache[key] = data # Store in L1
|
| 247 |
-
return data
|
| 248 |
-
|
| 249 |
-
# Fetch and cache
|
| 250 |
-
data = await fetch_func()
|
| 251 |
-
await self._store_in_both_caches(key, data)
|
| 252 |
-
return data
|
| 253 |
-
```
|
| 254 |
-
|
| 255 |
-
### **4. Async Resource Management:**
|
| 256 |
-
|
| 257 |
-
```python
|
| 258 |
-
# Proper async model loading
|
| 259 |
-
class AsyncNLPProcessor:
|
| 260 |
-
async def get_nlp_model(self):
|
| 261 |
-
if self._nlp_model is None:
|
| 262 |
-
async with self._model_lock:
|
| 263 |
-
if self._nlp_model is None:
|
| 264 |
-
loop = asyncio.get_event_loop()
|
| 265 |
-
self._nlp_model = await loop.run_in_executor(
|
| 266 |
-
self.executor, self._load_spacy_model
|
| 267 |
-
)
|
| 268 |
-
return self._nlp_model
|
| 269 |
-
|
| 270 |
-
async def cleanup(self):
|
| 271 |
-
self._shutdown = True
|
| 272 |
-
if self.executor:
|
| 273 |
-
self.executor.shutdown(wait=True)
|
| 274 |
-
self._nlp_model = None
|
| 275 |
-
```
|
| 276 |
-
|
| 277 |
-
---
|
| 278 |
-
|
| 279 |
-
## π **API ENDPOINTS FOR MONITORING**
|
| 280 |
-
|
| 281 |
-
### **Performance Monitoring:**
|
| 282 |
-
|
| 283 |
-
- `GET /api/v1/performance/database-indexes` - Index usage statistics
|
| 284 |
-
- `GET /api/v1/performance/cache-stats` - Cache performance metrics
|
| 285 |
-
- `GET /api/v1/performance/memory-usage` - Memory usage statistics
|
| 286 |
-
- `GET /api/v1/performance/comprehensive-report` - Full performance report
|
| 287 |
-
|
| 288 |
-
### **Optimization Controls:**
|
| 289 |
-
|
| 290 |
-
- `POST /api/v1/performance/create-indexes` - Create/recreate indexes
|
| 291 |
-
- `POST /api/v1/performance/invalidate-cache` - Cache invalidation
|
| 292 |
-
- `POST /api/v1/performance/optimize-collection` - Collection optimization
|
| 293 |
-
- `GET /api/v1/performance/slow-queries` - Slow query analysis
|
| 294 |
-
|
| 295 |
-
---
|
| 296 |
-
|
| 297 |
-
## π **PERFORMANCE CHECKLIST - ALL COMPLETE**
|
| 298 |
-
|
| 299 |
-
### **Database Performance:**
|
| 300 |
-
|
| 301 |
-
- [x] Compound indexes on all frequently queried fields
|
| 302 |
-
- [x] Geospatial indexes for location-based queries
|
| 303 |
-
- [x] Text indexes for search functionality
|
| 304 |
-
- [x] Query pipeline optimization
|
| 305 |
-
- [x] Index usage monitoring
|
| 306 |
-
- [x] Collection statistics and recommendations
|
| 307 |
-
|
| 308 |
-
### **Memory Management:**
|
| 309 |
-
|
| 310 |
-
- [x] Cursor-based pagination implementation
|
| 311 |
-
- [x] Streaming aggregation for large datasets
|
| 312 |
-
- [x] Memory usage monitoring and limits
|
| 313 |
-
- [x] Automatic garbage collection optimization
|
| 314 |
-
- [x] Resource leak prevention
|
| 315 |
-
- [x] Configurable memory thresholds
|
| 316 |
-
|
| 317 |
-
### **Caching Strategy:**
|
| 318 |
-
|
| 319 |
-
- [x] Multi-level L1/L2 caching architecture
|
| 320 |
-
- [x] Automatic cache warming before expiry
|
| 321 |
-
- [x] Optimized cache key generation
|
| 322 |
-
- [x] Cache performance monitoring
|
| 323 |
-
- [x] Pattern-based invalidation
|
| 324 |
-
- [x] LRU eviction for memory management
|
| 325 |
-
|
| 326 |
-
### **Async Operations:**
|
| 327 |
-
|
| 328 |
-
- [x] Proper async/await patterns
|
| 329 |
-
- [x] Thread pool management
|
| 330 |
-
- [x] Non-blocking model loading
|
| 331 |
-
- [x] Timeout handling
|
| 332 |
-
- [x] Resource cleanup procedures
|
| 333 |
-
- [x] Graceful shutdown implementation
|
| 334 |
-
|
| 335 |
-
---
|
| 336 |
-
|
| 337 |
-
## π― **PERFORMANCE MONITORING DASHBOARD**
|
| 338 |
-
|
| 339 |
-
### **Real-time Metrics:**
|
| 340 |
-
|
| 341 |
-
- Database query performance (avg: 0.3s)
|
| 342 |
-
- Cache hit rate (85%+)
|
| 343 |
-
- Memory usage (150MB avg)
|
| 344 |
-
- Concurrent request handling (50/s)
|
| 345 |
-
- Error rates (<1%)
|
| 346 |
-
|
| 347 |
-
### **Automated Alerts:**
|
| 348 |
-
|
| 349 |
-
- Slow query detection (>1s)
|
| 350 |
-
- High memory usage (>80%)
|
| 351 |
-
- Low cache hit rate (<70%)
|
| 352 |
-
- Database connection issues
|
| 353 |
-
- Resource leak detection
|
| 354 |
-
|
| 355 |
-
---
|
| 356 |
-
|
| 357 |
-
## π **ACHIEVEMENT SUMMARY**
|
| 358 |
-
|
| 359 |
-
β
**ALL PERFORMANCE ISSUES RESOLVED**
|
| 360 |
-
β
**8X QUERY PERFORMANCE IMPROVEMENT**
|
| 361 |
-
β
**70% MEMORY USAGE REDUCTION**
|
| 362 |
-
β
**500% THROUGHPUT INCREASE**
|
| 363 |
-
β
**COMPREHENSIVE MONITORING IMPLEMENTED**
|
| 364 |
-
β
**ZERO RESOURCE LEAKS**
|
| 365 |
-
|
| 366 |
-
**The application now delivers enterprise-grade performance with comprehensive monitoring and optimization capabilities.**
|
| 367 |
-
|
| 368 |
-
---
|
| 369 |
-
|
| 370 |
-
## π **QUICK START GUIDE**
|
| 371 |
-
|
| 372 |
-
### **1. Initialize Performance Optimizations:**
|
| 373 |
-
|
| 374 |
-
```bash
|
| 375 |
-
# Application automatically creates indexes on startup
|
| 376 |
-
# Monitor startup logs for optimization status
|
| 377 |
-
```
|
| 378 |
-
|
| 379 |
-
### **2. Monitor Performance:**
|
| 380 |
-
|
| 381 |
-
```bash
|
| 382 |
-
# Check comprehensive performance report
|
| 383 |
-
curl http://localhost:8000/api/v1/performance/comprehensive-report
|
| 384 |
-
|
| 385 |
-
# Monitor cache performance
|
| 386 |
-
curl http://localhost:8000/api/v1/performance/cache-stats
|
| 387 |
-
|
| 388 |
-
# Check database indexes
|
| 389 |
-
curl http://localhost:8000/api/v1/performance/database-indexes
|
| 390 |
-
```
|
| 391 |
-
|
| 392 |
-
### **3. Optimize as Needed:**
|
| 393 |
-
|
| 394 |
-
```bash
|
| 395 |
-
# Create/recreate indexes
|
| 396 |
-
curl -X POST http://localhost:8000/api/v1/performance/create-indexes
|
| 397 |
-
|
| 398 |
-
# Invalidate cache
|
| 399 |
-
curl -X POST "http://localhost:8000/api/v1/performance/invalidate-cache?pattern=merchants:*"
|
| 400 |
-
|
| 401 |
-
# Optimize specific collection
|
| 402 |
-
curl -X POST "http://localhost:8000/api/v1/performance/optimize-collection?collection_name=merchants"
|
| 403 |
-
```
|
| 404 |
-
|
| 405 |
-
---
|
| 406 |
-
|
| 407 |
-
_Performance optimization completed on: $(date)_
|
| 408 |
-
_All optimizations active: β
_
|
| 409 |
-
_Performance score: 95/100_
|
| 410 |
-
_Production ready: β
_
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SECURITY_IMPROVEMENTS.md
DELETED
|
@@ -1,274 +0,0 @@
|
|
| 1 |
-
# Security Improvements Implementation - FIXED
|
| 2 |
-
|
| 3 |
-
## Overview
|
| 4 |
-
This document outlines the comprehensive security improvements implemented to address input sanitization and sensitive data logging vulnerabilities. **All issues have been resolved and tested.**
|
| 5 |
-
|
| 6 |
-
## π¨ Critical Fixes Applied
|
| 7 |
-
- β
**Regex Error Fixed**: Resolved invalid group reference error in log sanitizer
|
| 8 |
-
- β
**Circular Dependency Fixed**: Created simple log sanitizer to avoid middleware issues
|
| 9 |
-
- β
**Input Validation Working**: All dangerous patterns now properly detected and blocked
|
| 10 |
-
- β
**Log Sanitization Working**: Sensitive data properly redacted in all logs
|
| 11 |
-
|
| 12 |
-
## π Input Sanitization Implementation
|
| 13 |
-
|
| 14 |
-
### 1. InputSanitizer Class (`app/utils/input_sanitizer.py`)
|
| 15 |
-
- **Comprehensive input validation** for all data types
|
| 16 |
-
- **Pattern-based detection** of dangerous content (SQL injection, XSS, etc.)
|
| 17 |
-
- **Field-specific sanitization** for location IDs, merchant IDs, coordinates
|
| 18 |
-
- **Length limits** and **character validation**
|
| 19 |
-
- **HTML escaping** and **tag stripping** using bleach library
|
| 20 |
-
|
| 21 |
-
#### Key Features:
|
| 22 |
-
- Validates location IDs with regex pattern `^[A-Z]{2}-[A-Z0-9]+$`
|
| 23 |
-
- Sanitizes coordinates with proper range validation (-90 to 90 for lat, -180 to 180 for lng)
|
| 24 |
-
- Limits pagination parameters (max 100 items, max 10000 offset)
|
| 25 |
-
- Detects dangerous patterns like `$where`, `javascript:`, `<script>`, etc.
|
| 26 |
-
|
| 27 |
-
### 2. Security Middleware (`app/middleware/security_middleware.py`)
|
| 28 |
-
- **Request size limiting** (10MB default)
|
| 29 |
-
- **Rate limiting** with sliding window algorithm
|
| 30 |
-
- **Security headers** injection
|
| 31 |
-
- **Client IP extraction** with proxy support
|
| 32 |
-
- **Request logging** with sanitization
|
| 33 |
-
|
| 34 |
-
#### Rate Limiting Rules:
|
| 35 |
-
- `/api/v1/merchants/ads`: 100 requests/minute
|
| 36 |
-
- `/api/v1/merchants/recommended-merchants`: 50 requests/minute
|
| 37 |
-
- `/api/v1/nlp/analyze-query`: 20 requests/minute
|
| 38 |
-
- Default: 60 requests/minute
|
| 39 |
-
|
| 40 |
-
### 3. Enhanced Pydantic Models (`app/models/merchant.py`)
|
| 41 |
-
- **Custom validators** for all input fields
|
| 42 |
-
- **Pattern matching** for business names and IDs
|
| 43 |
-
- **Dangerous content detection** in free text
|
| 44 |
-
- **Service validation** with length limits
|
| 45 |
-
|
| 46 |
-
## π Log Sanitization Implementation
|
| 47 |
-
|
| 48 |
-
### 1. LogSanitizer Class (`app/utils/log_sanitizer.py`)
|
| 49 |
-
- **Automatic redaction** of sensitive fields
|
| 50 |
-
- **Pattern-based sanitization** for credentials, tokens, emails
|
| 51 |
-
- **Recursive sanitization** for nested objects
|
| 52 |
-
- **Length limiting** to prevent log flooding
|
| 53 |
-
- **MongoDB-specific** query sanitization
|
| 54 |
-
|
| 55 |
-
#### Sensitive Data Patterns:
|
| 56 |
-
- Database connection strings (MongoDB, Redis)
|
| 57 |
-
- API keys and tokens
|
| 58 |
-
- Email addresses (partial redaction)
|
| 59 |
-
- Phone numbers
|
| 60 |
-
- Credit card numbers
|
| 61 |
-
- IP addresses (partial redaction)
|
| 62 |
-
|
| 63 |
-
### 2. SimpleSanitizedLogger Wrapper
|
| 64 |
-
- **Drop-in replacement** for standard Python logger
|
| 65 |
-
- **Automatic sanitization** of all log messages with fallback protection
|
| 66 |
-
- **Preserves log levels** and formatting
|
| 67 |
-
- **Error-resistant** with graceful degradation
|
| 68 |
-
- **No circular dependencies** - safe for middleware use
|
| 69 |
-
|
| 70 |
-
### 3. Utility Functions
|
| 71 |
-
- `log_query_safely()` - Safe database query logging
|
| 72 |
-
- `log_user_action_safely()` - Safe user action logging
|
| 73 |
-
- `log_api_request_safely()` - Safe API request logging
|
| 74 |
-
|
| 75 |
-
## π‘οΈ Security Configuration (`app/config/security_config.py`)
|
| 76 |
-
|
| 77 |
-
### Key Settings:
|
| 78 |
-
- **CORS origins**: Environment-controlled (no more `allow_origins=["*"]`)
|
| 79 |
-
- **Request size limits**: 10MB default
|
| 80 |
-
- **Rate limiting**: Configurable per endpoint
|
| 81 |
-
- **Security headers**: Comprehensive set including CSP, HSTS
|
| 82 |
-
- **Input validation patterns**: Centralized regex patterns
|
| 83 |
-
|
| 84 |
-
## π Implementation Details
|
| 85 |
-
|
| 86 |
-
### 1. Router Updates
|
| 87 |
-
All API endpoints now include:
|
| 88 |
-
```python
|
| 89 |
-
# Input sanitization
|
| 90 |
-
location_id = InputSanitizer.sanitize_location_id(location_id)
|
| 91 |
-
merchant_id = InputSanitizer.sanitize_merchant_id(merchant_id)
|
| 92 |
-
|
| 93 |
-
# Safe logging
|
| 94 |
-
log_api_request_safely(logger.logger, "/endpoint", sanitized_params)
|
| 95 |
-
```
|
| 96 |
-
|
| 97 |
-
### 2. Service Layer Updates
|
| 98 |
-
Database operations now use:
|
| 99 |
-
```python
|
| 100 |
-
# Safe query logging
|
| 101 |
-
log_query_safely(logger.logger, "collection", criteria, pipeline)
|
| 102 |
-
|
| 103 |
-
# Sanitized error messages
|
| 104 |
-
logger.error("Operation failed") # No sensitive data exposed
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
### 3. Database Repository Updates
|
| 108 |
-
All database operations include:
|
| 109 |
-
- Sanitized query logging
|
| 110 |
-
- Error message sanitization
|
| 111 |
-
- Performance monitoring with safe logging
|
| 112 |
-
|
| 113 |
-
## π Usage Examples
|
| 114 |
-
|
| 115 |
-
### Input Sanitization
|
| 116 |
-
```python
|
| 117 |
-
from app.utils.input_sanitizer import InputSanitizer
|
| 118 |
-
|
| 119 |
-
# Sanitize location ID
|
| 120 |
-
location_id = InputSanitizer.sanitize_location_id("in-south") # Returns "IN-SOUTH"
|
| 121 |
-
|
| 122 |
-
# Sanitize coordinates
|
| 123 |
-
lat, lng = InputSanitizer.sanitize_coordinates(13.0827, 80.2707)
|
| 124 |
-
|
| 125 |
-
# Sanitize pagination
|
| 126 |
-
limit, offset = InputSanitizer.sanitize_pagination(10, 0)
|
| 127 |
-
```
|
| 128 |
-
|
| 129 |
-
### Log Sanitization
|
| 130 |
-
```python
|
| 131 |
-
from app.utils.log_sanitizer import get_sanitized_logger, log_query_safely
|
| 132 |
-
|
| 133 |
-
logger = get_sanitized_logger(__name__)
|
| 134 |
-
|
| 135 |
-
# Safe logging
|
| 136 |
-
log_query_safely(logger.logger, "merchants", {"location_id": "IN-SOUTH"})
|
| 137 |
-
|
| 138 |
-
# Automatic sanitization
|
| 139 |
-
logger.info("User data: %s", {"email": "user@example.com", "password": "secret"})
|
| 140 |
-
# Logs: "User data: {'email': 'user***@example.com', 'password': '[REDACTED]'}"
|
| 141 |
-
```
|
| 142 |
-
|
| 143 |
-
## π§ Configuration
|
| 144 |
-
|
| 145 |
-
### Environment Variables
|
| 146 |
-
```bash
|
| 147 |
-
# CORS configuration
|
| 148 |
-
ALLOWED_ORIGINS=https://yourdomain.com,https://api.yourdomain.com
|
| 149 |
-
|
| 150 |
-
# Rate limiting
|
| 151 |
-
RATE_LIMIT_RPM=60
|
| 152 |
-
MAX_REQUEST_SIZE=10485760
|
| 153 |
-
|
| 154 |
-
# Security features
|
| 155 |
-
LOG_SANITIZATION_ENABLED=true
|
| 156 |
-
MAX_LOG_VALUE_LENGTH=500
|
| 157 |
-
```
|
| 158 |
-
|
| 159 |
-
### Security Headers Added
|
| 160 |
-
- `X-Content-Type-Options: nosniff`
|
| 161 |
-
- `X-Frame-Options: DENY`
|
| 162 |
-
- `X-XSS-Protection: 1; mode=block`
|
| 163 |
-
- `Strict-Transport-Security: max-age=31536000; includeSubDomains`
|
| 164 |
-
- `Content-Security-Policy: default-src 'self'`
|
| 165 |
-
|
| 166 |
-
## β
Security Benefits
|
| 167 |
-
|
| 168 |
-
### Input Sanitization Benefits:
|
| 169 |
-
1. **Prevents injection attacks** (SQL, NoSQL, XSS)
|
| 170 |
-
2. **Validates data integrity** before processing
|
| 171 |
-
3. **Standardizes input formats** (uppercase location IDs, etc.)
|
| 172 |
-
4. **Prevents buffer overflow** with length limits
|
| 173 |
-
5. **Blocks dangerous patterns** automatically
|
| 174 |
-
|
| 175 |
-
### Log Sanitization Benefits:
|
| 176 |
-
1. **Prevents credential exposure** in logs
|
| 177 |
-
2. **Complies with privacy regulations** (GDPR, CCPA)
|
| 178 |
-
3. **Reduces security audit risks**
|
| 179 |
-
4. **Maintains debugging capability** without sensitive data
|
| 180 |
-
5. **Prevents log-based attacks**
|
| 181 |
-
|
| 182 |
-
## π§ͺ Testing
|
| 183 |
-
|
| 184 |
-
### Input Validation Tests
|
| 185 |
-
```python
|
| 186 |
-
# Test dangerous input
|
| 187 |
-
try:
|
| 188 |
-
InputSanitizer.sanitize_string("'; DROP TABLE users; --")
|
| 189 |
-
except ValueError as e:
|
| 190 |
-
print("Blocked dangerous input")
|
| 191 |
-
|
| 192 |
-
# Test coordinate validation
|
| 193 |
-
lat, lng = InputSanitizer.sanitize_coordinates(91.0, 181.0) # Raises ValueError
|
| 194 |
-
```
|
| 195 |
-
|
| 196 |
-
### Log Sanitization Tests
|
| 197 |
-
```python
|
| 198 |
-
from app.utils.log_sanitizer import LogSanitizer
|
| 199 |
-
|
| 200 |
-
# Test credential redaction
|
| 201 |
-
data = {"password": "secret123", "api_key": "abc123def456"}
|
| 202 |
-
sanitized = LogSanitizer.sanitize_dict(data)
|
| 203 |
-
# Result: {"password": "[REDACTED]", "api_key": "[REDACTED]"}
|
| 204 |
-
```
|
| 205 |
-
|
| 206 |
-
## π Performance Impact
|
| 207 |
-
|
| 208 |
-
### Input Sanitization:
|
| 209 |
-
- **Minimal overhead**: ~1-2ms per request
|
| 210 |
-
- **Cached regex patterns**: Compiled once, reused
|
| 211 |
-
- **Early validation**: Fails fast on invalid input
|
| 212 |
-
|
| 213 |
-
### Log Sanitization:
|
| 214 |
-
- **Lazy evaluation**: Only processes when logging
|
| 215 |
-
- **Pattern caching**: Compiled regex patterns
|
| 216 |
-
- **Configurable depth**: Prevents infinite recursion
|
| 217 |
-
|
| 218 |
-
## π Migration Guide
|
| 219 |
-
|
| 220 |
-
### For Existing Endpoints:
|
| 221 |
-
1. Import sanitization utilities
|
| 222 |
-
2. Add input validation before processing
|
| 223 |
-
3. Replace direct logging with sanitized logging
|
| 224 |
-
4. Update error handling to avoid data exposure
|
| 225 |
-
|
| 226 |
-
### For New Endpoints:
|
| 227 |
-
1. Use `InputSanitizer` for all user inputs
|
| 228 |
-
2. Use `get_sanitized_logger()` instead of `logging.getLogger()`
|
| 229 |
-
3. Apply security middleware automatically
|
| 230 |
-
4. Follow validation patterns in existing code
|
| 231 |
-
|
| 232 |
-
## π― Next Steps
|
| 233 |
-
|
| 234 |
-
### Recommended Improvements:
|
| 235 |
-
1. **Add authentication middleware** for protected endpoints
|
| 236 |
-
2. **Implement API key validation** for external access
|
| 237 |
-
3. **Add request signing** for critical operations
|
| 238 |
-
4. **Set up security monitoring** and alerting
|
| 239 |
-
5. **Regular security audits** and penetration testing
|
| 240 |
-
|
| 241 |
-
### Monitoring:
|
| 242 |
-
1. **Track blocked requests** from input validation
|
| 243 |
-
2. **Monitor rate limiting** effectiveness
|
| 244 |
-
3. **Audit log sanitization** coverage
|
| 245 |
-
4. **Performance impact** measurement
|
| 246 |
-
|
| 247 |
-
This implementation provides comprehensive protection against the identified security vulnerabilities while maintaining application performance and functionality.
|
| 248 |
-
## π§
|
| 249 |
-
Final Implementation Status
|
| 250 |
-
|
| 251 |
-
### β
Successfully Implemented:
|
| 252 |
-
1. **Input Sanitization** - All endpoints now validate and sanitize inputs
|
| 253 |
-
2. **Log Sanitization** - All sensitive data redacted from logs
|
| 254 |
-
3. **CORS Security** - Fixed to use environment-controlled origins
|
| 255 |
-
4. **Request Validation** - Comprehensive parameter validation
|
| 256 |
-
5. **Error Handling** - Safe error messages without data exposure
|
| 257 |
-
|
| 258 |
-
### π§ͺ Tested and Verified:
|
| 259 |
-
- β
Location ID sanitization: `"in-south"` β `"IN-SOUTH"`
|
| 260 |
-
- β
Dangerous input blocked: SQL injection patterns detected
|
| 261 |
-
- β
Coordinate validation: Invalid ranges rejected
|
| 262 |
-
- β
Password redaction: `"secret123"` β `"[REDACTED]"`
|
| 263 |
-
- β
Connection string sanitization: MongoDB URIs protected
|
| 264 |
-
- β
Pagination limits: Large values rejected
|
| 265 |
-
|
| 266 |
-
### π Security Improvements Summary:
|
| 267 |
-
- **Input Validation**: 100% coverage on all API endpoints
|
| 268 |
-
- **Log Sanitization**: All sensitive fields automatically redacted
|
| 269 |
-
- **Error Handling**: No sensitive data exposed in error messages
|
| 270 |
-
- **Performance Impact**: < 2ms overhead per request
|
| 271 |
-
- **Reliability**: Graceful fallback if sanitization fails
|
| 272 |
-
|
| 273 |
-
### π Ready for Production:
|
| 274 |
-
The security improvements are now fully functional and ready for production deployment. All identified vulnerabilities have been addressed with comprehensive testing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/tests/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Regression Test Pack
|
| 2 |
+
|
| 3 |
+
This comprehensive regression test pack ensures the stability, performance, and security of the Merchant API application.
|
| 4 |
+
|
| 5 |
+
## Test Structure
|
| 6 |
+
|
| 7 |
+
### Test Files
|
| 8 |
+
|
| 9 |
+
- **`conftest.py`** - Pytest configuration and shared fixtures
|
| 10 |
+
- **`test_api_endpoints.py`** - API endpoint functionality tests
|
| 11 |
+
- **`test_services.py`** - Service layer component tests
|
| 12 |
+
- **`test_database.py`** - Database operations and repository tests
|
| 13 |
+
- **`test_performance.py`** - Performance and load testing
|
| 14 |
+
- **`test_integration.py`** - End-to-end integration tests
|
| 15 |
+
- **`test_security.py`** - Security and vulnerability tests
|
| 16 |
+
- **`test_advanced_nlp.py`** - Advanced NLP pipeline tests (existing)
|
| 17 |
+
- **`test_regression_suite.py`** - Main regression test suite
|
| 18 |
+
- **`run_tests.py`** - Test runner script
|
| 19 |
+
|
| 20 |
+
### Test Categories
|
| 21 |
+
|
| 22 |
+
Tests are organized using pytest markers:
|
| 23 |
+
|
| 24 |
+
- `unit` - Unit tests for individual components
|
| 25 |
+
- `integration` - Integration tests for component interactions
|
| 26 |
+
- `performance` - Performance and load tests
|
| 27 |
+
- `security` - Security and vulnerability tests
|
| 28 |
+
- `regression` - Critical regression tests
|
| 29 |
+
- `slow` - Tests that take longer to run
|
| 30 |
+
- `database` - Tests requiring database connections
|
| 31 |
+
- `nlp` - NLP functionality tests
|
| 32 |
+
- `api` - API endpoint tests
|
| 33 |
+
- `cache` - Cache-related tests
|
| 34 |
+
|
| 35 |
+
## Running Tests
|
| 36 |
+
|
| 37 |
+
### Quick Start
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
# Run all tests
|
| 41 |
+
python app/tests/run_tests.py
|
| 42 |
+
|
| 43 |
+
# Run specific test suite
|
| 44 |
+
python app/tests/run_tests.py --suite unit
|
| 45 |
+
python app/tests/run_tests.py --suite integration
|
| 46 |
+
python app/tests/run_tests.py --suite performance
|
| 47 |
+
python app/tests/run_tests.py --suite security
|
| 48 |
+
python app/tests/run_tests.py --suite regression
|
| 49 |
+
|
| 50 |
+
# Run with coverage
|
| 51 |
+
python app/tests/run_tests.py --coverage
|
| 52 |
+
|
| 53 |
+
# Run specific test file
|
| 54 |
+
python app/tests/run_tests.py --file test_api_endpoints.py
|
| 55 |
+
|
| 56 |
+
# Run tests matching pattern
|
| 57 |
+
python app/tests/run_tests.py --function "test_health"
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Advanced Usage
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# Run with parallel execution
|
| 64 |
+
python app/tests/run_tests.py --parallel 4
|
| 65 |
+
|
| 66 |
+
# Generate HTML report
|
| 67 |
+
python app/tests/run_tests.py --html-report
|
| 68 |
+
|
| 69 |
+
# Generate JUnit XML for CI/CD
|
| 70 |
+
python app/tests/run_tests.py --junit-xml results.xml
|
| 71 |
+
|
| 72 |
+
# Run with custom markers
|
| 73 |
+
python app/tests/run_tests.py --markers "api and not slow"
|
| 74 |
+
|
| 75 |
+
# Verbose output
|
| 76 |
+
python app/tests/run_tests.py --verbose
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Direct Pytest Usage
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# Run all tests
|
| 83 |
+
pytest app/tests/
|
| 84 |
+
|
| 85 |
+
# Run specific test categories
|
| 86 |
+
pytest app/tests/ -m unit
|
| 87 |
+
pytest app/tests/ -m "integration and not slow"
|
| 88 |
+
pytest app/tests/ -m performance
|
| 89 |
+
|
| 90 |
+
# Run with coverage
|
| 91 |
+
pytest app/tests/ --cov=app --cov-report=html
|
| 92 |
+
|
| 93 |
+
# Run specific test file
|
| 94 |
+
pytest app/tests/test_api_endpoints.py
|
| 95 |
+
|
| 96 |
+
# Run specific test function
|
| 97 |
+
pytest app/tests/test_api_endpoints.py::TestHealthEndpoints::test_health_check
|
| 98 |
+
|
| 99 |
+
# Run with parallel execution (requires pytest-xdist)
|
| 100 |
+
pytest app/tests/ -n 4
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Test Coverage
|
| 104 |
+
|
| 105 |
+
The test pack covers:
|
| 106 |
+
|
| 107 |
+
### API Endpoints
|
| 108 |
+
- Health check endpoints
|
| 109 |
+
- Merchant CRUD operations
|
| 110 |
+
- Search functionality
|
| 111 |
+
- NLP processing endpoints
|
| 112 |
+
- Helper services
|
| 113 |
+
- Error handling
|
| 114 |
+
- Security measures
|
| 115 |
+
|
| 116 |
+
### Service Layer
|
| 117 |
+
- Merchant service operations
|
| 118 |
+
- Helper service functionality
|
| 119 |
+
- Advanced NLP processing
|
| 120 |
+
- Search helpers
|
| 121 |
+
- Service integration
|
| 122 |
+
- Error propagation
|
| 123 |
+
- Performance characteristics
|
| 124 |
+
|
| 125 |
+
### Database Layer
|
| 126 |
+
- MongoDB operations
|
| 127 |
+
- Redis cache operations
|
| 128 |
+
- Database indexing
|
| 129 |
+
- Query optimization
|
| 130 |
+
- Transaction handling
|
| 131 |
+
- Connection management
|
| 132 |
+
- Error handling
|
| 133 |
+
|
| 134 |
+
### Performance Testing
|
| 135 |
+
- API response times
|
| 136 |
+
- Database query performance
|
| 137 |
+
- NLP processing speed
|
| 138 |
+
- Concurrent request handling
|
| 139 |
+
- Memory usage monitoring
|
| 140 |
+
- Load testing scenarios
|
| 141 |
+
- Performance regression detection
|
| 142 |
+
|
| 143 |
+
### Integration Testing
|
| 144 |
+
- End-to-end user journeys
|
| 145 |
+
- Service integration
|
| 146 |
+
- Data flow validation
|
| 147 |
+
- Concurrency handling
|
| 148 |
+
- Error recovery
|
| 149 |
+
- System stability
|
| 150 |
+
|
| 151 |
+
### Security Testing
|
| 152 |
+
- Input validation and sanitization
|
| 153 |
+
- SQL/NoSQL injection prevention
|
| 154 |
+
- XSS prevention
|
| 155 |
+
- Authentication and authorization
|
| 156 |
+
- CORS configuration
|
| 157 |
+
- Rate limiting
|
| 158 |
+
- Data protection
|
| 159 |
+
|
| 160 |
+
## Test Configuration
|
| 161 |
+
|
| 162 |
+
### Environment Variables
|
| 163 |
+
|
| 164 |
+
Set these environment variables for testing:
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
export TESTING=true
|
| 168 |
+
export MONGODB_URL=mongodb://localhost:27017/test_db
|
| 169 |
+
export REDIS_URL=redis://localhost:6379/1
|
| 170 |
+
export ALLOWED_ORIGINS=http://localhost:3000,http://testserver
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Dependencies
|
| 174 |
+
|
| 175 |
+
Install test dependencies:
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
pip install pytest pytest-asyncio pytest-cov pytest-html pytest-xdist pytest-timeout
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
Or install minimal dependencies:
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
pip install pytest pytest-asyncio
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
### Mock Configuration
|
| 188 |
+
|
| 189 |
+
Tests use extensive mocking to isolate components and ensure reliable, fast execution:
|
| 190 |
+
|
| 191 |
+
- Database operations are mocked to avoid external dependencies
|
| 192 |
+
- NLP services are mocked for consistent results
|
| 193 |
+
- External APIs are mocked to prevent network calls
|
| 194 |
+
- Time-sensitive operations use controlled timing
|
| 195 |
+
|
| 196 |
+
## Continuous Integration
|
| 197 |
+
|
| 198 |
+
### GitHub Actions Example
|
| 199 |
+
|
| 200 |
+
```yaml
|
| 201 |
+
name: Regression Tests
|
| 202 |
+
|
| 203 |
+
on: [push, pull_request]
|
| 204 |
+
|
| 205 |
+
jobs:
|
| 206 |
+
test:
|
| 207 |
+
runs-on: ubuntu-latest
|
| 208 |
+
|
| 209 |
+
steps:
|
| 210 |
+
- uses: actions/checkout@v2
|
| 211 |
+
|
| 212 |
+
- name: Set up Python
|
| 213 |
+
uses: actions/setup-python@v2
|
| 214 |
+
with:
|
| 215 |
+
python-version: 3.9
|
| 216 |
+
|
| 217 |
+
- name: Install dependencies
|
| 218 |
+
run: |
|
| 219 |
+
pip install -r requirements.txt
|
| 220 |
+
pip install pytest pytest-asyncio pytest-cov pytest-html
|
| 221 |
+
|
| 222 |
+
- name: Run regression tests
|
| 223 |
+
run: |
|
| 224 |
+
python app/tests/run_tests.py --suite regression --coverage --junit-xml results.xml
|
| 225 |
+
|
| 226 |
+
- name: Upload test results
|
| 227 |
+
uses: actions/upload-artifact@v2
|
| 228 |
+
with:
|
| 229 |
+
name: test-results
|
| 230 |
+
path: results.xml
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
## Performance Benchmarks
|
| 234 |
+
|
| 235 |
+
The test pack includes performance benchmarks to detect regressions:
|
| 236 |
+
|
| 237 |
+
- API endpoints should respond within 2 seconds
|
| 238 |
+
- Database queries should complete within 500ms
|
| 239 |
+
- NLP processing should complete within 3 seconds
|
| 240 |
+
- System should handle 50+ concurrent requests
|
| 241 |
+
- Memory usage should remain stable under load
|
| 242 |
+
|
| 243 |
+
## Test Data
|
| 244 |
+
|
| 245 |
+
Tests use controlled test data:
|
| 246 |
+
|
| 247 |
+
- Sample merchant data with realistic structure
|
| 248 |
+
- Predefined search queries for consistent testing
|
| 249 |
+
- Mock NLP responses for reliable results
|
| 250 |
+
- Performance test datasets for load testing
|
| 251 |
+
|
| 252 |
+
## Troubleshooting
|
| 253 |
+
|
| 254 |
+
### Common Issues
|
| 255 |
+
|
| 256 |
+
1. **Import Errors**: Ensure PYTHONPATH includes the project root
|
| 257 |
+
2. **Database Errors**: Check that test environment variables are set
|
| 258 |
+
3. **Async Errors**: Ensure pytest-asyncio is installed and configured
|
| 259 |
+
4. **Mock Errors**: Verify that mocks are properly configured for your test scenario
|
| 260 |
+
|
| 261 |
+
### Debug Mode
|
| 262 |
+
|
| 263 |
+
Run tests with maximum verbosity:
|
| 264 |
+
|
| 265 |
+
```bash
|
| 266 |
+
python app/tests/run_tests.py --verbose --function "test_specific_function"
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
### Test Isolation
|
| 270 |
+
|
| 271 |
+
Each test is designed to be independent:
|
| 272 |
+
- Fixtures provide clean test data
|
| 273 |
+
- Mocks prevent external dependencies
|
| 274 |
+
- Setup/teardown ensures clean state
|
| 275 |
+
|
| 276 |
+
## Contributing
|
| 277 |
+
|
| 278 |
+
When adding new tests:
|
| 279 |
+
|
| 280 |
+
1. Follow the existing naming conventions
|
| 281 |
+
2. Use appropriate pytest markers
|
| 282 |
+
3. Include docstrings explaining test purpose
|
| 283 |
+
4. Mock external dependencies
|
| 284 |
+
5. Ensure tests are deterministic
|
| 285 |
+
6. Add performance assertions where relevant
|
| 286 |
+
7. Include both positive and negative test cases
|
| 287 |
+
|
| 288 |
+
## Reporting Issues
|
| 289 |
+
|
| 290 |
+
If tests fail:
|
| 291 |
+
|
| 292 |
+
1. Check the test output for specific error messages
|
| 293 |
+
2. Verify environment setup
|
| 294 |
+
3. Run individual test files to isolate issues
|
| 295 |
+
4. Check for recent code changes that might affect functionality
|
| 296 |
+
5. Review mock configurations for accuracy
|
app/tests/conftest.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pytest configuration and shared fixtures for regression tests
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
from typing import AsyncGenerator, Dict, Any
|
| 9 |
+
from unittest.mock import AsyncMock, MagicMock
|
| 10 |
+
from fastapi.testclient import TestClient
|
| 11 |
+
from httpx import AsyncClient
|
| 12 |
+
|
| 13 |
+
# Import the FastAPI app
|
| 14 |
+
from app.app import app
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(scope="session")
|
| 17 |
+
def event_loop():
|
| 18 |
+
"""Create an instance of the default event loop for the test session."""
|
| 19 |
+
loop = asyncio.get_event_loop_policy().new_event_loop()
|
| 20 |
+
yield loop
|
| 21 |
+
loop.close()
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def client():
|
| 25 |
+
"""Create a test client for the FastAPI app."""
|
| 26 |
+
return TestClient(app)
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
async def async_client() -> AsyncGenerator[AsyncClient, None]:
|
| 30 |
+
"""Create an async test client for the FastAPI app."""
|
| 31 |
+
async with AsyncClient(app=app, base_url="http://test") as ac:
|
| 32 |
+
yield ac
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def mock_mongodb():
|
| 36 |
+
"""Mock MongoDB connection."""
|
| 37 |
+
mock_db = MagicMock()
|
| 38 |
+
mock_collection = MagicMock()
|
| 39 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 40 |
+
return mock_db
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def mock_redis():
|
| 44 |
+
"""Mock Redis connection."""
|
| 45 |
+
mock_redis = AsyncMock()
|
| 46 |
+
mock_redis.get.return_value = None
|
| 47 |
+
mock_redis.set.return_value = True
|
| 48 |
+
mock_redis.delete.return_value = True
|
| 49 |
+
return mock_redis
|
| 50 |
+
|
| 51 |
+
@pytest.fixture
|
| 52 |
+
def sample_merchant_data():
|
| 53 |
+
"""Sample merchant data for testing."""
|
| 54 |
+
return {
|
| 55 |
+
"_id": "test_merchant_123",
|
| 56 |
+
"name": "Test Hair Salon",
|
| 57 |
+
"category": "salon",
|
| 58 |
+
"subcategory": "hair_salon",
|
| 59 |
+
"location": {
|
| 60 |
+
"type": "Point",
|
| 61 |
+
"coordinates": [-74.0060, 40.7128] # NYC coordinates
|
| 62 |
+
},
|
| 63 |
+
"address": {
|
| 64 |
+
"street": "123 Test Street",
|
| 65 |
+
"city": "New York",
|
| 66 |
+
"state": "NY",
|
| 67 |
+
"zip_code": "10001"
|
| 68 |
+
},
|
| 69 |
+
"contact": {
|
| 70 |
+
"phone": "+1-555-0123",
|
| 71 |
+
"email": "test@testsalon.com"
|
| 72 |
+
},
|
| 73 |
+
"business_hours": {
|
| 74 |
+
"monday": {"open": "09:00", "close": "18:00"},
|
| 75 |
+
"tuesday": {"open": "09:00", "close": "18:00"},
|
| 76 |
+
"wednesday": {"open": "09:00", "close": "18:00"},
|
| 77 |
+
"thursday": {"open": "09:00", "close": "18:00"},
|
| 78 |
+
"friday": {"open": "09:00", "close": "19:00"},
|
| 79 |
+
"saturday": {"open": "08:00", "close": "17:00"},
|
| 80 |
+
"sunday": {"closed": True}
|
| 81 |
+
},
|
| 82 |
+
"services": [
|
| 83 |
+
{"name": "Haircut", "price": 50.0, "duration": 60},
|
| 84 |
+
{"name": "Hair Color", "price": 120.0, "duration": 120},
|
| 85 |
+
{"name": "Blowout", "price": 35.0, "duration": 45}
|
| 86 |
+
],
|
| 87 |
+
"amenities": ["parking", "wifi", "wheelchair_accessible"],
|
| 88 |
+
"average_rating": 4.5,
|
| 89 |
+
"total_reviews": 127,
|
| 90 |
+
"price_range": "$$",
|
| 91 |
+
"is_active": True,
|
| 92 |
+
"created_at": "2024-01-01T00:00:00Z",
|
| 93 |
+
"updated_at": "2024-01-15T12:00:00Z"
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@pytest.fixture
|
| 97 |
+
def sample_search_query():
|
| 98 |
+
"""Sample search query for testing."""
|
| 99 |
+
return {
|
| 100 |
+
"query": "find the best hair salon near me with parking",
|
| 101 |
+
"latitude": 40.7128,
|
| 102 |
+
"longitude": -74.0060,
|
| 103 |
+
"radius": 5000, # 5km
|
| 104 |
+
"category": "salon"
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@pytest.fixture
|
| 108 |
+
def mock_nlp_pipeline():
|
| 109 |
+
"""Mock NLP pipeline for testing."""
|
| 110 |
+
mock_pipeline = AsyncMock()
|
| 111 |
+
mock_pipeline.process_query.return_value = {
|
| 112 |
+
"query": "test query",
|
| 113 |
+
"primary_intent": {
|
| 114 |
+
"intent": "SEARCH_SERVICE",
|
| 115 |
+
"confidence": 0.85
|
| 116 |
+
},
|
| 117 |
+
"entities": {
|
| 118 |
+
"service_types": ["haircut"],
|
| 119 |
+
"amenities": ["parking"],
|
| 120 |
+
"location_modifiers": ["near me"]
|
| 121 |
+
},
|
| 122 |
+
"similar_services": [("salon", 0.9)],
|
| 123 |
+
"search_parameters": {
|
| 124 |
+
"merchant_category": "salon",
|
| 125 |
+
"amenities": ["parking"],
|
| 126 |
+
"radius": 5000
|
| 127 |
+
},
|
| 128 |
+
"processing_time": 0.123
|
| 129 |
+
}
|
| 130 |
+
return mock_pipeline
|
| 131 |
+
|
| 132 |
+
@pytest.fixture(autouse=True)
|
| 133 |
+
def setup_test_environment():
|
| 134 |
+
"""Setup test environment variables."""
|
| 135 |
+
os.environ["TESTING"] = "true"
|
| 136 |
+
os.environ["MONGODB_URL"] = "mongodb://localhost:27017/test_db"
|
| 137 |
+
os.environ["REDIS_URL"] = "redis://localhost:6379/1"
|
| 138 |
+
os.environ["ALLOWED_ORIGINS"] = "http://localhost:3000,http://testserver"
|
| 139 |
+
yield
|
| 140 |
+
# Cleanup
|
| 141 |
+
for key in ["TESTING", "MONGODB_URL", "REDIS_URL", "ALLOWED_ORIGINS"]:
|
| 142 |
+
os.environ.pop(key, None)
|
| 143 |
+
|
| 144 |
+
@pytest.fixture
|
| 145 |
+
def performance_test_data():
|
| 146 |
+
"""Data for performance testing."""
|
| 147 |
+
return {
|
| 148 |
+
"queries": [
|
| 149 |
+
"find a hair salon",
|
| 150 |
+
"best spa near me",
|
| 151 |
+
"gym with parking",
|
| 152 |
+
"dental clinic open now",
|
| 153 |
+
"massage therapy luxury",
|
| 154 |
+
"budget-friendly fitness center",
|
| 155 |
+
"nail salon walking distance",
|
| 156 |
+
"pet-friendly grooming",
|
| 157 |
+
"24/7 pharmacy",
|
| 158 |
+
"organic restaurant"
|
| 159 |
+
],
|
| 160 |
+
"expected_max_response_time": 2.0, # seconds
|
| 161 |
+
"expected_min_success_rate": 0.95 # 95%
|
| 162 |
+
}
|
app/tests/pytest.ini
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool:pytest]
|
| 2 |
+
# Pytest configuration for regression test pack
|
| 3 |
+
|
| 4 |
+
# Test discovery
|
| 5 |
+
testpaths = app/tests
|
| 6 |
+
python_files = test_*.py
|
| 7 |
+
python_classes = Test*
|
| 8 |
+
python_functions = test_*
|
| 9 |
+
|
| 10 |
+
# Markers for test categorization
|
| 11 |
+
markers =
|
| 12 |
+
unit: Unit tests for individual components
|
| 13 |
+
integration: Integration tests for component interactions
|
| 14 |
+
performance: Performance and load tests
|
| 15 |
+
security: Security and vulnerability tests
|
| 16 |
+
regression: Regression tests for critical functionality
|
| 17 |
+
slow: Tests that take longer to run
|
| 18 |
+
database: Tests that require database connections
|
| 19 |
+
nlp: Tests for NLP functionality
|
| 20 |
+
api: API endpoint tests
|
| 21 |
+
cache: Cache-related tests
|
| 22 |
+
|
| 23 |
+
# Test execution options
|
| 24 |
+
addopts =
|
| 25 |
+
-v
|
| 26 |
+
--tb=short
|
| 27 |
+
--strict-markers
|
| 28 |
+
--disable-warnings
|
| 29 |
+
--color=yes
|
| 30 |
+
--durations=10
|
| 31 |
+
--maxfail=5
|
| 32 |
+
|
| 33 |
+
# Async test configuration
|
| 34 |
+
asyncio_mode = auto
|
| 35 |
+
|
| 36 |
+
# Test timeout (in seconds) - requires pytest-timeout plugin
|
| 37 |
+
# timeout = 300
|
| 38 |
+
|
| 39 |
+
# Minimum Python version
|
| 40 |
+
minversion = 3.8
|
| 41 |
+
|
| 42 |
+
# Test output
|
| 43 |
+
console_output_style = progress
|
| 44 |
+
junit_family = xunit2
|
| 45 |
+
|
| 46 |
+
# Coverage configuration (if using pytest-cov)
|
| 47 |
+
# addopts = --cov=app --cov-report=html --cov-report=term-missing --cov-fail-under=80
|
| 48 |
+
|
| 49 |
+
# Logging configuration
|
| 50 |
+
log_cli = true
|
| 51 |
+
log_cli_level = INFO
|
| 52 |
+
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
|
| 53 |
+
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
| 54 |
+
|
| 55 |
+
# Filter warnings
|
| 56 |
+
filterwarnings =
|
| 57 |
+
ignore::DeprecationWarning
|
| 58 |
+
ignore::PendingDeprecationWarning
|
| 59 |
+
ignore::UserWarning:motor.*
|
| 60 |
+
ignore::UserWarning:pymongo.*
|
app/tests/run_tests.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test runner script for the regression test pack
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import argparse
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
def run_command(command, description=""):
|
| 14 |
+
"""Run a command and return the result"""
|
| 15 |
+
print(f"\n{'='*60}")
|
| 16 |
+
print(f"Running: {description or command}")
|
| 17 |
+
print(f"{'='*60}")
|
| 18 |
+
|
| 19 |
+
start_time = time.time()
|
| 20 |
+
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
| 21 |
+
duration = time.time() - start_time
|
| 22 |
+
|
| 23 |
+
print(f"Duration: {duration:.2f}s")
|
| 24 |
+
print(f"Exit code: {result.returncode}")
|
| 25 |
+
|
| 26 |
+
if result.stdout:
|
| 27 |
+
print(f"\nSTDOUT:\n{result.stdout}")
|
| 28 |
+
|
| 29 |
+
if result.stderr:
|
| 30 |
+
print(f"\nSTDERR:\n{result.stderr}")
|
| 31 |
+
|
| 32 |
+
return result
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
parser = argparse.ArgumentParser(description="Run regression test pack")
|
| 36 |
+
parser.add_argument("--suite", choices=["all", "unit", "integration", "performance", "security", "regression"],
|
| 37 |
+
default="all", help="Test suite to run")
|
| 38 |
+
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
| 39 |
+
parser.add_argument("--coverage", action="store_true", help="Run with coverage")
|
| 40 |
+
parser.add_argument("--parallel", "-n", type=int, help="Number of parallel workers")
|
| 41 |
+
parser.add_argument("--markers", "-m", help="Run tests with specific markers")
|
| 42 |
+
parser.add_argument("--file", "-f", help="Run specific test file")
|
| 43 |
+
parser.add_argument("--function", "-k", help="Run tests matching pattern")
|
| 44 |
+
parser.add_argument("--html-report", action="store_true", help="Generate HTML report")
|
| 45 |
+
parser.add_argument("--junit-xml", help="Generate JUnit XML report")
|
| 46 |
+
parser.add_argument("--timeout", type=int, default=300, help="Test timeout in seconds (requires pytest-timeout)")
|
| 47 |
+
|
| 48 |
+
args = parser.parse_args()
|
| 49 |
+
|
| 50 |
+
# Set up environment
|
| 51 |
+
os.environ["TESTING"] = "true"
|
| 52 |
+
os.environ["PYTHONPATH"] = str(Path(__file__).parent.parent.parent)
|
| 53 |
+
|
| 54 |
+
# Build pytest command
|
| 55 |
+
cmd_parts = ["python", "-m", "pytest"]
|
| 56 |
+
|
| 57 |
+
# Add test path
|
| 58 |
+
if args.file:
|
| 59 |
+
cmd_parts.append(f"app/tests/{args.file}")
|
| 60 |
+
else:
|
| 61 |
+
cmd_parts.append("app/tests/")
|
| 62 |
+
|
| 63 |
+
# Add verbosity
|
| 64 |
+
if args.verbose:
|
| 65 |
+
cmd_parts.append("-vv")
|
| 66 |
+
else:
|
| 67 |
+
cmd_parts.append("-v")
|
| 68 |
+
|
| 69 |
+
# Add coverage
|
| 70 |
+
if args.coverage:
|
| 71 |
+
cmd_parts.extend([
|
| 72 |
+
"--cov=app",
|
| 73 |
+
"--cov-report=html:htmlcov",
|
| 74 |
+
"--cov-report=term-missing",
|
| 75 |
+
"--cov-fail-under=70"
|
| 76 |
+
])
|
| 77 |
+
|
| 78 |
+
# Add parallel execution
|
| 79 |
+
if args.parallel:
|
| 80 |
+
cmd_parts.extend(["-n", str(args.parallel)])
|
| 81 |
+
|
| 82 |
+
# Add markers
|
| 83 |
+
if args.suite != "all":
|
| 84 |
+
cmd_parts.extend(["-m", args.suite])
|
| 85 |
+
elif args.markers:
|
| 86 |
+
cmd_parts.extend(["-m", args.markers])
|
| 87 |
+
|
| 88 |
+
# Add function pattern
|
| 89 |
+
if args.function:
|
| 90 |
+
cmd_parts.extend(["-k", args.function])
|
| 91 |
+
|
| 92 |
+
# Add timeout (only if pytest-timeout is available)
|
| 93 |
+
try:
|
| 94 |
+
import pytest_timeout
|
| 95 |
+
cmd_parts.extend(["--timeout", str(args.timeout)])
|
| 96 |
+
except ImportError:
|
| 97 |
+
print("Note: pytest-timeout not installed, skipping timeout option")
|
| 98 |
+
|
| 99 |
+
# Add HTML report
|
| 100 |
+
if args.html_report:
|
| 101 |
+
cmd_parts.extend(["--html=test_report.html", "--self-contained-html"])
|
| 102 |
+
|
| 103 |
+
# Add JUnit XML
|
| 104 |
+
if args.junit_xml:
|
| 105 |
+
cmd_parts.extend(["--junit-xml", args.junit_xml])
|
| 106 |
+
|
| 107 |
+
# Add other options
|
| 108 |
+
cmd_parts.extend([
|
| 109 |
+
"--tb=short",
|
| 110 |
+
"--strict-markers",
|
| 111 |
+
"--color=yes",
|
| 112 |
+
"--durations=10"
|
| 113 |
+
])
|
| 114 |
+
|
| 115 |
+
# Run the tests
|
| 116 |
+
command = " ".join(cmd_parts)
|
| 117 |
+
result = run_command(command, f"Running {args.suite} test suite")
|
| 118 |
+
|
| 119 |
+
# Print summary
|
| 120 |
+
print(f"\n{'='*60}")
|
| 121 |
+
print("TEST EXECUTION SUMMARY")
|
| 122 |
+
print(f"{'='*60}")
|
| 123 |
+
print(f"Suite: {args.suite}")
|
| 124 |
+
print(f"Exit Code: {result.returncode}")
|
| 125 |
+
print(f"Command: {command}")
|
| 126 |
+
|
| 127 |
+
if result.returncode == 0:
|
| 128 |
+
print("β
All tests passed!")
|
| 129 |
+
else:
|
| 130 |
+
print("β Some tests failed!")
|
| 131 |
+
|
| 132 |
+
# Extract test summary from output
|
| 133 |
+
if "failed" in result.stdout.lower() or "error" in result.stdout.lower():
|
| 134 |
+
lines = result.stdout.split('\n')
|
| 135 |
+
for line in lines:
|
| 136 |
+
if "failed" in line.lower() or "passed" in line.lower():
|
| 137 |
+
print(f"Result: {line.strip()}")
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
return result.returncode
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
sys.exit(main())
|
app/tests/test_api_endpoints.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regression tests for API endpoints
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import json
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
from unittest.mock import patch, AsyncMock
|
| 9 |
+
from httpx import AsyncClient
|
| 10 |
+
|
| 11 |
+
@pytest.mark.api
|
| 12 |
+
class TestHealthEndpoints:
|
| 13 |
+
"""Test health check endpoints"""
|
| 14 |
+
|
| 15 |
+
def test_health_check(self, client: TestClient):
|
| 16 |
+
"""Test basic health check endpoint"""
|
| 17 |
+
response = client.get("/health")
|
| 18 |
+
assert response.status_code == 200
|
| 19 |
+
|
| 20 |
+
data = response.json()
|
| 21 |
+
assert data["status"] == "healthy"
|
| 22 |
+
assert "timestamp" in data
|
| 23 |
+
assert data["service"] == "merchant-api"
|
| 24 |
+
assert data["version"] == "1.0.0"
|
| 25 |
+
|
| 26 |
+
@patch('app.nosql.check_mongodb_health')
|
| 27 |
+
@patch('app.nosql.check_redis_health')
|
| 28 |
+
def test_readiness_check_healthy(self, mock_redis, mock_mongo, client: TestClient):
|
| 29 |
+
"""Test readiness check when databases are healthy"""
|
| 30 |
+
mock_mongo.return_value = True
|
| 31 |
+
mock_redis.return_value = True
|
| 32 |
+
|
| 33 |
+
response = client.get("/ready")
|
| 34 |
+
assert response.status_code == 200
|
| 35 |
+
|
| 36 |
+
data = response.json()
|
| 37 |
+
assert data["status"] == "ready"
|
| 38 |
+
assert data["databases"]["mongodb"] == "healthy"
|
| 39 |
+
assert data["databases"]["redis"] == "healthy"
|
| 40 |
+
|
| 41 |
+
@patch('app.nosql.check_mongodb_health')
|
| 42 |
+
@patch('app.nosql.check_redis_health')
|
| 43 |
+
def test_readiness_check_unhealthy(self, mock_redis, mock_mongo, client: TestClient):
|
| 44 |
+
"""Test readiness check when databases are unhealthy"""
|
| 45 |
+
mock_mongo.return_value = False
|
| 46 |
+
mock_redis.return_value = True
|
| 47 |
+
|
| 48 |
+
response = client.get("/ready")
|
| 49 |
+
assert response.status_code == 503
|
| 50 |
+
|
| 51 |
+
data = response.json()
|
| 52 |
+
assert "not_ready" in data["detail"]["status"]
|
| 53 |
+
|
| 54 |
+
class TestMerchantEndpoints:
|
| 55 |
+
"""Test merchant-related endpoints"""
|
| 56 |
+
|
| 57 |
+
@patch('app.services.merchant.get_merchants')
|
| 58 |
+
def test_get_merchants_success(self, mock_get_merchants, client: TestClient, sample_merchant_data):
|
| 59 |
+
"""Test successful merchant retrieval"""
|
| 60 |
+
mock_get_merchants.return_value = [sample_merchant_data]
|
| 61 |
+
|
| 62 |
+
response = client.get("/api/v1/merchants/")
|
| 63 |
+
assert response.status_code == 200
|
| 64 |
+
|
| 65 |
+
data = response.json()
|
| 66 |
+
assert len(data) == 1
|
| 67 |
+
assert data[0]["name"] == "Test Hair Salon"
|
| 68 |
+
assert data[0]["category"] == "salon"
|
| 69 |
+
|
| 70 |
+
@patch('app.services.merchant.get_merchant_by_id')
|
| 71 |
+
def test_get_merchant_by_id_success(self, mock_get_merchant, client: TestClient, sample_merchant_data):
|
| 72 |
+
"""Test successful merchant retrieval by ID"""
|
| 73 |
+
mock_get_merchant.return_value = sample_merchant_data
|
| 74 |
+
|
| 75 |
+
response = client.get("/api/v1/merchants/test_merchant_123")
|
| 76 |
+
assert response.status_code == 200
|
| 77 |
+
|
| 78 |
+
data = response.json()
|
| 79 |
+
assert data["_id"] == "test_merchant_123"
|
| 80 |
+
assert data["name"] == "Test Hair Salon"
|
| 81 |
+
|
| 82 |
+
@patch('app.services.merchant.get_merchant_by_id')
|
| 83 |
+
def test_get_merchant_by_id_not_found(self, mock_get_merchant, client: TestClient):
|
| 84 |
+
"""Test merchant not found scenario"""
|
| 85 |
+
mock_get_merchant.return_value = None
|
| 86 |
+
|
| 87 |
+
response = client.get("/api/v1/merchants/nonexistent_id")
|
| 88 |
+
assert response.status_code == 404
|
| 89 |
+
|
| 90 |
+
@patch('app.services.merchant.search_merchants')
|
| 91 |
+
def test_search_merchants_with_location(self, mock_search, client: TestClient, sample_merchant_data):
|
| 92 |
+
"""Test merchant search with location parameters"""
|
| 93 |
+
mock_search.return_value = [sample_merchant_data]
|
| 94 |
+
|
| 95 |
+
response = client.get("/api/v1/merchants/search", params={
|
| 96 |
+
"latitude": 40.7128,
|
| 97 |
+
"longitude": -74.0060,
|
| 98 |
+
"radius": 5000,
|
| 99 |
+
"category": "salon"
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
assert response.status_code == 200
|
| 103 |
+
data = response.json()
|
| 104 |
+
assert len(data) == 1
|
| 105 |
+
assert data[0]["category"] == "salon"
|
| 106 |
+
|
| 107 |
+
def test_search_merchants_invalid_coordinates(self, client: TestClient):
|
| 108 |
+
"""Test merchant search with invalid coordinates"""
|
| 109 |
+
response = client.get("/api/v1/merchants/search", params={
|
| 110 |
+
"latitude": 200, # Invalid latitude
|
| 111 |
+
"longitude": -74.0060,
|
| 112 |
+
"radius": 5000
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
assert response.status_code == 400
|
| 116 |
+
|
| 117 |
+
class TestHelperEndpoints:
|
| 118 |
+
"""Test helper service endpoints"""
|
| 119 |
+
|
| 120 |
+
@patch('app.services.helper.process_free_text')
|
| 121 |
+
def test_process_free_text_success(self, mock_process, client: TestClient):
|
| 122 |
+
"""Test successful free text processing"""
|
| 123 |
+
mock_process.return_value = {
|
| 124 |
+
"query": "find a hair salon",
|
| 125 |
+
"extracted_keywords": ["hair", "salon"],
|
| 126 |
+
"suggested_category": "salon",
|
| 127 |
+
"search_parameters": {"category": "salon"}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 131 |
+
"text": "find a hair salon",
|
| 132 |
+
"latitude": 40.7128,
|
| 133 |
+
"longitude": -74.0060
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
assert response.status_code == 200
|
| 137 |
+
data = response.json()
|
| 138 |
+
assert data["suggested_category"] == "salon"
|
| 139 |
+
|
| 140 |
+
def test_process_free_text_empty_input(self, client: TestClient):
|
| 141 |
+
"""Test free text processing with empty input"""
|
| 142 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 143 |
+
"text": "",
|
| 144 |
+
"latitude": 40.7128,
|
| 145 |
+
"longitude": -74.0060
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
assert response.status_code == 400
|
| 149 |
+
|
| 150 |
+
def test_process_free_text_too_long(self, client: TestClient):
|
| 151 |
+
"""Test free text processing with input too long"""
|
| 152 |
+
long_text = "a" * 1001 # Assuming 1000 char limit
|
| 153 |
+
|
| 154 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 155 |
+
"text": long_text,
|
| 156 |
+
"latitude": 40.7128,
|
| 157 |
+
"longitude": -74.0060
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
assert response.status_code == 400
|
| 161 |
+
|
| 162 |
+
class TestNLPEndpoints:
|
| 163 |
+
"""Test NLP demo endpoints"""
|
| 164 |
+
|
| 165 |
+
@patch('app.services.advanced_nlp.advanced_nlp_pipeline')
|
| 166 |
+
def test_analyze_query_success(self, mock_pipeline, client: TestClient, mock_nlp_pipeline):
|
| 167 |
+
"""Test successful query analysis"""
|
| 168 |
+
mock_pipeline.process_query = mock_nlp_pipeline.process_query
|
| 169 |
+
|
| 170 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 171 |
+
"query": "find the best hair salon near me",
|
| 172 |
+
"latitude": 40.7128,
|
| 173 |
+
"longitude": -74.0060
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
assert response.status_code == 200
|
| 177 |
+
data = response.json()
|
| 178 |
+
assert data["status"] == "success"
|
| 179 |
+
assert "analysis" in data
|
| 180 |
+
|
| 181 |
+
def test_analyze_query_empty_input(self, client: TestClient):
|
| 182 |
+
"""Test query analysis with empty input"""
|
| 183 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 184 |
+
"query": ""
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
assert response.status_code == 400
|
| 188 |
+
|
| 189 |
+
def test_get_supported_intents(self, client: TestClient):
|
| 190 |
+
"""Test getting supported intents"""
|
| 191 |
+
response = client.get("/api/v1/nlp/supported-intents")
|
| 192 |
+
assert response.status_code == 200
|
| 193 |
+
|
| 194 |
+
data = response.json()
|
| 195 |
+
assert data["status"] == "success"
|
| 196 |
+
assert "supported_intents" in data
|
| 197 |
+
assert "SEARCH_SERVICE" in data["supported_intents"]
|
| 198 |
+
assert "FILTER_QUALITY" in data["supported_intents"]
|
| 199 |
+
|
| 200 |
+
def test_get_supported_entities(self, client: TestClient):
|
| 201 |
+
"""Test getting supported entities"""
|
| 202 |
+
response = client.get("/api/v1/nlp/supported-entities")
|
| 203 |
+
assert response.status_code == 200
|
| 204 |
+
|
| 205 |
+
data = response.json()
|
| 206 |
+
assert data["status"] == "success"
|
| 207 |
+
assert "supported_entities" in data
|
| 208 |
+
assert "services" in data["supported_entities"]
|
| 209 |
+
assert "amenities" in data["supported_entities"]
|
| 210 |
+
|
| 211 |
+
class TestPerformanceEndpoints:
|
| 212 |
+
"""Test performance monitoring endpoints"""
|
| 213 |
+
|
| 214 |
+
@patch('app.utils.performance_monitor.get_performance_report')
|
| 215 |
+
def test_get_performance_report(self, mock_report, client: TestClient):
|
| 216 |
+
"""Test performance report endpoint"""
|
| 217 |
+
mock_report.return_value = {
|
| 218 |
+
"metrics": {
|
| 219 |
+
"total_queries": 100,
|
| 220 |
+
"average_time": 0.5,
|
| 221 |
+
"slow_queries": []
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
response = client.get("/api/v1/performance/report")
|
| 226 |
+
assert response.status_code == 200
|
| 227 |
+
|
| 228 |
+
def test_get_metrics(self, client: TestClient):
|
| 229 |
+
"""Test metrics endpoint"""
|
| 230 |
+
response = client.get("/metrics")
|
| 231 |
+
# Should return metrics even if some components fail
|
| 232 |
+
assert response.status_code in [200, 500]
|
| 233 |
+
|
| 234 |
+
class TestSecurityEndpoints:
|
| 235 |
+
"""Test security-related functionality"""
|
| 236 |
+
|
| 237 |
+
def test_cors_headers(self, client: TestClient):
|
| 238 |
+
"""Test CORS headers are properly set"""
|
| 239 |
+
response = client.options("/api/v1/merchants/", headers={
|
| 240 |
+
"Origin": "http://localhost:3000",
|
| 241 |
+
"Access-Control-Request-Method": "GET"
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
# Should allow the request
|
| 245 |
+
assert response.status_code in [200, 204]
|
| 246 |
+
|
| 247 |
+
def test_invalid_origin_blocked(self, client: TestClient):
|
| 248 |
+
"""Test that invalid origins are blocked"""
|
| 249 |
+
response = client.get("/api/v1/merchants/", headers={
|
| 250 |
+
"Origin": "http://malicious-site.com"
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
# Should still work but without CORS headers for invalid origin
|
| 254 |
+
assert response.status_code == 200
|
| 255 |
+
|
| 256 |
+
def test_request_size_limit(self, client: TestClient):
|
| 257 |
+
"""Test request size limits"""
|
| 258 |
+
large_payload = {"data": "x" * (11 * 1024 * 1024)} # 11MB
|
| 259 |
+
|
| 260 |
+
response = client.post("/api/v1/helpers/process-text", json=large_payload)
|
| 261 |
+
# Should be rejected due to size limit
|
| 262 |
+
assert response.status_code in [413, 400]
|
| 263 |
+
|
| 264 |
+
class TestErrorHandling:
|
| 265 |
+
"""Test error handling across endpoints"""
|
| 266 |
+
|
| 267 |
+
def test_404_for_nonexistent_endpoint(self, client: TestClient):
|
| 268 |
+
"""Test 404 for non-existent endpoints"""
|
| 269 |
+
response = client.get("/api/v1/nonexistent")
|
| 270 |
+
assert response.status_code == 404
|
| 271 |
+
|
| 272 |
+
def test_405_for_wrong_method(self, client: TestClient):
|
| 273 |
+
"""Test 405 for wrong HTTP method"""
|
| 274 |
+
response = client.delete("/api/v1/merchants/")
|
| 275 |
+
assert response.status_code == 405
|
| 276 |
+
|
| 277 |
+
@patch('app.services.merchant.get_merchants')
|
| 278 |
+
def test_500_error_handling(self, mock_get_merchants, client: TestClient):
|
| 279 |
+
"""Test 500 error handling"""
|
| 280 |
+
mock_get_merchants.side_effect = Exception("Database error")
|
| 281 |
+
|
| 282 |
+
response = client.get("/api/v1/merchants/")
|
| 283 |
+
assert response.status_code == 500
|
| 284 |
+
|
| 285 |
+
def test_malformed_json(self, client: TestClient):
|
| 286 |
+
"""Test handling of malformed JSON"""
|
| 287 |
+
response = client.post(
|
| 288 |
+
"/api/v1/helpers/process-text",
|
| 289 |
+
data="invalid json",
|
| 290 |
+
headers={"Content-Type": "application/json"}
|
| 291 |
+
)
|
| 292 |
+
assert response.status_code == 422
|
| 293 |
+
|
| 294 |
+
class TestAsyncEndpoints:
|
| 295 |
+
"""Test async endpoint functionality"""
|
| 296 |
+
|
| 297 |
+
@pytest.mark.asyncio
|
| 298 |
+
async def test_async_client_health_check(self, async_client: AsyncClient):
|
| 299 |
+
"""Test health check with async client"""
|
| 300 |
+
response = await async_client.get("/health")
|
| 301 |
+
assert response.status_code == 200
|
| 302 |
+
|
| 303 |
+
data = response.json()
|
| 304 |
+
assert data["status"] == "healthy"
|
| 305 |
+
|
| 306 |
+
@pytest.mark.asyncio
|
| 307 |
+
@patch('app.services.advanced_nlp.advanced_nlp_pipeline')
|
| 308 |
+
async def test_async_nlp_processing(self, mock_pipeline, async_client: AsyncClient, mock_nlp_pipeline):
|
| 309 |
+
"""Test async NLP processing"""
|
| 310 |
+
mock_pipeline.process_query = mock_nlp_pipeline.process_query
|
| 311 |
+
|
| 312 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 313 |
+
"query": "find a spa"
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
assert response.status_code == 200
|
| 317 |
+
data = response.json()
|
| 318 |
+
assert data["status"] == "success"
|
app/tests/test_database.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regression tests for database operations and repositories
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 7 |
+
from typing import Dict, Any, List
|
| 8 |
+
import asyncio
|
| 9 |
+
|
| 10 |
+
class TestMongoDBOperations:
|
| 11 |
+
"""Test MongoDB database operations"""
|
| 12 |
+
|
| 13 |
+
@pytest.mark.asyncio
|
| 14 |
+
async def test_mongodb_connection_health(self):
|
| 15 |
+
"""Test MongoDB connection health check"""
|
| 16 |
+
from app.nosql import check_mongodb_health
|
| 17 |
+
|
| 18 |
+
with patch('app.nosql.db') as mock_db:
|
| 19 |
+
mock_db.command.return_value = {"ok": 1}
|
| 20 |
+
|
| 21 |
+
result = await check_mongodb_health()
|
| 22 |
+
assert result is True
|
| 23 |
+
|
| 24 |
+
@pytest.mark.asyncio
|
| 25 |
+
async def test_mongodb_connection_failure(self):
|
| 26 |
+
"""Test MongoDB connection failure handling"""
|
| 27 |
+
from app.nosql import check_mongodb_health
|
| 28 |
+
|
| 29 |
+
with patch('app.nosql.db') as mock_db:
|
| 30 |
+
mock_db.command.side_effect = Exception("Connection failed")
|
| 31 |
+
|
| 32 |
+
result = await check_mongodb_health()
|
| 33 |
+
assert result is False
|
| 34 |
+
|
| 35 |
+
@pytest.mark.asyncio
|
| 36 |
+
async def test_fetch_documents(self, sample_merchant_data):
|
| 37 |
+
"""Test retrieving documents from database"""
|
| 38 |
+
from app.repositories.db_repository import fetch_documents
|
| 39 |
+
|
| 40 |
+
with patch('app.nosql.db') as mock_db:
|
| 41 |
+
mock_collection = AsyncMock()
|
| 42 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 43 |
+
mock_collection.count_documents.return_value = 1
|
| 44 |
+
mock_collection.find.return_value.skip.return_value.limit.return_value.to_list.return_value = [sample_merchant_data]
|
| 45 |
+
|
| 46 |
+
result = await fetch_documents("merchants", {}, {}, 0, 10)
|
| 47 |
+
|
| 48 |
+
assert result["total"] == 1
|
| 49 |
+
assert len(result["documents"]) == 1
|
| 50 |
+
assert result["documents"][0]["name"] == "Test Hair Salon"
|
| 51 |
+
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_execute_query(self, sample_merchant_data):
|
| 54 |
+
"""Test executing aggregation query"""
|
| 55 |
+
from app.repositories.db_repository import execute_query
|
| 56 |
+
|
| 57 |
+
with patch('app.nosql.db') as mock_db:
|
| 58 |
+
mock_collection = AsyncMock()
|
| 59 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 60 |
+
mock_collection.aggregate.return_value.to_list.return_value = [sample_merchant_data]
|
| 61 |
+
|
| 62 |
+
pipeline = [{"$match": {"_id": "test_merchant_123"}}]
|
| 63 |
+
result = await execute_query("merchants", pipeline, use_optimization=False)
|
| 64 |
+
|
| 65 |
+
assert len(result) == 1
|
| 66 |
+
assert result[0]["name"] == "Test Hair Salon"
|
| 67 |
+
|
| 68 |
+
@pytest.mark.asyncio
|
| 69 |
+
async def test_count_documents(self):
|
| 70 |
+
"""Test counting documents in collection"""
|
| 71 |
+
from app.repositories.db_repository import count_documents
|
| 72 |
+
|
| 73 |
+
with patch('app.nosql.db') as mock_db:
|
| 74 |
+
mock_collection = AsyncMock()
|
| 75 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 76 |
+
mock_collection.count_documents.return_value = 5
|
| 77 |
+
|
| 78 |
+
result = await count_documents("merchants", {"category": "salon"})
|
| 79 |
+
|
| 80 |
+
assert result == 5
|
| 81 |
+
mock_collection.count_documents.assert_called_once_with({"category": "salon"})
|
| 82 |
+
|
| 83 |
+
@pytest.mark.asyncio
|
| 84 |
+
async def test_serialize_mongo_document(self):
|
| 85 |
+
"""Test MongoDB document serialization"""
|
| 86 |
+
from app.repositories.db_repository import serialize_mongo_document
|
| 87 |
+
from bson import ObjectId
|
| 88 |
+
from datetime import datetime
|
| 89 |
+
|
| 90 |
+
test_doc = {
|
| 91 |
+
"_id": ObjectId("507f1f77bcf86cd799439011"),
|
| 92 |
+
"name": "Test Merchant",
|
| 93 |
+
"created_at": datetime(2024, 1, 1, 12, 0, 0),
|
| 94 |
+
"nested": {
|
| 95 |
+
"id": ObjectId("507f1f77bcf86cd799439012"),
|
| 96 |
+
"date": datetime(2024, 1, 2, 12, 0, 0)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
result = serialize_mongo_document(test_doc)
|
| 101 |
+
|
| 102 |
+
assert isinstance(result["_id"], str)
|
| 103 |
+
assert isinstance(result["created_at"], str)
|
| 104 |
+
assert isinstance(result["nested"]["id"], str)
|
| 105 |
+
assert isinstance(result["nested"]["date"], str)
|
| 106 |
+
|
| 107 |
+
class TestRedisOperations:
|
| 108 |
+
"""Test Redis cache operations"""
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_redis_connection_health(self):
|
| 112 |
+
"""Test Redis connection health check"""
|
| 113 |
+
from app.nosql import check_redis_health
|
| 114 |
+
|
| 115 |
+
with patch('app.nosql.get_redis_client') as mock_client:
|
| 116 |
+
mock_redis = AsyncMock()
|
| 117 |
+
mock_client.return_value = mock_redis
|
| 118 |
+
mock_redis.ping.return_value = True
|
| 119 |
+
|
| 120 |
+
result = await check_redis_health()
|
| 121 |
+
assert result is True
|
| 122 |
+
|
| 123 |
+
@pytest.mark.asyncio
|
| 124 |
+
async def test_redis_connection_failure(self):
|
| 125 |
+
"""Test Redis connection failure handling"""
|
| 126 |
+
from app.nosql import check_redis_health
|
| 127 |
+
|
| 128 |
+
with patch('app.nosql.get_redis_client') as mock_client:
|
| 129 |
+
mock_client.side_effect = Exception("Redis connection failed")
|
| 130 |
+
|
| 131 |
+
result = await check_redis_health()
|
| 132 |
+
assert result is False
|
| 133 |
+
|
| 134 |
+
@pytest.mark.asyncio
|
| 135 |
+
async def test_cache_merchant_data(self, sample_merchant_data):
|
| 136 |
+
"""Test caching merchant data in Redis"""
|
| 137 |
+
from app.repositories.cache_repository import cache_merchant_data
|
| 138 |
+
|
| 139 |
+
with patch('app.nosql.get_redis_client') as mock_client:
|
| 140 |
+
mock_redis = AsyncMock()
|
| 141 |
+
mock_client.return_value = mock_redis
|
| 142 |
+
mock_redis.setex.return_value = True
|
| 143 |
+
|
| 144 |
+
result = await cache_merchant_data("test_merchant_123", sample_merchant_data)
|
| 145 |
+
|
| 146 |
+
assert result is True
|
| 147 |
+
mock_redis.setex.assert_called_once()
|
| 148 |
+
|
| 149 |
+
@pytest.mark.asyncio
|
| 150 |
+
async def test_cache_manager_get_or_set(self, sample_merchant_data):
|
| 151 |
+
"""Test cache manager get_or_set functionality"""
|
| 152 |
+
from app.repositories.cache_repository import cache_manager
|
| 153 |
+
import json
|
| 154 |
+
|
| 155 |
+
with patch('app.nosql.redis_client') as mock_redis:
|
| 156 |
+
mock_redis.get.return_value = None # Cache miss
|
| 157 |
+
mock_redis.set.return_value = True
|
| 158 |
+
|
| 159 |
+
async def fetch_func():
|
| 160 |
+
return sample_merchant_data
|
| 161 |
+
|
| 162 |
+
result = await cache_manager.get_or_set_cache("test_key", fetch_func)
|
| 163 |
+
|
| 164 |
+
assert result["name"] == "Test Hair Salon"
|
| 165 |
+
mock_redis.set.assert_called_once()
|
| 166 |
+
|
| 167 |
+
@pytest.mark.asyncio
|
| 168 |
+
async def test_cache_manager_hit(self, sample_merchant_data):
|
| 169 |
+
"""Test cache manager cache hit"""
|
| 170 |
+
from app.repositories.cache_repository import cache_manager
|
| 171 |
+
import json
|
| 172 |
+
|
| 173 |
+
with patch('app.nosql.redis_client') as mock_redis:
|
| 174 |
+
mock_redis.get.return_value = json.dumps(sample_merchant_data)
|
| 175 |
+
|
| 176 |
+
async def fetch_func():
|
| 177 |
+
return {"should": "not_be_called"}
|
| 178 |
+
|
| 179 |
+
result = await cache_manager.get_or_set_cache("test_key", fetch_func)
|
| 180 |
+
|
| 181 |
+
assert result["name"] == "Test Hair Salon"
|
| 182 |
+
mock_redis.get.assert_called_once()
|
| 183 |
+
|
| 184 |
+
@pytest.mark.asyncio
|
| 185 |
+
async def test_cache_manager_invalidate(self):
|
| 186 |
+
"""Test cache invalidation"""
|
| 187 |
+
from app.repositories.cache_repository import cache_manager
|
| 188 |
+
|
| 189 |
+
with patch('app.nosql.redis_client') as mock_redis:
|
| 190 |
+
mock_redis.delete.return_value = 1
|
| 191 |
+
|
| 192 |
+
await cache_manager.invalidate_cache("test_key")
|
| 193 |
+
|
| 194 |
+
mock_redis.delete.assert_called_once()
|
| 195 |
+
|
| 196 |
+
@pytest.mark.asyncio
|
| 197 |
+
async def test_invalidate_cache(self):
|
| 198 |
+
"""Test cache invalidation"""
|
| 199 |
+
from app.repositories.cache_repository import invalidate_cache
|
| 200 |
+
|
| 201 |
+
with patch('app.nosql.get_redis_client') as mock_client:
|
| 202 |
+
mock_redis = AsyncMock()
|
| 203 |
+
mock_client.return_value = mock_redis
|
| 204 |
+
mock_redis.delete.return_value = 1
|
| 205 |
+
|
| 206 |
+
result = await invalidate_cache("test_key")
|
| 207 |
+
|
| 208 |
+
assert result is True
|
| 209 |
+
mock_redis.delete.assert_called_once_with("test_key")
|
| 210 |
+
|
| 211 |
+
class TestDatabaseIndexes:
|
| 212 |
+
"""Test database indexing functionality"""
|
| 213 |
+
|
| 214 |
+
@pytest.mark.asyncio
|
| 215 |
+
async def test_create_geospatial_index(self):
|
| 216 |
+
"""Test creating geospatial index"""
|
| 217 |
+
from app.database.indexes import create_geospatial_index
|
| 218 |
+
|
| 219 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 220 |
+
mock_collection = MagicMock()
|
| 221 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 222 |
+
mock_collection.create_index.return_value = "location_2dsphere"
|
| 223 |
+
|
| 224 |
+
result = await create_geospatial_index()
|
| 225 |
+
|
| 226 |
+
assert result == "location_2dsphere"
|
| 227 |
+
mock_collection.create_index.assert_called_once()
|
| 228 |
+
|
| 229 |
+
@pytest.mark.asyncio
|
| 230 |
+
async def test_create_text_search_index(self):
|
| 231 |
+
"""Test creating text search index"""
|
| 232 |
+
from app.database.indexes import create_text_search_index
|
| 233 |
+
|
| 234 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 235 |
+
mock_collection = MagicMock()
|
| 236 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 237 |
+
mock_collection.create_index.return_value = "text_search_index"
|
| 238 |
+
|
| 239 |
+
result = await create_text_search_index()
|
| 240 |
+
|
| 241 |
+
assert result == "text_search_index"
|
| 242 |
+
mock_collection.create_index.assert_called_once()
|
| 243 |
+
|
| 244 |
+
@pytest.mark.asyncio
|
| 245 |
+
async def test_create_category_index(self):
|
| 246 |
+
"""Test creating category index"""
|
| 247 |
+
from app.database.indexes import create_category_index
|
| 248 |
+
|
| 249 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 250 |
+
mock_collection = MagicMock()
|
| 251 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 252 |
+
mock_collection.create_index.return_value = "category_index"
|
| 253 |
+
|
| 254 |
+
result = await create_category_index()
|
| 255 |
+
|
| 256 |
+
assert result == "category_index"
|
| 257 |
+
mock_collection.create_index.assert_called_once()
|
| 258 |
+
|
| 259 |
+
class TestQueryOptimization:
|
| 260 |
+
"""Test database query optimization"""
|
| 261 |
+
|
| 262 |
+
@pytest.mark.asyncio
|
| 263 |
+
async def test_optimized_merchant_search(self, sample_merchant_data):
|
| 264 |
+
"""Test optimized merchant search query"""
|
| 265 |
+
from app.database.query_optimizer import optimize_search_query
|
| 266 |
+
|
| 267 |
+
search_params = {
|
| 268 |
+
"category": "salon",
|
| 269 |
+
"latitude": 40.7128,
|
| 270 |
+
"longitude": -74.0060,
|
| 271 |
+
"radius": 5000,
|
| 272 |
+
"min_rating": 4.0
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
optimized_query = optimize_search_query(search_params)
|
| 276 |
+
|
| 277 |
+
assert isinstance(optimized_query, dict)
|
| 278 |
+
assert "location" in optimized_query
|
| 279 |
+
assert "category" in optimized_query
|
| 280 |
+
assert "average_rating" in optimized_query
|
| 281 |
+
|
| 282 |
+
@pytest.mark.asyncio
|
| 283 |
+
async def test_query_performance_analysis(self):
|
| 284 |
+
"""Test query performance analysis"""
|
| 285 |
+
from app.database.query_optimizer import analyze_query_performance
|
| 286 |
+
|
| 287 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 288 |
+
mock_collection = MagicMock()
|
| 289 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 290 |
+
|
| 291 |
+
# Mock explain output
|
| 292 |
+
mock_collection.find.return_value.explain.return_value = {
|
| 293 |
+
"executionStats": {
|
| 294 |
+
"totalDocsExamined": 100,
|
| 295 |
+
"totalDocsReturned": 10,
|
| 296 |
+
"executionTimeMillis": 50
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
query = {"category": "salon"}
|
| 301 |
+
result = await analyze_query_performance(query)
|
| 302 |
+
|
| 303 |
+
assert "executionStats" in result
|
| 304 |
+
assert result["executionStats"]["totalDocsExamined"] == 100
|
| 305 |
+
|
| 306 |
+
class TestDatabaseTransactions:
|
| 307 |
+
"""Test database transaction handling"""
|
| 308 |
+
|
| 309 |
+
@pytest.mark.asyncio
|
| 310 |
+
async def test_merchant_creation_transaction(self, sample_merchant_data):
|
| 311 |
+
"""Test merchant creation with transaction"""
|
| 312 |
+
from app.repositories.db_repository import create_merchant_with_transaction
|
| 313 |
+
|
| 314 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 315 |
+
mock_session = AsyncMock()
|
| 316 |
+
mock_client.return_value.start_session.return_value.__aenter__.return_value = mock_session
|
| 317 |
+
mock_collection = MagicMock()
|
| 318 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 319 |
+
mock_collection.insert_one.return_value.inserted_id = "new_merchant_id"
|
| 320 |
+
|
| 321 |
+
result = await create_merchant_with_transaction(sample_merchant_data)
|
| 322 |
+
|
| 323 |
+
assert result == "new_merchant_id"
|
| 324 |
+
mock_session.start_transaction.assert_called_once()
|
| 325 |
+
|
| 326 |
+
@pytest.mark.asyncio
|
| 327 |
+
async def test_transaction_rollback(self, sample_merchant_data):
|
| 328 |
+
"""Test transaction rollback on error"""
|
| 329 |
+
from app.repositories.db_repository import create_merchant_with_transaction
|
| 330 |
+
|
| 331 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 332 |
+
mock_session = AsyncMock()
|
| 333 |
+
mock_client.return_value.start_session.return_value.__aenter__.return_value = mock_session
|
| 334 |
+
mock_collection = MagicMock()
|
| 335 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 336 |
+
mock_collection.insert_one.side_effect = Exception("Insert failed")
|
| 337 |
+
|
| 338 |
+
with pytest.raises(Exception):
|
| 339 |
+
await create_merchant_with_transaction(sample_merchant_data)
|
| 340 |
+
|
| 341 |
+
mock_session.abort_transaction.assert_called_once()
|
| 342 |
+
|
| 343 |
+
class TestDatabasePerformance:
|
| 344 |
+
"""Test database performance characteristics"""
|
| 345 |
+
|
| 346 |
+
@pytest.mark.asyncio
|
| 347 |
+
async def test_concurrent_database_operations(self, sample_merchant_data):
|
| 348 |
+
"""Test concurrent database operations"""
|
| 349 |
+
from app.repositories.db_repository import get_merchant_by_id_from_db
|
| 350 |
+
|
| 351 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 352 |
+
mock_collection = MagicMock()
|
| 353 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 354 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 355 |
+
|
| 356 |
+
# Create multiple concurrent requests
|
| 357 |
+
tasks = [
|
| 358 |
+
get_merchant_by_id_from_db(f"merchant_{i}")
|
| 359 |
+
for i in range(20)
|
| 360 |
+
]
|
| 361 |
+
|
| 362 |
+
results = await asyncio.gather(*tasks)
|
| 363 |
+
|
| 364 |
+
assert len(results) == 20
|
| 365 |
+
assert all(result["name"] == "Test Hair Salon" for result in results)
|
| 366 |
+
|
| 367 |
+
@pytest.mark.asyncio
|
| 368 |
+
async def test_large_result_set_handling(self):
|
| 369 |
+
"""Test handling of large result sets"""
|
| 370 |
+
from app.repositories.db_repository import get_merchants_from_db
|
| 371 |
+
|
| 372 |
+
# Create mock data for large result set
|
| 373 |
+
large_dataset = [{"_id": f"merchant_{i}", "name": f"Merchant {i}"} for i in range(1000)]
|
| 374 |
+
|
| 375 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 376 |
+
mock_collection = MagicMock()
|
| 377 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 378 |
+
mock_collection.find.return_value.limit.return_value.skip.return_value.to_list.return_value = large_dataset[:100]
|
| 379 |
+
|
| 380 |
+
result = await get_merchants_from_db(limit=100, skip=0)
|
| 381 |
+
|
| 382 |
+
assert len(result) == 100
|
| 383 |
+
# Verify pagination was applied
|
| 384 |
+
mock_collection.find.return_value.limit.assert_called_with(100)
|
| 385 |
+
|
| 386 |
+
@pytest.mark.asyncio
|
| 387 |
+
async def test_connection_pool_management(self):
|
| 388 |
+
"""Test database connection pool management"""
|
| 389 |
+
from app.nosql import get_mongodb_client
|
| 390 |
+
|
| 391 |
+
# Test multiple client requests
|
| 392 |
+
clients = []
|
| 393 |
+
for _ in range(10):
|
| 394 |
+
client = get_mongodb_client()
|
| 395 |
+
clients.append(client)
|
| 396 |
+
|
| 397 |
+
# All clients should be the same instance (singleton pattern)
|
| 398 |
+
assert all(client is clients[0] for client in clients)
|
| 399 |
+
|
| 400 |
+
class TestDatabaseErrorHandling:
|
| 401 |
+
"""Test database error handling scenarios"""
|
| 402 |
+
|
| 403 |
+
@pytest.mark.asyncio
|
| 404 |
+
async def test_connection_timeout_handling(self):
|
| 405 |
+
"""Test handling of connection timeouts"""
|
| 406 |
+
from app.repositories.db_repository import get_merchants_from_db
|
| 407 |
+
|
| 408 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 409 |
+
mock_client.side_effect = Exception("Connection timeout")
|
| 410 |
+
|
| 411 |
+
with pytest.raises(Exception) as exc_info:
|
| 412 |
+
await get_merchants_from_db()
|
| 413 |
+
|
| 414 |
+
assert "Connection timeout" in str(exc_info.value)
|
| 415 |
+
|
| 416 |
+
@pytest.mark.asyncio
|
| 417 |
+
async def test_invalid_query_handling(self):
|
| 418 |
+
"""Test handling of invalid queries"""
|
| 419 |
+
from app.repositories.db_repository import search_merchants_in_db
|
| 420 |
+
|
| 421 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 422 |
+
mock_collection = MagicMock()
|
| 423 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 424 |
+
mock_collection.find.side_effect = Exception("Invalid query")
|
| 425 |
+
|
| 426 |
+
with pytest.raises(Exception) as exc_info:
|
| 427 |
+
await search_merchants_in_db(latitude=200, longitude=200) # Invalid coordinates
|
| 428 |
+
|
| 429 |
+
assert "Invalid query" in str(exc_info.value)
|
| 430 |
+
|
| 431 |
+
@pytest.mark.asyncio
|
| 432 |
+
async def test_duplicate_key_error_handling(self, sample_merchant_data):
|
| 433 |
+
"""Test handling of duplicate key errors"""
|
| 434 |
+
from app.repositories.db_repository import create_merchant_in_db
|
| 435 |
+
|
| 436 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 437 |
+
mock_collection = MagicMock()
|
| 438 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 439 |
+
mock_collection.insert_one.side_effect = Exception("Duplicate key error")
|
| 440 |
+
|
| 441 |
+
with pytest.raises(Exception) as exc_info:
|
| 442 |
+
await create_merchant_in_db(sample_merchant_data)
|
| 443 |
+
|
| 444 |
+
assert "Duplicate key error" in str(exc_info.value)
|
app/tests/test_integration.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for end-to-end functionality
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import asyncio
|
| 7 |
+
from unittest.mock import patch, AsyncMock, MagicMock
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
|
| 10 |
+
class TestEndToEndUserJourney:
|
| 11 |
+
"""Test complete user journey scenarios"""
|
| 12 |
+
|
| 13 |
+
@pytest.mark.asyncio
|
| 14 |
+
async def test_complete_search_journey(self, async_client, sample_merchant_data):
|
| 15 |
+
"""Test complete user search journey from query to results"""
|
| 16 |
+
# Mock all dependencies
|
| 17 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 18 |
+
patch('app.services.merchant.search_merchants') as mock_search:
|
| 19 |
+
|
| 20 |
+
# Setup NLP pipeline mock
|
| 21 |
+
mock_nlp.process_query.return_value = {
|
| 22 |
+
"query": "find the best hair salon near me with parking",
|
| 23 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.9},
|
| 24 |
+
"entities": {
|
| 25 |
+
"service_types": ["haircut", "hair styling"],
|
| 26 |
+
"amenities": ["parking"],
|
| 27 |
+
"location_modifiers": ["near me"],
|
| 28 |
+
"quality_indicators": ["best"]
|
| 29 |
+
},
|
| 30 |
+
"similar_services": [("salon", 0.95), ("beauty", 0.8)],
|
| 31 |
+
"search_parameters": {
|
| 32 |
+
"merchant_category": "salon",
|
| 33 |
+
"amenities": ["parking"],
|
| 34 |
+
"radius": 5000,
|
| 35 |
+
"min_rating": 4.0
|
| 36 |
+
},
|
| 37 |
+
"processing_time": 0.234
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Setup merchant search mock
|
| 41 |
+
mock_search.return_value = [sample_merchant_data]
|
| 42 |
+
|
| 43 |
+
# Step 1: User submits natural language query
|
| 44 |
+
nlp_response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 45 |
+
"query": "find the best hair salon near me with parking",
|
| 46 |
+
"latitude": 40.7128,
|
| 47 |
+
"longitude": -74.0060
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
assert nlp_response.status_code == 200
|
| 51 |
+
nlp_data = nlp_response.json()
|
| 52 |
+
assert nlp_data["status"] == "success"
|
| 53 |
+
assert "analysis" in nlp_data
|
| 54 |
+
|
| 55 |
+
# Step 2: Use NLP results to search merchants
|
| 56 |
+
search_params = nlp_data["analysis"]["search_parameters"]
|
| 57 |
+
search_response = await async_client.get("/api/v1/merchants/search", params={
|
| 58 |
+
"latitude": 40.7128,
|
| 59 |
+
"longitude": -74.0060,
|
| 60 |
+
**search_params
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
assert search_response.status_code == 200
|
| 64 |
+
search_data = search_response.json()
|
| 65 |
+
assert len(search_data) == 1
|
| 66 |
+
assert search_data[0]["name"] == "Test Hair Salon"
|
| 67 |
+
assert "parking" in search_data[0]["amenities"]
|
| 68 |
+
|
| 69 |
+
@pytest.mark.asyncio
|
| 70 |
+
async def test_merchant_discovery_flow(self, async_client, sample_merchant_data):
|
| 71 |
+
"""Test merchant discovery and detail retrieval flow"""
|
| 72 |
+
with patch('app.services.merchant.get_merchants') as mock_get_all, \
|
| 73 |
+
patch('app.services.merchant.get_merchant_by_id') as mock_get_by_id:
|
| 74 |
+
|
| 75 |
+
mock_get_all.return_value = [sample_merchant_data]
|
| 76 |
+
mock_get_by_id.return_value = sample_merchant_data
|
| 77 |
+
|
| 78 |
+
# Step 1: Browse all merchants
|
| 79 |
+
browse_response = await async_client.get("/api/v1/merchants/")
|
| 80 |
+
assert browse_response.status_code == 200
|
| 81 |
+
merchants = browse_response.json()
|
| 82 |
+
assert len(merchants) == 1
|
| 83 |
+
|
| 84 |
+
# Step 2: Get detailed information for specific merchant
|
| 85 |
+
merchant_id = merchants[0]["_id"]
|
| 86 |
+
detail_response = await async_client.get(f"/api/v1/merchants/{merchant_id}")
|
| 87 |
+
assert detail_response.status_code == 200
|
| 88 |
+
|
| 89 |
+
merchant_detail = detail_response.json()
|
| 90 |
+
assert merchant_detail["_id"] == merchant_id
|
| 91 |
+
assert merchant_detail["name"] == "Test Hair Salon"
|
| 92 |
+
assert "services" in merchant_detail
|
| 93 |
+
assert "business_hours" in merchant_detail
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_error_recovery_flow(self, async_client):
|
| 97 |
+
"""Test error recovery and graceful degradation"""
|
| 98 |
+
# Test NLP service failure with fallback
|
| 99 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 100 |
+
patch('app.services.helper.process_free_text') as mock_fallback:
|
| 101 |
+
|
| 102 |
+
# NLP service fails
|
| 103 |
+
mock_nlp.process_query.side_effect = Exception("NLP service unavailable")
|
| 104 |
+
|
| 105 |
+
# Fallback service works
|
| 106 |
+
mock_fallback.return_value = {
|
| 107 |
+
"query": "hair salon",
|
| 108 |
+
"extracted_keywords": ["hair", "salon"],
|
| 109 |
+
"suggested_category": "salon"
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
# Should gracefully fall back to basic processing
|
| 113 |
+
response = await async_client.post("/api/v1/helpers/process-text", json={
|
| 114 |
+
"text": "hair salon",
|
| 115 |
+
"latitude": 40.7128,
|
| 116 |
+
"longitude": -74.0060
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
assert response.status_code == 200
|
| 120 |
+
data = response.json()
|
| 121 |
+
assert data["suggested_category"] == "salon"
|
| 122 |
+
|
| 123 |
+
class TestServiceIntegration:
|
| 124 |
+
"""Test integration between different services"""
|
| 125 |
+
|
| 126 |
+
@pytest.mark.asyncio
|
| 127 |
+
async def test_nlp_to_search_integration(self, sample_merchant_data):
|
| 128 |
+
"""Test integration between NLP processing and merchant search"""
|
| 129 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 130 |
+
from app.services.merchant import search_merchants
|
| 131 |
+
|
| 132 |
+
with patch('app.repositories.db_repository.search_merchants_in_db') as mock_db_search:
|
| 133 |
+
mock_db_search.return_value = [sample_merchant_data]
|
| 134 |
+
|
| 135 |
+
# Process query with NLP
|
| 136 |
+
pipeline = AdvancedNLPPipeline()
|
| 137 |
+
nlp_result = await pipeline.process_query("find a hair salon with parking")
|
| 138 |
+
|
| 139 |
+
# Use NLP results for merchant search
|
| 140 |
+
search_params = nlp_result["search_parameters"]
|
| 141 |
+
merchants = await search_merchants(**search_params)
|
| 142 |
+
|
| 143 |
+
assert len(merchants) == 1
|
| 144 |
+
assert merchants[0]["category"] == "salon"
|
| 145 |
+
|
| 146 |
+
@pytest.mark.asyncio
|
| 147 |
+
async def test_cache_integration(self, sample_merchant_data):
|
| 148 |
+
"""Test cache integration across services"""
|
| 149 |
+
from app.services.merchant import get_merchant_by_id
|
| 150 |
+
from app.repositories.cache_repository import cache_merchant_data, get_cached_merchant_data
|
| 151 |
+
|
| 152 |
+
with patch('app.nosql.get_redis_client') as mock_redis_client, \
|
| 153 |
+
patch('app.repositories.db_repository.get_merchant_by_id_from_db') as mock_db:
|
| 154 |
+
|
| 155 |
+
mock_redis = AsyncMock()
|
| 156 |
+
mock_redis_client.return_value = mock_redis
|
| 157 |
+
mock_db.return_value = sample_merchant_data
|
| 158 |
+
|
| 159 |
+
# First call - cache miss, should hit database
|
| 160 |
+
mock_redis.get.return_value = None
|
| 161 |
+
result1 = await get_merchant_by_id("test_merchant_123")
|
| 162 |
+
|
| 163 |
+
# Should cache the result
|
| 164 |
+
mock_redis.setex.assert_called_once()
|
| 165 |
+
|
| 166 |
+
# Second call - cache hit, should not hit database
|
| 167 |
+
mock_redis.get.return_value = '{"_id": "test_merchant_123", "name": "Test Hair Salon"}'
|
| 168 |
+
result2 = await get_merchant_by_id("test_merchant_123")
|
| 169 |
+
|
| 170 |
+
assert result1 is not None
|
| 171 |
+
assert result2 is not None
|
| 172 |
+
|
| 173 |
+
@pytest.mark.asyncio
|
| 174 |
+
async def test_database_to_api_integration(self, sample_merchant_data):
|
| 175 |
+
"""Test integration from database layer to API layer"""
|
| 176 |
+
from app.repositories.db_repository import get_merchants_from_db
|
| 177 |
+
from app.services.merchant import get_merchants
|
| 178 |
+
|
| 179 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 180 |
+
mock_collection = AsyncMock()
|
| 181 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 182 |
+
mock_collection.find.return_value.limit.return_value.skip.return_value.to_list.return_value = [sample_merchant_data]
|
| 183 |
+
|
| 184 |
+
# Database layer
|
| 185 |
+
db_result = await get_merchants_from_db(limit=10, skip=0)
|
| 186 |
+
|
| 187 |
+
# Service layer
|
| 188 |
+
service_result = await get_merchants(limit=10, skip=0)
|
| 189 |
+
|
| 190 |
+
assert len(db_result) == 1
|
| 191 |
+
assert len(service_result) == 1
|
| 192 |
+
assert db_result[0]["name"] == service_result[0]["name"]
|
| 193 |
+
|
| 194 |
+
class TestDataFlowIntegration:
|
| 195 |
+
"""Test data flow through the entire system"""
|
| 196 |
+
|
| 197 |
+
@pytest.mark.asyncio
|
| 198 |
+
async def test_search_data_flow(self, async_client, sample_merchant_data):
|
| 199 |
+
"""Test complete search data flow"""
|
| 200 |
+
with patch('app.nosql.get_mongodb_client') as mock_mongo, \
|
| 201 |
+
patch('app.nosql.get_redis_client') as mock_redis_client:
|
| 202 |
+
|
| 203 |
+
# Setup MongoDB mock
|
| 204 |
+
mock_collection = AsyncMock()
|
| 205 |
+
mock_mongo.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 206 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = [sample_merchant_data]
|
| 207 |
+
|
| 208 |
+
# Setup Redis mock
|
| 209 |
+
mock_redis = AsyncMock()
|
| 210 |
+
mock_redis_client.return_value = mock_redis
|
| 211 |
+
mock_redis.get.return_value = None # Cache miss
|
| 212 |
+
|
| 213 |
+
# Make search request
|
| 214 |
+
response = await async_client.get("/api/v1/merchants/search", params={
|
| 215 |
+
"latitude": 40.7128,
|
| 216 |
+
"longitude": -74.0060,
|
| 217 |
+
"radius": 5000,
|
| 218 |
+
"category": "salon"
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
assert response.status_code == 200
|
| 222 |
+
data = response.json()
|
| 223 |
+
assert len(data) == 1
|
| 224 |
+
assert data[0]["name"] == "Test Hair Salon"
|
| 225 |
+
|
| 226 |
+
# Verify data flowed through all layers
|
| 227 |
+
mock_collection.find.assert_called_once() # Database was queried
|
| 228 |
+
mock_redis.setex.assert_called_once() # Result was cached
|
| 229 |
+
|
| 230 |
+
@pytest.mark.asyncio
|
| 231 |
+
async def test_nlp_processing_data_flow(self, async_client):
|
| 232 |
+
"""Test NLP processing data flow"""
|
| 233 |
+
with patch('app.services.advanced_nlp.IntentClassifier') as mock_intent, \
|
| 234 |
+
patch('app.services.advanced_nlp.BusinessEntityExtractor') as mock_entity, \
|
| 235 |
+
patch('app.services.advanced_nlp.SemanticMatcher') as mock_semantic:
|
| 236 |
+
|
| 237 |
+
# Setup mocks
|
| 238 |
+
mock_intent_instance = MagicMock()
|
| 239 |
+
mock_intent.return_value = mock_intent_instance
|
| 240 |
+
mock_intent_instance.get_primary_intent.return_value = ("SEARCH_SERVICE", 0.9)
|
| 241 |
+
|
| 242 |
+
mock_entity_instance = MagicMock()
|
| 243 |
+
mock_entity.return_value = mock_entity_instance
|
| 244 |
+
mock_entity_instance.extract_entities.return_value = {"service_types": ["haircut"]}
|
| 245 |
+
|
| 246 |
+
mock_semantic_instance = MagicMock()
|
| 247 |
+
mock_semantic.return_value = mock_semantic_instance
|
| 248 |
+
mock_semantic_instance.find_similar_services.return_value = [("salon", 0.9)]
|
| 249 |
+
|
| 250 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 251 |
+
"query": "find a hair salon"
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
assert response.status_code == 200
|
| 255 |
+
data = response.json()
|
| 256 |
+
assert data["status"] == "success"
|
| 257 |
+
|
| 258 |
+
# Verify data flowed through NLP pipeline
|
| 259 |
+
mock_intent_instance.get_primary_intent.assert_called_once()
|
| 260 |
+
mock_entity_instance.extract_entities.assert_called_once()
|
| 261 |
+
mock_semantic_instance.find_similar_services.assert_called_once()
|
| 262 |
+
|
| 263 |
+
class TestConcurrencyIntegration:
|
| 264 |
+
"""Test concurrent operations and race conditions"""
|
| 265 |
+
|
| 266 |
+
@pytest.mark.asyncio
|
| 267 |
+
async def test_concurrent_cache_operations(self, sample_merchant_data):
|
| 268 |
+
"""Test concurrent cache read/write operations"""
|
| 269 |
+
from app.repositories.cache_repository import cache_merchant_data, get_cached_merchant_data
|
| 270 |
+
|
| 271 |
+
with patch('app.nosql.get_redis_client') as mock_redis_client:
|
| 272 |
+
mock_redis = AsyncMock()
|
| 273 |
+
mock_redis_client.return_value = mock_redis
|
| 274 |
+
mock_redis.setex.return_value = True
|
| 275 |
+
mock_redis.get.return_value = '{"_id": "test_merchant_123", "name": "Test Hair Salon"}'
|
| 276 |
+
|
| 277 |
+
# Create concurrent cache operations
|
| 278 |
+
cache_tasks = [
|
| 279 |
+
cache_merchant_data(f"merchant_{i}", sample_merchant_data)
|
| 280 |
+
for i in range(10)
|
| 281 |
+
]
|
| 282 |
+
|
| 283 |
+
read_tasks = [
|
| 284 |
+
get_cached_merchant_data(f"merchant_{i}")
|
| 285 |
+
for i in range(10)
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
# Execute concurrently
|
| 289 |
+
cache_results = await asyncio.gather(*cache_tasks)
|
| 290 |
+
read_results = await asyncio.gather(*read_tasks)
|
| 291 |
+
|
| 292 |
+
assert all(result is True for result in cache_results)
|
| 293 |
+
assert all(result is not None for result in read_results)
|
| 294 |
+
|
| 295 |
+
@pytest.mark.asyncio
|
| 296 |
+
async def test_concurrent_nlp_processing(self):
|
| 297 |
+
"""Test concurrent NLP processing"""
|
| 298 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 299 |
+
|
| 300 |
+
pipeline = AdvancedNLPPipeline()
|
| 301 |
+
queries = [
|
| 302 |
+
"find a hair salon",
|
| 303 |
+
"best spa near me",
|
| 304 |
+
"gym with parking",
|
| 305 |
+
"dental clinic",
|
| 306 |
+
"massage therapy"
|
| 307 |
+
]
|
| 308 |
+
|
| 309 |
+
# Process queries concurrently
|
| 310 |
+
tasks = [pipeline.process_query(query) for query in queries]
|
| 311 |
+
results = await asyncio.gather(*tasks)
|
| 312 |
+
|
| 313 |
+
assert len(results) == 5
|
| 314 |
+
assert all("query" in result for result in results)
|
| 315 |
+
assert all("primary_intent" in result for result in results)
|
| 316 |
+
|
| 317 |
+
@pytest.mark.asyncio
|
| 318 |
+
async def test_concurrent_database_operations(self, sample_merchant_data):
|
| 319 |
+
"""Test concurrent database operations"""
|
| 320 |
+
from app.repositories.db_repository import get_merchant_by_id_from_db, search_merchants_in_db
|
| 321 |
+
|
| 322 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 323 |
+
mock_collection = AsyncMock()
|
| 324 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 325 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 326 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = [sample_merchant_data]
|
| 327 |
+
|
| 328 |
+
# Mix of different database operations
|
| 329 |
+
tasks = []
|
| 330 |
+
|
| 331 |
+
# Add get by ID tasks
|
| 332 |
+
for i in range(5):
|
| 333 |
+
tasks.append(get_merchant_by_id_from_db(f"merchant_{i}"))
|
| 334 |
+
|
| 335 |
+
# Add search tasks
|
| 336 |
+
for i in range(5):
|
| 337 |
+
tasks.append(search_merchants_in_db(
|
| 338 |
+
latitude=40.7128 + (i * 0.01),
|
| 339 |
+
longitude=-74.0060 + (i * 0.01),
|
| 340 |
+
radius=5000
|
| 341 |
+
))
|
| 342 |
+
|
| 343 |
+
results = await asyncio.gather(*tasks)
|
| 344 |
+
|
| 345 |
+
assert len(results) == 10
|
| 346 |
+
# First 5 should be single merchants
|
| 347 |
+
assert all(isinstance(result, dict) for result in results[:5])
|
| 348 |
+
# Last 5 should be lists of merchants
|
| 349 |
+
assert all(isinstance(result, list) for result in results[5:])
|
| 350 |
+
|
| 351 |
+
class TestErrorHandlingIntegration:
|
| 352 |
+
"""Test error handling across integrated components"""
|
| 353 |
+
|
| 354 |
+
@pytest.mark.asyncio
|
| 355 |
+
async def test_database_failure_propagation(self, async_client):
|
| 356 |
+
"""Test how database failures propagate through the system"""
|
| 357 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 358 |
+
mock_client.side_effect = Exception("Database connection failed")
|
| 359 |
+
|
| 360 |
+
response = await async_client.get("/api/v1/merchants/")
|
| 361 |
+
assert response.status_code == 500
|
| 362 |
+
|
| 363 |
+
@pytest.mark.asyncio
|
| 364 |
+
async def test_cache_failure_graceful_degradation(self, async_client, sample_merchant_data):
|
| 365 |
+
"""Test graceful degradation when cache fails"""
|
| 366 |
+
with patch('app.nosql.get_redis_client') as mock_redis_client, \
|
| 367 |
+
patch('app.repositories.db_repository.get_merchants_from_db') as mock_db:
|
| 368 |
+
|
| 369 |
+
# Cache fails
|
| 370 |
+
mock_redis_client.side_effect = Exception("Redis connection failed")
|
| 371 |
+
# Database works
|
| 372 |
+
mock_db.return_value = [sample_merchant_data]
|
| 373 |
+
|
| 374 |
+
response = await async_client.get("/api/v1/merchants/")
|
| 375 |
+
|
| 376 |
+
# Should still work, just without caching
|
| 377 |
+
assert response.status_code == 200
|
| 378 |
+
data = response.json()
|
| 379 |
+
assert len(data) == 1
|
| 380 |
+
|
| 381 |
+
@pytest.mark.asyncio
|
| 382 |
+
async def test_nlp_service_fallback(self, async_client):
|
| 383 |
+
"""Test fallback when NLP service fails"""
|
| 384 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 385 |
+
patch('app.services.helper.process_free_text') as mock_fallback:
|
| 386 |
+
|
| 387 |
+
# NLP service fails
|
| 388 |
+
mock_nlp.process_query.side_effect = Exception("NLP service error")
|
| 389 |
+
|
| 390 |
+
# Fallback service works
|
| 391 |
+
mock_fallback.return_value = {
|
| 392 |
+
"query": "hair salon",
|
| 393 |
+
"extracted_keywords": ["hair", "salon"],
|
| 394 |
+
"suggested_category": "salon"
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
# Try NLP endpoint first (should fail)
|
| 398 |
+
nlp_response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 399 |
+
"query": "hair salon"
|
| 400 |
+
})
|
| 401 |
+
assert nlp_response.status_code == 500
|
| 402 |
+
|
| 403 |
+
# Use fallback endpoint (should work)
|
| 404 |
+
fallback_response = await async_client.post("/api/v1/helpers/process-text", json={
|
| 405 |
+
"text": "hair salon",
|
| 406 |
+
"latitude": 40.7128,
|
| 407 |
+
"longitude": -74.0060
|
| 408 |
+
})
|
| 409 |
+
assert fallback_response.status_code == 200
|
| 410 |
+
|
| 411 |
+
class TestSecurityIntegration:
|
| 412 |
+
"""Test security measures across integrated components"""
|
| 413 |
+
|
| 414 |
+
@pytest.mark.asyncio
|
| 415 |
+
async def test_input_sanitization_flow(self, async_client):
|
| 416 |
+
"""Test input sanitization across the request flow"""
|
| 417 |
+
# Test with potentially malicious input
|
| 418 |
+
malicious_query = "<script>alert('xss')</script>find salon"
|
| 419 |
+
|
| 420 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp:
|
| 421 |
+
mock_nlp.process_query.return_value = {
|
| 422 |
+
"query": "find salon", # Should be sanitized
|
| 423 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 424 |
+
"entities": {},
|
| 425 |
+
"similar_services": [],
|
| 426 |
+
"search_parameters": {},
|
| 427 |
+
"processing_time": 0.1
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 431 |
+
"query": malicious_query
|
| 432 |
+
})
|
| 433 |
+
|
| 434 |
+
assert response.status_code == 200
|
| 435 |
+
data = response.json()
|
| 436 |
+
# Script tags should be removed/sanitized
|
| 437 |
+
assert "<script>" not in data["analysis"]["query"]
|
| 438 |
+
|
| 439 |
+
@pytest.mark.asyncio
|
| 440 |
+
async def test_rate_limiting_integration(self, async_client):
|
| 441 |
+
"""Test rate limiting across endpoints"""
|
| 442 |
+
# This would require actual rate limiting implementation
|
| 443 |
+
# For now, test that multiple requests don't crash the system
|
| 444 |
+
|
| 445 |
+
tasks = [
|
| 446 |
+
async_client.get("/health")
|
| 447 |
+
for _ in range(50)
|
| 448 |
+
]
|
| 449 |
+
|
| 450 |
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
| 451 |
+
|
| 452 |
+
# All requests should either succeed or be rate limited (not crash)
|
| 453 |
+
for response in responses:
|
| 454 |
+
if hasattr(response, 'status_code'):
|
| 455 |
+
assert response.status_code in [200, 429] # OK or Too Many Requests
|
| 456 |
+
|
| 457 |
+
class TestPerformanceIntegration:
|
| 458 |
+
"""Test performance characteristics of integrated system"""
|
| 459 |
+
|
| 460 |
+
@pytest.mark.asyncio
|
| 461 |
+
async def test_end_to_end_performance(self, async_client, sample_merchant_data):
|
| 462 |
+
"""Test end-to-end performance of complete user journey"""
|
| 463 |
+
import time
|
| 464 |
+
|
| 465 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 466 |
+
patch('app.services.merchant.search_merchants') as mock_search:
|
| 467 |
+
|
| 468 |
+
mock_nlp.process_query.return_value = {
|
| 469 |
+
"query": "find salon",
|
| 470 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 471 |
+
"entities": {"service_types": ["haircut"]},
|
| 472 |
+
"similar_services": [("salon", 0.9)],
|
| 473 |
+
"search_parameters": {"merchant_category": "salon"},
|
| 474 |
+
"processing_time": 0.1
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
mock_search.return_value = [sample_merchant_data]
|
| 478 |
+
|
| 479 |
+
start_time = time.time()
|
| 480 |
+
|
| 481 |
+
# Step 1: NLP processing
|
| 482 |
+
nlp_response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 483 |
+
"query": "find a hair salon"
|
| 484 |
+
})
|
| 485 |
+
|
| 486 |
+
# Step 2: Merchant search
|
| 487 |
+
search_response = await async_client.get("/api/v1/merchants/search", params={
|
| 488 |
+
"category": "salon"
|
| 489 |
+
})
|
| 490 |
+
|
| 491 |
+
total_time = time.time() - start_time
|
| 492 |
+
|
| 493 |
+
assert nlp_response.status_code == 200
|
| 494 |
+
assert search_response.status_code == 200
|
| 495 |
+
assert total_time < 3.0 # Complete journey within 3 seconds
|
app/tests/test_performance.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Performance regression tests for the application
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import time
|
| 7 |
+
import asyncio
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
from unittest.mock import patch, AsyncMock
|
| 10 |
+
import statistics
|
| 11 |
+
|
| 12 |
+
class TestAPIPerformance:
|
| 13 |
+
"""Test API endpoint performance"""
|
| 14 |
+
|
| 15 |
+
@pytest.mark.asyncio
|
| 16 |
+
async def test_health_endpoint_response_time(self, async_client):
|
| 17 |
+
"""Test health endpoint response time"""
|
| 18 |
+
start_time = time.time()
|
| 19 |
+
response = await async_client.get("/health")
|
| 20 |
+
response_time = time.time() - start_time
|
| 21 |
+
|
| 22 |
+
assert response.status_code == 200
|
| 23 |
+
assert response_time < 0.1 # Should respond within 100ms
|
| 24 |
+
|
| 25 |
+
@pytest.mark.asyncio
|
| 26 |
+
async def test_merchant_search_performance(self, async_client, sample_merchant_data):
|
| 27 |
+
"""Test merchant search endpoint performance"""
|
| 28 |
+
with patch('app.services.merchant.search_merchants') as mock_search:
|
| 29 |
+
mock_search.return_value = [sample_merchant_data] * 10
|
| 30 |
+
|
| 31 |
+
start_time = time.time()
|
| 32 |
+
response = await async_client.get("/api/v1/merchants/search", params={
|
| 33 |
+
"latitude": 40.7128,
|
| 34 |
+
"longitude": -74.0060,
|
| 35 |
+
"radius": 5000,
|
| 36 |
+
"category": "salon"
|
| 37 |
+
})
|
| 38 |
+
response_time = time.time() - start_time
|
| 39 |
+
|
| 40 |
+
assert response.status_code == 200
|
| 41 |
+
assert response_time < 1.0 # Should respond within 1 second
|
| 42 |
+
assert len(response.json()) == 10
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_nlp_processing_performance(self, async_client, mock_nlp_pipeline):
|
| 46 |
+
"""Test NLP processing endpoint performance"""
|
| 47 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_pipeline:
|
| 48 |
+
mock_pipeline.process_query = mock_nlp_pipeline.process_query
|
| 49 |
+
|
| 50 |
+
start_time = time.time()
|
| 51 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 52 |
+
"query": "find the best hair salon near me with parking"
|
| 53 |
+
})
|
| 54 |
+
response_time = time.time() - start_time
|
| 55 |
+
|
| 56 |
+
assert response.status_code == 200
|
| 57 |
+
assert response_time < 2.0 # Should respond within 2 seconds
|
| 58 |
+
|
| 59 |
+
@pytest.mark.asyncio
|
| 60 |
+
async def test_concurrent_api_requests(self, async_client, sample_merchant_data):
|
| 61 |
+
"""Test concurrent API request handling"""
|
| 62 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 63 |
+
mock_get.return_value = [sample_merchant_data] * 5
|
| 64 |
+
|
| 65 |
+
# Create 20 concurrent requests
|
| 66 |
+
tasks = [
|
| 67 |
+
async_client.get("/api/v1/merchants/")
|
| 68 |
+
for _ in range(20)
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
start_time = time.time()
|
| 72 |
+
responses = await asyncio.gather(*tasks)
|
| 73 |
+
total_time = time.time() - start_time
|
| 74 |
+
|
| 75 |
+
# All requests should succeed
|
| 76 |
+
assert all(r.status_code == 200 for r in responses)
|
| 77 |
+
# Should handle concurrent requests efficiently
|
| 78 |
+
assert total_time < 5.0 # Within 5 seconds for 20 requests
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_large_result_set_performance(self, async_client):
|
| 82 |
+
"""Test performance with large result sets"""
|
| 83 |
+
# Mock large dataset
|
| 84 |
+
large_dataset = [
|
| 85 |
+
{
|
| 86 |
+
"_id": f"merchant_{i}",
|
| 87 |
+
"name": f"Merchant {i}",
|
| 88 |
+
"category": "salon",
|
| 89 |
+
"location": {"type": "Point", "coordinates": [-74.0060, 40.7128]}
|
| 90 |
+
}
|
| 91 |
+
for i in range(100)
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 95 |
+
mock_get.return_value = large_dataset
|
| 96 |
+
|
| 97 |
+
start_time = time.time()
|
| 98 |
+
response = await async_client.get("/api/v1/merchants/", params={"limit": 100})
|
| 99 |
+
response_time = time.time() - start_time
|
| 100 |
+
|
| 101 |
+
assert response.status_code == 200
|
| 102 |
+
assert len(response.json()) == 100
|
| 103 |
+
assert response_time < 2.0 # Should handle large datasets efficiently
|
| 104 |
+
|
| 105 |
+
class TestDatabasePerformance:
|
| 106 |
+
"""Test database operation performance"""
|
| 107 |
+
|
| 108 |
+
@pytest.mark.asyncio
|
| 109 |
+
async def test_single_merchant_query_performance(self, sample_merchant_data):
|
| 110 |
+
"""Test single merchant query performance"""
|
| 111 |
+
from app.repositories.db_repository import get_merchant_by_id_from_db
|
| 112 |
+
|
| 113 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 114 |
+
mock_collection = AsyncMock()
|
| 115 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 116 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 117 |
+
|
| 118 |
+
start_time = time.time()
|
| 119 |
+
result = await get_merchant_by_id_from_db("test_merchant_123")
|
| 120 |
+
query_time = time.time() - start_time
|
| 121 |
+
|
| 122 |
+
assert result is not None
|
| 123 |
+
assert query_time < 0.1 # Should complete within 100ms
|
| 124 |
+
|
| 125 |
+
@pytest.mark.asyncio
|
| 126 |
+
async def test_geospatial_search_performance(self, sample_merchant_data):
|
| 127 |
+
"""Test geospatial search performance"""
|
| 128 |
+
from app.repositories.db_repository import search_merchants_in_db
|
| 129 |
+
|
| 130 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 131 |
+
mock_collection = AsyncMock()
|
| 132 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 133 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = [sample_merchant_data] * 20
|
| 134 |
+
|
| 135 |
+
start_time = time.time()
|
| 136 |
+
result = await search_merchants_in_db(
|
| 137 |
+
latitude=40.7128,
|
| 138 |
+
longitude=-74.0060,
|
| 139 |
+
radius=5000,
|
| 140 |
+
category="salon"
|
| 141 |
+
)
|
| 142 |
+
query_time = time.time() - start_time
|
| 143 |
+
|
| 144 |
+
assert len(result) == 20
|
| 145 |
+
assert query_time < 0.5 # Should complete within 500ms
|
| 146 |
+
|
| 147 |
+
@pytest.mark.asyncio
|
| 148 |
+
async def test_concurrent_database_queries(self, sample_merchant_data):
|
| 149 |
+
"""Test concurrent database query performance"""
|
| 150 |
+
from app.repositories.db_repository import get_merchant_by_id_from_db
|
| 151 |
+
|
| 152 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 153 |
+
mock_collection = AsyncMock()
|
| 154 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 155 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 156 |
+
|
| 157 |
+
# Create 50 concurrent queries
|
| 158 |
+
tasks = [
|
| 159 |
+
get_merchant_by_id_from_db(f"merchant_{i}")
|
| 160 |
+
for i in range(50)
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
start_time = time.time()
|
| 164 |
+
results = await asyncio.gather(*tasks)
|
| 165 |
+
total_time = time.time() - start_time
|
| 166 |
+
|
| 167 |
+
assert len(results) == 50
|
| 168 |
+
assert all(r is not None for r in results)
|
| 169 |
+
assert total_time < 2.0 # Should handle 50 concurrent queries within 2 seconds
|
| 170 |
+
|
| 171 |
+
@pytest.mark.asyncio
|
| 172 |
+
async def test_cache_performance(self, sample_merchant_data):
|
| 173 |
+
"""Test cache operation performance"""
|
| 174 |
+
from app.repositories.cache_repository import cache_merchant_data, get_cached_merchant_data
|
| 175 |
+
|
| 176 |
+
with patch('app.nosql.get_redis_client') as mock_client:
|
| 177 |
+
mock_redis = AsyncMock()
|
| 178 |
+
mock_client.return_value = mock_redis
|
| 179 |
+
mock_redis.setex.return_value = True
|
| 180 |
+
mock_redis.get.return_value = '{"_id": "test_merchant_123", "name": "Test Hair Salon"}'
|
| 181 |
+
|
| 182 |
+
# Test cache write performance
|
| 183 |
+
start_time = time.time()
|
| 184 |
+
await cache_merchant_data("test_merchant_123", sample_merchant_data)
|
| 185 |
+
cache_write_time = time.time() - start_time
|
| 186 |
+
|
| 187 |
+
# Test cache read performance
|
| 188 |
+
start_time = time.time()
|
| 189 |
+
result = await get_cached_merchant_data("test_merchant_123")
|
| 190 |
+
cache_read_time = time.time() - start_time
|
| 191 |
+
|
| 192 |
+
assert cache_write_time < 0.05 # Cache write within 50ms
|
| 193 |
+
assert cache_read_time < 0.05 # Cache read within 50ms
|
| 194 |
+
assert result is not None
|
| 195 |
+
|
| 196 |
+
class TestNLPPerformance:
|
| 197 |
+
"""Test NLP processing performance"""
|
| 198 |
+
|
| 199 |
+
@pytest.mark.asyncio
|
| 200 |
+
async def test_intent_classification_performance(self):
|
| 201 |
+
"""Test intent classification performance"""
|
| 202 |
+
from app.services.advanced_nlp import IntentClassifier
|
| 203 |
+
|
| 204 |
+
classifier = IntentClassifier()
|
| 205 |
+
test_queries = [
|
| 206 |
+
"find a hair salon",
|
| 207 |
+
"best spa near me",
|
| 208 |
+
"gym with parking",
|
| 209 |
+
"dental clinic open now",
|
| 210 |
+
"massage therapy luxury"
|
| 211 |
+
]
|
| 212 |
+
|
| 213 |
+
start_time = time.time()
|
| 214 |
+
results = [classifier.get_primary_intent(query) for query in test_queries]
|
| 215 |
+
total_time = time.time() - start_time
|
| 216 |
+
|
| 217 |
+
assert len(results) == 5
|
| 218 |
+
assert all(len(result) == 2 for result in results) # (intent, confidence)
|
| 219 |
+
assert total_time < 1.0 # Should classify 5 queries within 1 second
|
| 220 |
+
|
| 221 |
+
@pytest.mark.asyncio
|
| 222 |
+
async def test_entity_extraction_performance(self):
|
| 223 |
+
"""Test entity extraction performance"""
|
| 224 |
+
from app.services.advanced_nlp import BusinessEntityExtractor
|
| 225 |
+
|
| 226 |
+
extractor = BusinessEntityExtractor()
|
| 227 |
+
test_queries = [
|
| 228 |
+
"hair salon with parking near me",
|
| 229 |
+
"luxury spa treatment with wifi",
|
| 230 |
+
"budget-friendly gym open 24/7",
|
| 231 |
+
"pet-friendly grooming service",
|
| 232 |
+
"organic restaurant with outdoor seating"
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
start_time = time.time()
|
| 236 |
+
results = [extractor.extract_entities(query) for query in test_queries]
|
| 237 |
+
total_time = time.time() - start_time
|
| 238 |
+
|
| 239 |
+
assert len(results) == 5
|
| 240 |
+
assert all(isinstance(result, dict) for result in results)
|
| 241 |
+
assert total_time < 2.0 # Should extract entities from 5 queries within 2 seconds
|
| 242 |
+
|
| 243 |
+
@pytest.mark.asyncio
|
| 244 |
+
async def test_semantic_matching_performance(self):
|
| 245 |
+
"""Test semantic matching performance"""
|
| 246 |
+
from app.services.advanced_nlp import SemanticMatcher
|
| 247 |
+
|
| 248 |
+
matcher = SemanticMatcher()
|
| 249 |
+
test_queries = [
|
| 250 |
+
"hair salon",
|
| 251 |
+
"spa treatment",
|
| 252 |
+
"fitness center",
|
| 253 |
+
"dental care",
|
| 254 |
+
"massage therapy"
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
start_time = time.time()
|
| 258 |
+
results = [matcher.find_similar_services(query) for query in test_queries]
|
| 259 |
+
total_time = time.time() - start_time
|
| 260 |
+
|
| 261 |
+
assert len(results) == 5
|
| 262 |
+
assert all(isinstance(result, list) for result in results)
|
| 263 |
+
assert total_time < 1.5 # Should find matches for 5 queries within 1.5 seconds
|
| 264 |
+
|
| 265 |
+
@pytest.mark.asyncio
|
| 266 |
+
async def test_complete_nlp_pipeline_performance(self):
|
| 267 |
+
"""Test complete NLP pipeline performance"""
|
| 268 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 269 |
+
|
| 270 |
+
pipeline = AdvancedNLPPipeline()
|
| 271 |
+
test_queries = [
|
| 272 |
+
"find the best hair salon near me with parking",
|
| 273 |
+
"luxury spa treatment open now",
|
| 274 |
+
"budget-friendly gym with pool",
|
| 275 |
+
"pet-friendly grooming service",
|
| 276 |
+
"organic restaurant with delivery"
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
processing_times = []
|
| 280 |
+
|
| 281 |
+
for query in test_queries:
|
| 282 |
+
start_time = time.time()
|
| 283 |
+
result = await pipeline.process_query(query)
|
| 284 |
+
processing_time = time.time() - start_time
|
| 285 |
+
processing_times.append(processing_time)
|
| 286 |
+
|
| 287 |
+
assert "processing_time" in result
|
| 288 |
+
assert processing_time < 3.0 # Each query within 3 seconds
|
| 289 |
+
|
| 290 |
+
# Calculate statistics
|
| 291 |
+
avg_time = statistics.mean(processing_times)
|
| 292 |
+
max_time = max(processing_times)
|
| 293 |
+
|
| 294 |
+
assert avg_time < 2.0 # Average processing time under 2 seconds
|
| 295 |
+
assert max_time < 3.0 # Maximum processing time under 3 seconds
|
| 296 |
+
|
| 297 |
+
class TestMemoryPerformance:
|
| 298 |
+
"""Test memory usage and performance"""
|
| 299 |
+
|
| 300 |
+
@pytest.mark.asyncio
|
| 301 |
+
async def test_memory_usage_during_processing(self):
|
| 302 |
+
"""Test memory usage during heavy processing"""
|
| 303 |
+
import psutil
|
| 304 |
+
import os
|
| 305 |
+
|
| 306 |
+
process = psutil.Process(os.getpid())
|
| 307 |
+
initial_memory = process.memory_info().rss / 1024 / 1024 # MB
|
| 308 |
+
|
| 309 |
+
# Simulate heavy processing
|
| 310 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 311 |
+
pipeline = AdvancedNLPPipeline()
|
| 312 |
+
|
| 313 |
+
# Process multiple queries
|
| 314 |
+
queries = [f"find service {i}" for i in range(100)]
|
| 315 |
+
tasks = [pipeline.process_query(query) for query in queries]
|
| 316 |
+
await asyncio.gather(*tasks)
|
| 317 |
+
|
| 318 |
+
final_memory = process.memory_info().rss / 1024 / 1024 # MB
|
| 319 |
+
memory_increase = final_memory - initial_memory
|
| 320 |
+
|
| 321 |
+
# Memory increase should be reasonable (less than 100MB for 100 queries)
|
| 322 |
+
assert memory_increase < 100
|
| 323 |
+
|
| 324 |
+
@pytest.mark.asyncio
|
| 325 |
+
async def test_cache_memory_efficiency(self):
|
| 326 |
+
"""Test cache memory efficiency"""
|
| 327 |
+
from app.services.advanced_nlp import AsyncNLPProcessor
|
| 328 |
+
|
| 329 |
+
processor = AsyncNLPProcessor(max_cache_size=100)
|
| 330 |
+
|
| 331 |
+
def dummy_processor(text):
|
| 332 |
+
return {"processed": text}
|
| 333 |
+
|
| 334 |
+
# Fill cache beyond limit
|
| 335 |
+
for i in range(150):
|
| 336 |
+
await processor.process_async(f"query_{i}", dummy_processor)
|
| 337 |
+
|
| 338 |
+
# Cache should not exceed max size
|
| 339 |
+
assert len(processor.cache) <= 100
|
| 340 |
+
|
| 341 |
+
@pytest.mark.asyncio
|
| 342 |
+
async def test_garbage_collection_efficiency(self):
|
| 343 |
+
"""Test garbage collection during processing"""
|
| 344 |
+
import gc
|
| 345 |
+
|
| 346 |
+
# Force garbage collection
|
| 347 |
+
gc.collect()
|
| 348 |
+
initial_objects = len(gc.get_objects())
|
| 349 |
+
|
| 350 |
+
# Create and process many objects
|
| 351 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 352 |
+
pipeline = AdvancedNLPPipeline()
|
| 353 |
+
|
| 354 |
+
for i in range(50):
|
| 355 |
+
await pipeline.process_query(f"test query {i}")
|
| 356 |
+
|
| 357 |
+
# Force garbage collection again
|
| 358 |
+
gc.collect()
|
| 359 |
+
final_objects = len(gc.get_objects())
|
| 360 |
+
|
| 361 |
+
# Object count should not grow excessively
|
| 362 |
+
object_increase = final_objects - initial_objects
|
| 363 |
+
assert object_increase < 1000 # Reasonable object increase
|
| 364 |
+
|
| 365 |
+
class TestLoadTesting:
|
| 366 |
+
"""Load testing scenarios"""
|
| 367 |
+
|
| 368 |
+
@pytest.mark.asyncio
|
| 369 |
+
async def test_sustained_load_performance(self, async_client, sample_merchant_data):
|
| 370 |
+
"""Test performance under sustained load"""
|
| 371 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 372 |
+
mock_get.return_value = [sample_merchant_data] * 10
|
| 373 |
+
|
| 374 |
+
# Simulate sustained load for 30 seconds
|
| 375 |
+
end_time = time.time() + 30
|
| 376 |
+
request_count = 0
|
| 377 |
+
response_times = []
|
| 378 |
+
|
| 379 |
+
while time.time() < end_time:
|
| 380 |
+
start_time = time.time()
|
| 381 |
+
response = await async_client.get("/api/v1/merchants/")
|
| 382 |
+
response_time = time.time() - start_time
|
| 383 |
+
|
| 384 |
+
response_times.append(response_time)
|
| 385 |
+
request_count += 1
|
| 386 |
+
|
| 387 |
+
assert response.status_code == 200
|
| 388 |
+
|
| 389 |
+
# Small delay to prevent overwhelming
|
| 390 |
+
await asyncio.sleep(0.1)
|
| 391 |
+
|
| 392 |
+
# Calculate performance metrics
|
| 393 |
+
avg_response_time = statistics.mean(response_times)
|
| 394 |
+
max_response_time = max(response_times)
|
| 395 |
+
requests_per_second = request_count / 30
|
| 396 |
+
|
| 397 |
+
assert avg_response_time < 1.0 # Average response time under 1 second
|
| 398 |
+
assert max_response_time < 3.0 # Max response time under 3 seconds
|
| 399 |
+
assert requests_per_second > 5 # At least 5 requests per second
|
| 400 |
+
|
| 401 |
+
@pytest.mark.asyncio
|
| 402 |
+
async def test_burst_load_handling(self, async_client, sample_merchant_data):
|
| 403 |
+
"""Test handling of burst load"""
|
| 404 |
+
with patch('app.services.merchant.search_merchants') as mock_search:
|
| 405 |
+
mock_search.return_value = [sample_merchant_data] * 5
|
| 406 |
+
|
| 407 |
+
# Create burst of 100 concurrent requests
|
| 408 |
+
tasks = [
|
| 409 |
+
async_client.get("/api/v1/merchants/search", params={
|
| 410 |
+
"latitude": 40.7128,
|
| 411 |
+
"longitude": -74.0060,
|
| 412 |
+
"radius": 5000
|
| 413 |
+
})
|
| 414 |
+
for _ in range(100)
|
| 415 |
+
]
|
| 416 |
+
|
| 417 |
+
start_time = time.time()
|
| 418 |
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
| 419 |
+
total_time = time.time() - start_time
|
| 420 |
+
|
| 421 |
+
# Count successful responses
|
| 422 |
+
successful_responses = [r for r in responses if hasattr(r, 'status_code') and r.status_code == 200]
|
| 423 |
+
success_rate = len(successful_responses) / len(responses)
|
| 424 |
+
|
| 425 |
+
assert success_rate > 0.95 # At least 95% success rate
|
| 426 |
+
assert total_time < 10.0 # Complete within 10 seconds
|
| 427 |
+
|
| 428 |
+
class TestPerformanceRegression:
|
| 429 |
+
"""Performance regression detection"""
|
| 430 |
+
|
| 431 |
+
@pytest.mark.asyncio
|
| 432 |
+
async def test_api_response_time_regression(self, async_client, performance_test_data):
|
| 433 |
+
"""Test for API response time regression"""
|
| 434 |
+
queries = performance_test_data["queries"]
|
| 435 |
+
max_expected_time = performance_test_data["expected_max_response_time"]
|
| 436 |
+
|
| 437 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_pipeline:
|
| 438 |
+
mock_pipeline.process_query.return_value = {
|
| 439 |
+
"query": "test",
|
| 440 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 441 |
+
"entities": {},
|
| 442 |
+
"similar_services": [],
|
| 443 |
+
"search_parameters": {},
|
| 444 |
+
"processing_time": 0.1
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
response_times = []
|
| 448 |
+
|
| 449 |
+
for query in queries:
|
| 450 |
+
start_time = time.time()
|
| 451 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={"query": query})
|
| 452 |
+
response_time = time.time() - start_time
|
| 453 |
+
response_times.append(response_time)
|
| 454 |
+
|
| 455 |
+
assert response.status_code == 200
|
| 456 |
+
|
| 457 |
+
avg_response_time = statistics.mean(response_times)
|
| 458 |
+
max_response_time = max(response_times)
|
| 459 |
+
|
| 460 |
+
# Check for performance regression
|
| 461 |
+
assert avg_response_time < max_expected_time
|
| 462 |
+
assert max_response_time < max_expected_time * 2 # Allow some variance
|
| 463 |
+
|
| 464 |
+
@pytest.mark.asyncio
|
| 465 |
+
async def test_database_query_performance_regression(self, sample_merchant_data):
|
| 466 |
+
"""Test for database query performance regression"""
|
| 467 |
+
from app.repositories.db_repository import search_merchants_in_db
|
| 468 |
+
|
| 469 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 470 |
+
mock_collection = AsyncMock()
|
| 471 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 472 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = [sample_merchant_data] * 10
|
| 473 |
+
|
| 474 |
+
query_times = []
|
| 475 |
+
|
| 476 |
+
# Test multiple search scenarios
|
| 477 |
+
for i in range(10):
|
| 478 |
+
start_time = time.time()
|
| 479 |
+
await search_merchants_in_db(
|
| 480 |
+
latitude=40.7128 + (i * 0.01),
|
| 481 |
+
longitude=-74.0060 + (i * 0.01),
|
| 482 |
+
radius=5000,
|
| 483 |
+
category="salon"
|
| 484 |
+
)
|
| 485 |
+
query_time = time.time() - start_time
|
| 486 |
+
query_times.append(query_time)
|
| 487 |
+
|
| 488 |
+
avg_query_time = statistics.mean(query_times)
|
| 489 |
+
max_query_time = max(query_times)
|
| 490 |
+
|
| 491 |
+
# Database queries should be fast
|
| 492 |
+
assert avg_query_time < 0.1 # Average under 100ms
|
| 493 |
+
assert max_query_time < 0.2 # Maximum under 200ms
|
app/tests/test_regression_suite.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main regression test suite runner and comprehensive system tests
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import asyncio
|
| 7 |
+
import time
|
| 8 |
+
import statistics
|
| 9 |
+
from typing import Dict, Any, List
|
| 10 |
+
from unittest.mock import patch, AsyncMock
|
| 11 |
+
|
| 12 |
+
class TestRegressionSuite:
|
| 13 |
+
"""Main regression test suite for critical functionality"""
|
| 14 |
+
|
| 15 |
+
@pytest.mark.asyncio
|
| 16 |
+
async def test_critical_path_regression(self, async_client, sample_merchant_data):
|
| 17 |
+
"""Test the most critical user path for regressions"""
|
| 18 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 19 |
+
patch('app.services.merchant.search_merchants') as mock_search:
|
| 20 |
+
|
| 21 |
+
# Setup mocks for critical path
|
| 22 |
+
mock_nlp.process_query.return_value = {
|
| 23 |
+
"query": "find the best hair salon near me",
|
| 24 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.9},
|
| 25 |
+
"entities": {"service_types": ["haircut"], "location_modifiers": ["near me"]},
|
| 26 |
+
"similar_services": [("salon", 0.95)],
|
| 27 |
+
"search_parameters": {"merchant_category": "salon", "radius": 5000},
|
| 28 |
+
"processing_time": 0.15
|
| 29 |
+
}
|
| 30 |
+
mock_search.return_value = [sample_merchant_data]
|
| 31 |
+
|
| 32 |
+
# Critical Path: Health Check -> NLP Processing -> Merchant Search
|
| 33 |
+
|
| 34 |
+
# Step 1: System health
|
| 35 |
+
health_response = await async_client.get("/health")
|
| 36 |
+
assert health_response.status_code == 200
|
| 37 |
+
assert health_response.json()["status"] == "healthy"
|
| 38 |
+
|
| 39 |
+
# Step 2: NLP processing
|
| 40 |
+
nlp_response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 41 |
+
"query": "find the best hair salon near me",
|
| 42 |
+
"latitude": 40.7128,
|
| 43 |
+
"longitude": -74.0060
|
| 44 |
+
})
|
| 45 |
+
assert nlp_response.status_code == 200
|
| 46 |
+
nlp_data = nlp_response.json()
|
| 47 |
+
assert nlp_data["status"] == "success"
|
| 48 |
+
assert "analysis" in nlp_data
|
| 49 |
+
|
| 50 |
+
# Step 3: Merchant search using NLP results
|
| 51 |
+
search_params = nlp_data["analysis"]["search_parameters"]
|
| 52 |
+
search_response = await async_client.get("/api/v1/merchants/search", params={
|
| 53 |
+
"latitude": 40.7128,
|
| 54 |
+
"longitude": -74.0060,
|
| 55 |
+
**search_params
|
| 56 |
+
})
|
| 57 |
+
assert search_response.status_code == 200
|
| 58 |
+
merchants = search_response.json()
|
| 59 |
+
assert len(merchants) >= 1
|
| 60 |
+
assert merchants[0]["name"] == "Test Hair Salon"
|
| 61 |
+
|
| 62 |
+
@pytest.mark.asyncio
|
| 63 |
+
async def test_api_contract_regression(self, async_client):
|
| 64 |
+
"""Test that API contracts haven't changed unexpectedly"""
|
| 65 |
+
# Test health endpoint contract
|
| 66 |
+
health_response = await async_client.get("/health")
|
| 67 |
+
assert health_response.status_code == 200
|
| 68 |
+
health_data = health_response.json()
|
| 69 |
+
|
| 70 |
+
required_health_fields = ["status", "timestamp", "service", "version"]
|
| 71 |
+
for field in required_health_fields:
|
| 72 |
+
assert field in health_data, f"Health endpoint missing required field: {field}"
|
| 73 |
+
|
| 74 |
+
# Test NLP supported intents contract
|
| 75 |
+
intents_response = await async_client.get("/api/v1/nlp/supported-intents")
|
| 76 |
+
assert intents_response.status_code == 200
|
| 77 |
+
intents_data = intents_response.json()
|
| 78 |
+
|
| 79 |
+
assert "status" in intents_data
|
| 80 |
+
assert "supported_intents" in intents_data
|
| 81 |
+
assert intents_data["status"] == "success"
|
| 82 |
+
|
| 83 |
+
# Verify expected intents are still supported
|
| 84 |
+
expected_intents = ["SEARCH_SERVICE", "FILTER_QUALITY", "FILTER_LOCATION"]
|
| 85 |
+
for intent in expected_intents:
|
| 86 |
+
assert intent in intents_data["supported_intents"]
|
| 87 |
+
|
| 88 |
+
@pytest.mark.asyncio
|
| 89 |
+
async def test_performance_regression_baseline(self, async_client, performance_test_data):
|
| 90 |
+
"""Test for performance regressions against baseline"""
|
| 91 |
+
queries = performance_test_data["queries"][:5] # Test subset for speed
|
| 92 |
+
max_expected_time = performance_test_data["expected_max_response_time"]
|
| 93 |
+
|
| 94 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp:
|
| 95 |
+
mock_nlp.process_query.return_value = {
|
| 96 |
+
"query": "test",
|
| 97 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 98 |
+
"entities": {},
|
| 99 |
+
"similar_services": [],
|
| 100 |
+
"search_parameters": {},
|
| 101 |
+
"processing_time": 0.1
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
response_times = []
|
| 105 |
+
|
| 106 |
+
for query in queries:
|
| 107 |
+
start_time = time.time()
|
| 108 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={"query": query})
|
| 109 |
+
response_time = time.time() - start_time
|
| 110 |
+
response_times.append(response_time)
|
| 111 |
+
|
| 112 |
+
assert response.status_code == 200
|
| 113 |
+
|
| 114 |
+
avg_response_time = statistics.mean(response_times)
|
| 115 |
+
p95_response_time = sorted(response_times)[int(len(response_times) * 0.95)]
|
| 116 |
+
|
| 117 |
+
# Performance regression checks
|
| 118 |
+
assert avg_response_time < max_expected_time, f"Average response time {avg_response_time:.3f}s exceeds baseline {max_expected_time}s"
|
| 119 |
+
assert p95_response_time < max_expected_time * 1.5, f"P95 response time {p95_response_time:.3f}s exceeds acceptable threshold"
|
| 120 |
+
|
| 121 |
+
@pytest.mark.asyncio
|
| 122 |
+
async def test_error_handling_regression(self, async_client):
|
| 123 |
+
"""Test that error handling hasn't regressed"""
|
| 124 |
+
# Test various error scenarios
|
| 125 |
+
error_scenarios = [
|
| 126 |
+
# Invalid coordinates
|
| 127 |
+
{
|
| 128 |
+
"endpoint": "/api/v1/merchants/search",
|
| 129 |
+
"params": {"latitude": 999, "longitude": 999},
|
| 130 |
+
"expected_codes": [400, 422]
|
| 131 |
+
},
|
| 132 |
+
# Empty NLP query
|
| 133 |
+
{
|
| 134 |
+
"endpoint": "/api/v1/nlp/analyze-query",
|
| 135 |
+
"params": {"query": ""},
|
| 136 |
+
"expected_codes": [400, 422]
|
| 137 |
+
},
|
| 138 |
+
# Non-existent merchant
|
| 139 |
+
{
|
| 140 |
+
"endpoint": "/api/v1/merchants/nonexistent_id",
|
| 141 |
+
"params": {},
|
| 142 |
+
"expected_codes": [404]
|
| 143 |
+
},
|
| 144 |
+
# Invalid endpoint
|
| 145 |
+
{
|
| 146 |
+
"endpoint": "/api/v1/invalid-endpoint",
|
| 147 |
+
"params": {},
|
| 148 |
+
"expected_codes": [404]
|
| 149 |
+
}
|
| 150 |
+
]
|
| 151 |
+
|
| 152 |
+
for scenario in error_scenarios:
|
| 153 |
+
if scenario["endpoint"].endswith("analyze-query"):
|
| 154 |
+
response = await async_client.post(scenario["endpoint"], params=scenario["params"])
|
| 155 |
+
else:
|
| 156 |
+
response = await async_client.get(scenario["endpoint"], params=scenario["params"])
|
| 157 |
+
|
| 158 |
+
assert response.status_code in scenario["expected_codes"], \
|
| 159 |
+
f"Endpoint {scenario['endpoint']} returned {response.status_code}, expected one of {scenario['expected_codes']}"
|
| 160 |
+
|
| 161 |
+
@pytest.mark.asyncio
|
| 162 |
+
async def test_data_consistency_regression(self, async_client, sample_merchant_data):
|
| 163 |
+
"""Test data consistency across different endpoints"""
|
| 164 |
+
with patch('app.services.merchant.get_merchant_by_id') as mock_get_by_id, \
|
| 165 |
+
patch('app.services.merchant.search_merchants') as mock_search:
|
| 166 |
+
|
| 167 |
+
mock_get_by_id.return_value = sample_merchant_data
|
| 168 |
+
mock_search.return_value = [sample_merchant_data]
|
| 169 |
+
|
| 170 |
+
# Get merchant by ID
|
| 171 |
+
merchant_response = await async_client.get(f"/api/v1/merchants/{sample_merchant_data['_id']}")
|
| 172 |
+
assert merchant_response.status_code == 200
|
| 173 |
+
merchant_data = merchant_response.json()
|
| 174 |
+
|
| 175 |
+
# Search for the same merchant
|
| 176 |
+
search_response = await async_client.get("/api/v1/merchants/search", params={
|
| 177 |
+
"category": sample_merchant_data["category"],
|
| 178 |
+
"latitude": 40.7128,
|
| 179 |
+
"longitude": -74.0060
|
| 180 |
+
})
|
| 181 |
+
assert search_response.status_code == 200
|
| 182 |
+
search_results = search_response.json()
|
| 183 |
+
|
| 184 |
+
# Data should be consistent
|
| 185 |
+
assert len(search_results) >= 1
|
| 186 |
+
found_merchant = next((m for m in search_results if m["_id"] == sample_merchant_data["_id"]), None)
|
| 187 |
+
assert found_merchant is not None
|
| 188 |
+
|
| 189 |
+
# Key fields should match
|
| 190 |
+
key_fields = ["_id", "name", "category", "average_rating"]
|
| 191 |
+
for field in key_fields:
|
| 192 |
+
assert merchant_data[field] == found_merchant[field], f"Field {field} inconsistent between endpoints"
|
| 193 |
+
|
| 194 |
+
class TestSystemStability:
|
| 195 |
+
"""Test system stability under various conditions"""
|
| 196 |
+
|
| 197 |
+
@pytest.mark.asyncio
|
| 198 |
+
async def test_concurrent_load_stability(self, async_client, sample_merchant_data):
|
| 199 |
+
"""Test system stability under concurrent load"""
|
| 200 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 201 |
+
mock_get.return_value = [sample_merchant_data] * 10
|
| 202 |
+
|
| 203 |
+
# Create concurrent requests
|
| 204 |
+
concurrent_requests = 50
|
| 205 |
+
tasks = [
|
| 206 |
+
async_client.get("/api/v1/merchants/")
|
| 207 |
+
for _ in range(concurrent_requests)
|
| 208 |
+
]
|
| 209 |
+
|
| 210 |
+
# Execute all requests concurrently
|
| 211 |
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
| 212 |
+
|
| 213 |
+
# Analyze results
|
| 214 |
+
successful_responses = 0
|
| 215 |
+
error_responses = 0
|
| 216 |
+
exceptions = 0
|
| 217 |
+
|
| 218 |
+
for response in responses:
|
| 219 |
+
if isinstance(response, Exception):
|
| 220 |
+
exceptions += 1
|
| 221 |
+
elif hasattr(response, 'status_code'):
|
| 222 |
+
if response.status_code == 200:
|
| 223 |
+
successful_responses += 1
|
| 224 |
+
else:
|
| 225 |
+
error_responses += 1
|
| 226 |
+
else:
|
| 227 |
+
exceptions += 1
|
| 228 |
+
|
| 229 |
+
# System should handle concurrent load gracefully
|
| 230 |
+
success_rate = successful_responses / concurrent_requests
|
| 231 |
+
assert success_rate >= 0.9, f"Success rate {success_rate:.2%} below acceptable threshold"
|
| 232 |
+
assert exceptions == 0, f"Got {exceptions} exceptions during concurrent load test"
|
| 233 |
+
|
| 234 |
+
@pytest.mark.asyncio
|
| 235 |
+
async def test_memory_leak_detection(self, async_client):
|
| 236 |
+
"""Test for potential memory leaks during repeated operations"""
|
| 237 |
+
import psutil
|
| 238 |
+
import os
|
| 239 |
+
|
| 240 |
+
process = psutil.Process(os.getpid())
|
| 241 |
+
initial_memory = process.memory_info().rss / 1024 / 1024 # MB
|
| 242 |
+
|
| 243 |
+
# Perform many operations
|
| 244 |
+
for i in range(100):
|
| 245 |
+
await async_client.get("/health")
|
| 246 |
+
|
| 247 |
+
# Occasionally check memory growth
|
| 248 |
+
if i % 20 == 0:
|
| 249 |
+
current_memory = process.memory_info().rss / 1024 / 1024 # MB
|
| 250 |
+
memory_growth = current_memory - initial_memory
|
| 251 |
+
|
| 252 |
+
# Memory growth should be reasonable
|
| 253 |
+
assert memory_growth < 50, f"Excessive memory growth: {memory_growth:.2f}MB after {i} requests"
|
| 254 |
+
|
| 255 |
+
final_memory = process.memory_info().rss / 1024 / 1024 # MB
|
| 256 |
+
total_growth = final_memory - initial_memory
|
| 257 |
+
|
| 258 |
+
# Total memory growth should be reasonable
|
| 259 |
+
assert total_growth < 100, f"Total memory growth {total_growth:.2f}MB suggests potential memory leak"
|
| 260 |
+
|
| 261 |
+
@pytest.mark.asyncio
|
| 262 |
+
async def test_database_connection_resilience(self, async_client, sample_merchant_data):
|
| 263 |
+
"""Test resilience to database connection issues"""
|
| 264 |
+
with patch('app.nosql.get_mongodb_client') as mock_mongo_client:
|
| 265 |
+
# Simulate intermittent database failures
|
| 266 |
+
call_count = 0
|
| 267 |
+
|
| 268 |
+
def side_effect(*args, **kwargs):
|
| 269 |
+
nonlocal call_count
|
| 270 |
+
call_count += 1
|
| 271 |
+
if call_count % 3 == 0: # Fail every 3rd call
|
| 272 |
+
raise Exception("Database connection timeout")
|
| 273 |
+
|
| 274 |
+
# Return mock for successful calls
|
| 275 |
+
mock_client = AsyncMock()
|
| 276 |
+
mock_collection = AsyncMock()
|
| 277 |
+
mock_client.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 278 |
+
mock_collection.find.return_value.limit.return_value.skip.return_value.to_list.return_value = [sample_merchant_data]
|
| 279 |
+
return mock_client
|
| 280 |
+
|
| 281 |
+
mock_mongo_client.side_effect = side_effect
|
| 282 |
+
|
| 283 |
+
# Make multiple requests
|
| 284 |
+
success_count = 0
|
| 285 |
+
error_count = 0
|
| 286 |
+
|
| 287 |
+
for _ in range(10):
|
| 288 |
+
response = await async_client.get("/api/v1/merchants/")
|
| 289 |
+
if response.status_code == 200:
|
| 290 |
+
success_count += 1
|
| 291 |
+
else:
|
| 292 |
+
error_count += 1
|
| 293 |
+
|
| 294 |
+
# System should handle some database failures gracefully
|
| 295 |
+
assert success_count > 0, "No successful requests despite intermittent failures"
|
| 296 |
+
# Some failures are expected due to simulated database issues
|
| 297 |
+
|
| 298 |
+
class TestBackwardCompatibility:
|
| 299 |
+
"""Test backward compatibility of API changes"""
|
| 300 |
+
|
| 301 |
+
@pytest.mark.asyncio
|
| 302 |
+
async def test_api_version_compatibility(self, async_client):
|
| 303 |
+
"""Test that API versions remain compatible"""
|
| 304 |
+
# Test v1 API endpoints still work
|
| 305 |
+
v1_endpoints = [
|
| 306 |
+
"/api/v1/merchants/",
|
| 307 |
+
"/api/v1/nlp/supported-intents",
|
| 308 |
+
"/api/v1/nlp/supported-entities"
|
| 309 |
+
]
|
| 310 |
+
|
| 311 |
+
for endpoint in v1_endpoints:
|
| 312 |
+
response = await async_client.get(endpoint)
|
| 313 |
+
assert response.status_code == 200, f"v1 endpoint {endpoint} is not accessible"
|
| 314 |
+
|
| 315 |
+
@pytest.mark.asyncio
|
| 316 |
+
async def test_response_format_compatibility(self, async_client, sample_merchant_data):
|
| 317 |
+
"""Test that response formats haven't broken compatibility"""
|
| 318 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 319 |
+
mock_get.return_value = [sample_merchant_data]
|
| 320 |
+
|
| 321 |
+
response = await async_client.get("/api/v1/merchants/")
|
| 322 |
+
assert response.status_code == 200
|
| 323 |
+
|
| 324 |
+
merchants = response.json()
|
| 325 |
+
assert isinstance(merchants, list)
|
| 326 |
+
|
| 327 |
+
if merchants:
|
| 328 |
+
merchant = merchants[0]
|
| 329 |
+
# Check that essential fields are still present
|
| 330 |
+
essential_fields = ["_id", "name", "category", "location"]
|
| 331 |
+
for field in essential_fields:
|
| 332 |
+
assert field in merchant, f"Essential field {field} missing from merchant response"
|
| 333 |
+
|
| 334 |
+
@pytest.mark.asyncio
|
| 335 |
+
async def test_parameter_compatibility(self, async_client):
|
| 336 |
+
"""Test that API parameters remain compatible"""
|
| 337 |
+
# Test that old parameter names still work
|
| 338 |
+
response = await async_client.get("/api/v1/merchants/search", params={
|
| 339 |
+
"latitude": 40.7128,
|
| 340 |
+
"longitude": -74.0060,
|
| 341 |
+
"radius": 5000,
|
| 342 |
+
"category": "salon"
|
| 343 |
+
})
|
| 344 |
+
|
| 345 |
+
# Should accept these parameters without error
|
| 346 |
+
assert response.status_code in [200, 400] # 400 might be due to mocking, but not 422 (validation error)
|
| 347 |
+
|
| 348 |
+
class TestDataIntegrity:
|
| 349 |
+
"""Test data integrity across the system"""
|
| 350 |
+
|
| 351 |
+
@pytest.mark.asyncio
|
| 352 |
+
async def test_search_result_integrity(self, async_client, sample_merchant_data):
|
| 353 |
+
"""Test integrity of search results"""
|
| 354 |
+
with patch('app.services.merchant.search_merchants') as mock_search:
|
| 355 |
+
mock_search.return_value = [sample_merchant_data]
|
| 356 |
+
|
| 357 |
+
response = await async_client.get("/api/v1/merchants/search", params={
|
| 358 |
+
"latitude": 40.7128,
|
| 359 |
+
"longitude": -74.0060,
|
| 360 |
+
"radius": 5000,
|
| 361 |
+
"category": "salon"
|
| 362 |
+
})
|
| 363 |
+
|
| 364 |
+
assert response.status_code == 200
|
| 365 |
+
merchants = response.json()
|
| 366 |
+
|
| 367 |
+
for merchant in merchants:
|
| 368 |
+
# Verify data integrity
|
| 369 |
+
assert "_id" in merchant and merchant["_id"]
|
| 370 |
+
assert "name" in merchant and merchant["name"]
|
| 371 |
+
assert "category" in merchant and merchant["category"]
|
| 372 |
+
|
| 373 |
+
# Verify location data integrity
|
| 374 |
+
if "location" in merchant:
|
| 375 |
+
location = merchant["location"]
|
| 376 |
+
assert "type" in location
|
| 377 |
+
assert "coordinates" in location
|
| 378 |
+
assert len(location["coordinates"]) == 2
|
| 379 |
+
|
| 380 |
+
# Coordinates should be valid
|
| 381 |
+
lng, lat = location["coordinates"]
|
| 382 |
+
assert -180 <= lng <= 180, f"Invalid longitude: {lng}"
|
| 383 |
+
assert -90 <= lat <= 90, f"Invalid latitude: {lat}"
|
| 384 |
+
|
| 385 |
+
@pytest.mark.asyncio
|
| 386 |
+
async def test_nlp_result_integrity(self, async_client):
|
| 387 |
+
"""Test integrity of NLP processing results"""
|
| 388 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp:
|
| 389 |
+
mock_nlp.process_query.return_value = {
|
| 390 |
+
"query": "find a hair salon",
|
| 391 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.85},
|
| 392 |
+
"entities": {"service_types": ["haircut"]},
|
| 393 |
+
"similar_services": [("salon", 0.9)],
|
| 394 |
+
"search_parameters": {"merchant_category": "salon"},
|
| 395 |
+
"processing_time": 0.123
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
response = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 399 |
+
"query": "find a hair salon"
|
| 400 |
+
})
|
| 401 |
+
|
| 402 |
+
assert response.status_code == 200
|
| 403 |
+
data = response.json()
|
| 404 |
+
|
| 405 |
+
# Verify NLP result integrity
|
| 406 |
+
assert data["status"] == "success"
|
| 407 |
+
assert "analysis" in data
|
| 408 |
+
|
| 409 |
+
analysis = data["analysis"]
|
| 410 |
+
assert "query" in analysis
|
| 411 |
+
assert "primary_intent" in analysis
|
| 412 |
+
assert "entities" in analysis
|
| 413 |
+
assert "similar_services" in analysis
|
| 414 |
+
assert "search_parameters" in analysis
|
| 415 |
+
assert "processing_time" in analysis
|
| 416 |
+
|
| 417 |
+
# Verify confidence scores are valid
|
| 418 |
+
if "confidence" in analysis["primary_intent"]:
|
| 419 |
+
confidence = analysis["primary_intent"]["confidence"]
|
| 420 |
+
assert 0.0 <= confidence <= 1.0, f"Invalid confidence score: {confidence}"
|
| 421 |
+
|
| 422 |
+
class TestSystemRecovery:
|
| 423 |
+
"""Test system recovery from various failure scenarios"""
|
| 424 |
+
|
| 425 |
+
@pytest.mark.asyncio
|
| 426 |
+
async def test_recovery_from_service_failure(self, async_client):
|
| 427 |
+
"""Test recovery when services fail and recover"""
|
| 428 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp:
|
| 429 |
+
# First, service fails
|
| 430 |
+
mock_nlp.process_query.side_effect = Exception("Service temporarily unavailable")
|
| 431 |
+
|
| 432 |
+
response1 = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 433 |
+
"query": "find a salon"
|
| 434 |
+
})
|
| 435 |
+
assert response1.status_code == 500
|
| 436 |
+
|
| 437 |
+
# Then, service recovers
|
| 438 |
+
mock_nlp.process_query.side_effect = None
|
| 439 |
+
mock_nlp.process_query.return_value = {
|
| 440 |
+
"query": "find a salon",
|
| 441 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 442 |
+
"entities": {},
|
| 443 |
+
"similar_services": [],
|
| 444 |
+
"search_parameters": {},
|
| 445 |
+
"processing_time": 0.1
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
response2 = await async_client.post("/api/v1/nlp/analyze-query", params={
|
| 449 |
+
"query": "find a salon"
|
| 450 |
+
})
|
| 451 |
+
assert response2.status_code == 200
|
| 452 |
+
|
| 453 |
+
@pytest.mark.asyncio
|
| 454 |
+
async def test_graceful_degradation(self, async_client, sample_merchant_data):
|
| 455 |
+
"""Test graceful degradation when advanced features fail"""
|
| 456 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp, \
|
| 457 |
+
patch('app.services.helper.process_free_text') as mock_fallback, \
|
| 458 |
+
patch('app.services.merchant.get_merchants') as mock_get_merchants:
|
| 459 |
+
|
| 460 |
+
# Advanced NLP fails
|
| 461 |
+
mock_nlp.process_query.side_effect = Exception("NLP service down")
|
| 462 |
+
|
| 463 |
+
# Fallback service works
|
| 464 |
+
mock_fallback.return_value = {
|
| 465 |
+
"query": "salon",
|
| 466 |
+
"extracted_keywords": ["salon"],
|
| 467 |
+
"suggested_category": "salon"
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
# Basic merchant service works
|
| 471 |
+
mock_get_merchants.return_value = [sample_merchant_data]
|
| 472 |
+
|
| 473 |
+
# Should still be able to get basic functionality
|
| 474 |
+
fallback_response = await async_client.post("/api/v1/helpers/process-text", json={
|
| 475 |
+
"text": "salon",
|
| 476 |
+
"latitude": 40.7128,
|
| 477 |
+
"longitude": -74.0060
|
| 478 |
+
})
|
| 479 |
+
assert fallback_response.status_code == 200
|
| 480 |
+
|
| 481 |
+
merchants_response = await async_client.get("/api/v1/merchants/")
|
| 482 |
+
assert merchants_response.status_code == 200
|
| 483 |
+
|
| 484 |
+
# Performance benchmarks for regression detection
|
| 485 |
+
class TestPerformanceBenchmarks:
|
| 486 |
+
"""Performance benchmarks to detect regressions"""
|
| 487 |
+
|
| 488 |
+
@pytest.mark.asyncio
|
| 489 |
+
async def test_response_time_benchmarks(self, async_client):
|
| 490 |
+
"""Benchmark response times for key endpoints"""
|
| 491 |
+
benchmarks = {
|
| 492 |
+
"/health": 0.1, # 100ms
|
| 493 |
+
"/api/v1/nlp/supported-intents": 0.2, # 200ms
|
| 494 |
+
"/api/v1/nlp/supported-entities": 0.2, # 200ms
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
for endpoint, max_time in benchmarks.items():
|
| 498 |
+
start_time = time.time()
|
| 499 |
+
response = await async_client.get(endpoint)
|
| 500 |
+
response_time = time.time() - start_time
|
| 501 |
+
|
| 502 |
+
assert response.status_code == 200
|
| 503 |
+
assert response_time < max_time, f"Endpoint {endpoint} took {response_time:.3f}s, exceeds benchmark {max_time}s"
|
| 504 |
+
|
| 505 |
+
@pytest.mark.asyncio
|
| 506 |
+
async def test_throughput_benchmarks(self, async_client):
|
| 507 |
+
"""Benchmark throughput for key operations"""
|
| 508 |
+
# Test health endpoint throughput
|
| 509 |
+
start_time = time.time()
|
| 510 |
+
tasks = [async_client.get("/health") for _ in range(20)]
|
| 511 |
+
responses = await asyncio.gather(*tasks)
|
| 512 |
+
total_time = time.time() - start_time
|
| 513 |
+
|
| 514 |
+
# All should succeed
|
| 515 |
+
assert all(r.status_code == 200 for r in responses)
|
| 516 |
+
|
| 517 |
+
# Should handle 20 requests reasonably fast
|
| 518 |
+
requests_per_second = 20 / total_time
|
| 519 |
+
assert requests_per_second > 10, f"Throughput {requests_per_second:.1f} req/s below benchmark"
|
app/tests/test_security.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Security regression tests
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import patch, MagicMock
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
class TestInputValidation:
|
| 10 |
+
"""Test input validation and sanitization"""
|
| 11 |
+
|
| 12 |
+
def test_sql_injection_prevention(self, client: TestClient):
|
| 13 |
+
"""Test SQL injection prevention in merchant search"""
|
| 14 |
+
malicious_input = "'; DROP TABLE merchants; --"
|
| 15 |
+
|
| 16 |
+
response = client.get("/api/v1/merchants/search", params={
|
| 17 |
+
"category": malicious_input,
|
| 18 |
+
"latitude": 40.7128,
|
| 19 |
+
"longitude": -74.0060
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
# Should either sanitize input or return 400
|
| 23 |
+
assert response.status_code in [200, 400]
|
| 24 |
+
if response.status_code == 200:
|
| 25 |
+
# If processed, should not contain malicious SQL
|
| 26 |
+
data = response.json()
|
| 27 |
+
assert isinstance(data, list)
|
| 28 |
+
|
| 29 |
+
def test_xss_prevention_in_nlp_query(self, client: TestClient):
|
| 30 |
+
"""Test XSS prevention in NLP query processing"""
|
| 31 |
+
xss_payload = "<script>alert('xss')</script>find salon"
|
| 32 |
+
|
| 33 |
+
with patch('app.services.advanced_nlp.advanced_nlp_pipeline') as mock_nlp:
|
| 34 |
+
mock_nlp.process_query.return_value = {
|
| 35 |
+
"query": "find salon", # Should be sanitized
|
| 36 |
+
"primary_intent": {"intent": "SEARCH_SERVICE", "confidence": 0.8},
|
| 37 |
+
"entities": {},
|
| 38 |
+
"similar_services": [],
|
| 39 |
+
"search_parameters": {},
|
| 40 |
+
"processing_time": 0.1
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 44 |
+
"query": xss_payload
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
assert response.status_code == 200
|
| 48 |
+
data = response.json()
|
| 49 |
+
# Script tags should be removed/sanitized
|
| 50 |
+
assert "<script>" not in str(data)
|
| 51 |
+
|
| 52 |
+
def test_command_injection_prevention(self, client: TestClient):
|
| 53 |
+
"""Test command injection prevention"""
|
| 54 |
+
command_injection = "; rm -rf /"
|
| 55 |
+
|
| 56 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 57 |
+
"text": f"find salon{command_injection}",
|
| 58 |
+
"latitude": 40.7128,
|
| 59 |
+
"longitude": -74.0060
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
# Should handle malicious input safely
|
| 63 |
+
assert response.status_code in [200, 400]
|
| 64 |
+
|
| 65 |
+
def test_path_traversal_prevention(self, client: TestClient):
|
| 66 |
+
"""Test path traversal prevention in merchant ID"""
|
| 67 |
+
path_traversal = "../../../etc/passwd"
|
| 68 |
+
|
| 69 |
+
response = client.get(f"/api/v1/merchants/{path_traversal}")
|
| 70 |
+
|
| 71 |
+
# Should not allow path traversal
|
| 72 |
+
assert response.status_code in [400, 404]
|
| 73 |
+
|
| 74 |
+
def test_large_payload_handling(self, client: TestClient):
|
| 75 |
+
"""Test handling of excessively large payloads"""
|
| 76 |
+
large_text = "A" * (10 * 1024 * 1024) # 10MB
|
| 77 |
+
|
| 78 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 79 |
+
"text": large_text,
|
| 80 |
+
"latitude": 40.7128,
|
| 81 |
+
"longitude": -74.0060
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
# Should reject or handle large payloads appropriately
|
| 85 |
+
assert response.status_code in [400, 413, 422]
|
| 86 |
+
|
| 87 |
+
def test_invalid_coordinates_handling(self, client: TestClient):
|
| 88 |
+
"""Test handling of invalid coordinates"""
|
| 89 |
+
invalid_coords = [
|
| 90 |
+
(999, 999), # Out of range
|
| 91 |
+
(-999, -999), # Out of range
|
| 92 |
+
("abc", "def"), # Non-numeric
|
| 93 |
+
(None, None) # Null values
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
for lat, lng in invalid_coords:
|
| 97 |
+
response = client.get("/api/v1/merchants/search", params={
|
| 98 |
+
"latitude": lat,
|
| 99 |
+
"longitude": lng,
|
| 100 |
+
"radius": 5000
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
# Should handle invalid coordinates gracefully
|
| 104 |
+
assert response.status_code in [200, 400, 422]
|
| 105 |
+
|
| 106 |
+
class TestAuthentication:
|
| 107 |
+
"""Test authentication mechanisms (if implemented)"""
|
| 108 |
+
|
| 109 |
+
def test_unauthenticated_access_to_public_endpoints(self, client: TestClient):
|
| 110 |
+
"""Test that public endpoints don't require authentication"""
|
| 111 |
+
public_endpoints = [
|
| 112 |
+
"/health",
|
| 113 |
+
"/api/v1/merchants/",
|
| 114 |
+
"/api/v1/merchants/search"
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
for endpoint in public_endpoints:
|
| 118 |
+
response = client.get(endpoint)
|
| 119 |
+
# Should not require authentication
|
| 120 |
+
assert response.status_code != 401
|
| 121 |
+
|
| 122 |
+
def test_api_key_validation(self, client: TestClient):
|
| 123 |
+
"""Test API key validation if implemented"""
|
| 124 |
+
# This test assumes API key authentication might be implemented
|
| 125 |
+
invalid_api_key = "invalid_key_12345"
|
| 126 |
+
|
| 127 |
+
response = client.get("/api/v1/merchants/", headers={
|
| 128 |
+
"X-API-Key": invalid_api_key
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
# Should either ignore invalid key or reject it
|
| 132 |
+
assert response.status_code in [200, 401, 403]
|
| 133 |
+
|
| 134 |
+
class TestAuthorization:
|
| 135 |
+
"""Test authorization and access control"""
|
| 136 |
+
|
| 137 |
+
def test_admin_endpoint_access(self, client: TestClient):
|
| 138 |
+
"""Test access to admin endpoints"""
|
| 139 |
+
# Assuming there might be admin endpoints
|
| 140 |
+
admin_endpoints = [
|
| 141 |
+
"/admin/users",
|
| 142 |
+
"/admin/merchants",
|
| 143 |
+
"/admin/system"
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
for endpoint in admin_endpoints:
|
| 147 |
+
response = client.get(endpoint)
|
| 148 |
+
# Should require proper authorization or not exist
|
| 149 |
+
assert response.status_code in [401, 403, 404]
|
| 150 |
+
|
| 151 |
+
def test_user_data_isolation(self, client: TestClient):
|
| 152 |
+
"""Test that users can only access their own data"""
|
| 153 |
+
# This would be relevant if user-specific data exists
|
| 154 |
+
user_id = "user123"
|
| 155 |
+
other_user_id = "user456"
|
| 156 |
+
|
| 157 |
+
# Try to access another user's data
|
| 158 |
+
response = client.get(f"/api/v1/users/{other_user_id}/data", headers={
|
| 159 |
+
"User-ID": user_id
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
# Should not allow access to other user's data
|
| 163 |
+
assert response.status_code in [401, 403, 404]
|
| 164 |
+
|
| 165 |
+
class TestDataProtection:
|
| 166 |
+
"""Test data protection and privacy"""
|
| 167 |
+
|
| 168 |
+
def test_sensitive_data_not_exposed(self, client: TestClient, sample_merchant_data):
|
| 169 |
+
"""Test that sensitive data is not exposed in API responses"""
|
| 170 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 171 |
+
# Add sensitive data to mock
|
| 172 |
+
merchant_with_sensitive = sample_merchant_data.copy()
|
| 173 |
+
merchant_with_sensitive.update({
|
| 174 |
+
"internal_id": "INTERNAL_123",
|
| 175 |
+
"api_key": "secret_api_key",
|
| 176 |
+
"database_password": "secret_password",
|
| 177 |
+
"private_notes": "Internal business notes"
|
| 178 |
+
})
|
| 179 |
+
mock_get.return_value = [merchant_with_sensitive]
|
| 180 |
+
|
| 181 |
+
response = client.get("/api/v1/merchants/")
|
| 182 |
+
assert response.status_code == 200
|
| 183 |
+
|
| 184 |
+
data = response.json()
|
| 185 |
+
response_text = str(data)
|
| 186 |
+
|
| 187 |
+
# Sensitive fields should not be in response
|
| 188 |
+
sensitive_fields = ["api_key", "database_password", "internal_id"]
|
| 189 |
+
for field in sensitive_fields:
|
| 190 |
+
assert field not in response_text
|
| 191 |
+
|
| 192 |
+
def test_error_message_information_disclosure(self, client: TestClient):
|
| 193 |
+
"""Test that error messages don't disclose sensitive information"""
|
| 194 |
+
with patch('app.services.merchant.get_merchants') as mock_get:
|
| 195 |
+
# Simulate database error with sensitive info
|
| 196 |
+
mock_get.side_effect = Exception("Database connection failed: host=internal-db.company.com, user=admin, password=secret123")
|
| 197 |
+
|
| 198 |
+
response = client.get("/api/v1/merchants/")
|
| 199 |
+
assert response.status_code == 500
|
| 200 |
+
|
| 201 |
+
# Error response should not contain sensitive database info
|
| 202 |
+
error_text = str(response.json())
|
| 203 |
+
sensitive_info = ["password=", "host=internal-db", "user=admin"]
|
| 204 |
+
for info in sensitive_info:
|
| 205 |
+
assert info not in error_text
|
| 206 |
+
|
| 207 |
+
def test_log_sanitization(self, client: TestClient):
|
| 208 |
+
"""Test that logs don't contain sensitive information"""
|
| 209 |
+
# This would require checking actual log output
|
| 210 |
+
# For now, test that endpoints handle sensitive data properly
|
| 211 |
+
|
| 212 |
+
sensitive_query = "my credit card is 4111-1111-1111-1111"
|
| 213 |
+
|
| 214 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 215 |
+
"query": sensitive_query
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
# Should process without exposing sensitive data
|
| 219 |
+
assert response.status_code in [200, 400]
|
| 220 |
+
|
| 221 |
+
class TestCORSAndHeaders:
|
| 222 |
+
"""Test CORS and security headers"""
|
| 223 |
+
|
| 224 |
+
def test_cors_configuration(self, client: TestClient):
|
| 225 |
+
"""Test CORS configuration"""
|
| 226 |
+
# Test preflight request
|
| 227 |
+
response = client.options("/api/v1/merchants/", headers={
|
| 228 |
+
"Origin": "http://localhost:3000",
|
| 229 |
+
"Access-Control-Request-Method": "GET"
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
# Should handle CORS properly
|
| 233 |
+
assert response.status_code in [200, 204]
|
| 234 |
+
|
| 235 |
+
def test_cors_origin_validation(self, client: TestClient):
|
| 236 |
+
"""Test CORS origin validation"""
|
| 237 |
+
# Test with allowed origin
|
| 238 |
+
response = client.get("/api/v1/merchants/", headers={
|
| 239 |
+
"Origin": "http://localhost:3000"
|
| 240 |
+
})
|
| 241 |
+
assert response.status_code == 200
|
| 242 |
+
|
| 243 |
+
# Test with disallowed origin
|
| 244 |
+
response = client.get("/api/v1/merchants/", headers={
|
| 245 |
+
"Origin": "http://malicious-site.com"
|
| 246 |
+
})
|
| 247 |
+
# Should still work but without CORS headers for invalid origin
|
| 248 |
+
assert response.status_code == 200
|
| 249 |
+
|
| 250 |
+
def test_security_headers(self, client: TestClient):
|
| 251 |
+
"""Test security headers in responses"""
|
| 252 |
+
response = client.get("/health")
|
| 253 |
+
|
| 254 |
+
# Check for common security headers
|
| 255 |
+
headers = response.headers
|
| 256 |
+
|
| 257 |
+
# These might not be implemented yet, but good to test for
|
| 258 |
+
security_headers = [
|
| 259 |
+
"X-Content-Type-Options",
|
| 260 |
+
"X-Frame-Options",
|
| 261 |
+
"X-XSS-Protection",
|
| 262 |
+
"Strict-Transport-Security"
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
# At minimum, should not have dangerous headers
|
| 266 |
+
dangerous_headers = [
|
| 267 |
+
"Server", # Should not expose server details
|
| 268 |
+
"X-Powered-By" # Should not expose technology stack
|
| 269 |
+
]
|
| 270 |
+
|
| 271 |
+
for header in dangerous_headers:
|
| 272 |
+
if header in headers:
|
| 273 |
+
# If present, should not contain sensitive info
|
| 274 |
+
assert "internal" not in headers[header].lower()
|
| 275 |
+
assert "secret" not in headers[header].lower()
|
| 276 |
+
|
| 277 |
+
class TestRateLimiting:
|
| 278 |
+
"""Test rate limiting and abuse prevention"""
|
| 279 |
+
|
| 280 |
+
@pytest.mark.asyncio
|
| 281 |
+
async def test_rate_limiting_basic(self, async_client):
|
| 282 |
+
"""Test basic rate limiting functionality"""
|
| 283 |
+
# Make many requests quickly
|
| 284 |
+
responses = []
|
| 285 |
+
for _ in range(100):
|
| 286 |
+
response = await async_client.get("/health")
|
| 287 |
+
responses.append(response)
|
| 288 |
+
|
| 289 |
+
# Should either all succeed or some be rate limited
|
| 290 |
+
status_codes = [r.status_code for r in responses]
|
| 291 |
+
|
| 292 |
+
# All should be either 200 (OK) or 429 (Too Many Requests)
|
| 293 |
+
assert all(code in [200, 429] for code in status_codes)
|
| 294 |
+
|
| 295 |
+
def test_rate_limiting_per_endpoint(self, client: TestClient):
|
| 296 |
+
"""Test rate limiting per endpoint"""
|
| 297 |
+
endpoints = [
|
| 298 |
+
"/health",
|
| 299 |
+
"/api/v1/merchants/",
|
| 300 |
+
"/api/v1/nlp/supported-intents"
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
for endpoint in endpoints:
|
| 304 |
+
# Make multiple requests to each endpoint
|
| 305 |
+
responses = []
|
| 306 |
+
for _ in range(20):
|
| 307 |
+
response = client.get(endpoint)
|
| 308 |
+
responses.append(response)
|
| 309 |
+
|
| 310 |
+
# Should handle multiple requests appropriately
|
| 311 |
+
status_codes = [r.status_code for r in responses]
|
| 312 |
+
assert all(code in [200, 429, 500] for code in status_codes)
|
| 313 |
+
|
| 314 |
+
class TestInputSanitization:
|
| 315 |
+
"""Test comprehensive input sanitization"""
|
| 316 |
+
|
| 317 |
+
def test_html_sanitization(self, client: TestClient):
|
| 318 |
+
"""Test HTML tag sanitization"""
|
| 319 |
+
html_inputs = [
|
| 320 |
+
"<b>bold text</b>",
|
| 321 |
+
"<img src='x' onerror='alert(1)'>",
|
| 322 |
+
"<iframe src='javascript:alert(1)'></iframe>",
|
| 323 |
+
"<<script>alert('xss')</script>script>alert('xss')<</script>/script>"
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
for html_input in html_inputs:
|
| 327 |
+
response = client.post("/api/v1/helpers/process-text", json={
|
| 328 |
+
"text": html_input,
|
| 329 |
+
"latitude": 40.7128,
|
| 330 |
+
"longitude": -74.0060
|
| 331 |
+
})
|
| 332 |
+
|
| 333 |
+
# Should handle HTML input safely
|
| 334 |
+
assert response.status_code in [200, 400]
|
| 335 |
+
if response.status_code == 200:
|
| 336 |
+
# Response should not contain dangerous HTML
|
| 337 |
+
response_text = str(response.json())
|
| 338 |
+
assert "<script>" not in response_text
|
| 339 |
+
assert "javascript:" not in response_text
|
| 340 |
+
|
| 341 |
+
def test_unicode_handling(self, client: TestClient):
|
| 342 |
+
"""Test Unicode and special character handling"""
|
| 343 |
+
unicode_inputs = [
|
| 344 |
+
"cafΓ© rΓ©sumΓ© naΓ―ve", # Accented characters
|
| 345 |
+
"πͺππββοΈ", # Emojis
|
| 346 |
+
"ζ΅θ―δΈζε符", # Chinese characters
|
| 347 |
+
"ΡΠ΅ΡΡ ΠΊΠΈΡΠΈΠ»Π»ΠΈΡΠ°", # Cyrillic
|
| 348 |
+
"\u0000\u0001\u0002", # Control characters
|
| 349 |
+
]
|
| 350 |
+
|
| 351 |
+
for unicode_input in unicode_inputs:
|
| 352 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 353 |
+
"query": unicode_input
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
# Should handle Unicode safely
|
| 357 |
+
assert response.status_code in [200, 400]
|
| 358 |
+
|
| 359 |
+
def test_numeric_input_validation(self, client: TestClient):
|
| 360 |
+
"""Test numeric input validation"""
|
| 361 |
+
invalid_numeric_inputs = [
|
| 362 |
+
("latitude", "not_a_number"),
|
| 363 |
+
("longitude", "infinity"),
|
| 364 |
+
("radius", "-1000"),
|
| 365 |
+
("limit", "999999999999999999999"),
|
| 366 |
+
("skip", "-1")
|
| 367 |
+
]
|
| 368 |
+
|
| 369 |
+
for param, value in invalid_numeric_inputs:
|
| 370 |
+
response = client.get("/api/v1/merchants/search", params={
|
| 371 |
+
param: value,
|
| 372 |
+
"latitude": 40.7128 if param != "latitude" else value,
|
| 373 |
+
"longitude": -74.0060 if param != "longitude" else value
|
| 374 |
+
})
|
| 375 |
+
|
| 376 |
+
# Should validate numeric inputs
|
| 377 |
+
assert response.status_code in [200, 400, 422]
|
| 378 |
+
|
| 379 |
+
class TestDatabaseSecurity:
|
| 380 |
+
"""Test database security measures"""
|
| 381 |
+
|
| 382 |
+
@pytest.mark.asyncio
|
| 383 |
+
async def test_mongodb_injection_prevention(self):
|
| 384 |
+
"""Test MongoDB injection prevention"""
|
| 385 |
+
from app.repositories.db_repository import search_merchants_in_db
|
| 386 |
+
|
| 387 |
+
# MongoDB injection attempts
|
| 388 |
+
injection_attempts = [
|
| 389 |
+
{"$where": "this.name == 'test'"},
|
| 390 |
+
{"$regex": ".*"},
|
| 391 |
+
{"$ne": None}
|
| 392 |
+
]
|
| 393 |
+
|
| 394 |
+
with patch('app.nosql.get_mongodb_client') as mock_client:
|
| 395 |
+
mock_collection = MagicMock()
|
| 396 |
+
mock_client.return_value.__getitem__.return_value.__getitem__.return_value = mock_collection
|
| 397 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = []
|
| 398 |
+
|
| 399 |
+
for injection in injection_attempts:
|
| 400 |
+
try:
|
| 401 |
+
# Should sanitize or reject injection attempts
|
| 402 |
+
await search_merchants_in_db(category=injection)
|
| 403 |
+
# If it doesn't raise an exception, check that query was sanitized
|
| 404 |
+
call_args = mock_collection.find.call_args
|
| 405 |
+
if call_args:
|
| 406 |
+
query = call_args[0][0]
|
| 407 |
+
# Should not contain MongoDB operators in user input
|
| 408 |
+
assert "$where" not in str(query.get("category", ""))
|
| 409 |
+
except (ValueError, TypeError):
|
| 410 |
+
# Expected for invalid input
|
| 411 |
+
pass
|
| 412 |
+
|
| 413 |
+
def test_connection_string_security(self):
|
| 414 |
+
"""Test that database connection strings don't expose credentials"""
|
| 415 |
+
from app.nosql import get_mongodb_client
|
| 416 |
+
|
| 417 |
+
# This test ensures connection strings are properly configured
|
| 418 |
+
# In a real scenario, you'd check that credentials aren't hardcoded
|
| 419 |
+
client = get_mongodb_client()
|
| 420 |
+
|
| 421 |
+
# Should have a client instance
|
| 422 |
+
assert client is not None
|
| 423 |
+
|
| 424 |
+
# Connection string should not be exposed in error messages
|
| 425 |
+
# This would require triggering a connection error and checking the message
|
| 426 |
+
|
| 427 |
+
class TestAPISecurityBestPractices:
|
| 428 |
+
"""Test API security best practices"""
|
| 429 |
+
|
| 430 |
+
def test_http_methods_restriction(self, client: TestClient):
|
| 431 |
+
"""Test that endpoints only accept appropriate HTTP methods"""
|
| 432 |
+
# Test that GET endpoints don't accept POST
|
| 433 |
+
response = client.post("/api/v1/merchants/")
|
| 434 |
+
assert response.status_code == 405 # Method Not Allowed
|
| 435 |
+
|
| 436 |
+
# Test that POST endpoints don't accept GET
|
| 437 |
+
response = client.get("/api/v1/helpers/process-text")
|
| 438 |
+
assert response.status_code == 405 # Method Not Allowed
|
| 439 |
+
|
| 440 |
+
def test_content_type_validation(self, client: TestClient):
|
| 441 |
+
"""Test content type validation"""
|
| 442 |
+
# Send JSON data with wrong content type
|
| 443 |
+
response = client.post(
|
| 444 |
+
"/api/v1/helpers/process-text",
|
| 445 |
+
data='{"text": "test"}',
|
| 446 |
+
headers={"Content-Type": "text/plain"}
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
# Should reject or handle appropriately
|
| 450 |
+
assert response.status_code in [400, 415, 422]
|
| 451 |
+
|
| 452 |
+
def test_parameter_pollution(self, client: TestClient):
|
| 453 |
+
"""Test handling of parameter pollution"""
|
| 454 |
+
# Send duplicate parameters
|
| 455 |
+
response = client.get("/api/v1/merchants/search?category=salon&category=spa")
|
| 456 |
+
|
| 457 |
+
# Should handle duplicate parameters appropriately
|
| 458 |
+
assert response.status_code in [200, 400]
|
| 459 |
+
|
| 460 |
+
def test_null_byte_injection(self, client: TestClient):
|
| 461 |
+
"""Test null byte injection prevention"""
|
| 462 |
+
null_byte_input = "test\x00malicious"
|
| 463 |
+
|
| 464 |
+
response = client.post("/api/v1/nlp/analyze-query", params={
|
| 465 |
+
"query": null_byte_input
|
| 466 |
+
})
|
| 467 |
+
|
| 468 |
+
# Should handle null bytes safely
|
| 469 |
+
assert response.status_code in [200, 400]
|
| 470 |
+
if response.status_code == 200:
|
| 471 |
+
# Response should not contain null bytes
|
| 472 |
+
response_text = str(response.json())
|
| 473 |
+
assert "\x00" not in response_text
|
app/tests/test_services.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regression tests for service layer components
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 7 |
+
from typing import Dict, Any
|
| 8 |
+
|
| 9 |
+
class TestMerchantService:
|
| 10 |
+
"""Test merchant service functionality"""
|
| 11 |
+
|
| 12 |
+
@patch('app.nosql.db')
|
| 13 |
+
@pytest.mark.asyncio
|
| 14 |
+
async def test_get_merchants_success(self, mock_db, sample_merchant_data):
|
| 15 |
+
"""Test successful merchant retrieval"""
|
| 16 |
+
from app.services.merchant import get_merchants
|
| 17 |
+
|
| 18 |
+
# Mock the MongoDB collection
|
| 19 |
+
mock_collection = AsyncMock()
|
| 20 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 21 |
+
mock_collection.find.return_value.limit.return_value.skip.return_value.to_list.return_value = [sample_merchant_data]
|
| 22 |
+
|
| 23 |
+
result = await get_merchants(limit=10, skip=0)
|
| 24 |
+
|
| 25 |
+
assert len(result) == 1
|
| 26 |
+
assert result[0]["name"] == "Test Hair Salon"
|
| 27 |
+
|
| 28 |
+
@patch('app.nosql.db')
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_get_merchant_by_id_success(self, mock_db, sample_merchant_data):
|
| 31 |
+
"""Test successful merchant retrieval by ID"""
|
| 32 |
+
from app.services.merchant import get_merchant_by_id
|
| 33 |
+
|
| 34 |
+
# Mock the MongoDB collection
|
| 35 |
+
mock_collection = AsyncMock()
|
| 36 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 37 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 38 |
+
|
| 39 |
+
result = await get_merchant_by_id("test_merchant_123")
|
| 40 |
+
|
| 41 |
+
assert result["_id"] == "test_merchant_123"
|
| 42 |
+
assert result["name"] == "Test Hair Salon"
|
| 43 |
+
|
| 44 |
+
@patch('app.nosql.db')
|
| 45 |
+
@pytest.mark.asyncio
|
| 46 |
+
async def test_get_merchant_by_id_not_found(self, mock_db):
|
| 47 |
+
"""Test merchant not found scenario"""
|
| 48 |
+
from app.services.merchant import get_merchant_by_id
|
| 49 |
+
|
| 50 |
+
# Mock the MongoDB collection
|
| 51 |
+
mock_collection = AsyncMock()
|
| 52 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 53 |
+
mock_collection.find_one.return_value = None
|
| 54 |
+
|
| 55 |
+
result = await get_merchant_by_id("nonexistent_id")
|
| 56 |
+
|
| 57 |
+
assert result is None
|
| 58 |
+
|
| 59 |
+
@patch('app.nosql.db')
|
| 60 |
+
@pytest.mark.asyncio
|
| 61 |
+
async def test_search_merchants_with_location(self, mock_db, sample_merchant_data):
|
| 62 |
+
"""Test merchant search with location parameters"""
|
| 63 |
+
from app.services.merchant import search_merchants
|
| 64 |
+
|
| 65 |
+
# Mock the MongoDB collection
|
| 66 |
+
mock_collection = AsyncMock()
|
| 67 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 68 |
+
mock_collection.find.return_value.limit.return_value.to_list.return_value = [sample_merchant_data]
|
| 69 |
+
|
| 70 |
+
result = await search_merchants(
|
| 71 |
+
latitude=40.7128,
|
| 72 |
+
longitude=-74.0060,
|
| 73 |
+
radius=5000,
|
| 74 |
+
category="salon"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
assert len(result) == 1
|
| 78 |
+
assert result[0]["category"] == "salon"
|
| 79 |
+
|
| 80 |
+
@patch('app.nosql.redis_client')
|
| 81 |
+
@patch('app.nosql.db')
|
| 82 |
+
@pytest.mark.asyncio
|
| 83 |
+
async def test_merchant_caching(self, mock_db, mock_redis, sample_merchant_data):
|
| 84 |
+
"""Test merchant data caching"""
|
| 85 |
+
from app.services.merchant import get_merchants
|
| 86 |
+
|
| 87 |
+
# Mock the MongoDB collection
|
| 88 |
+
mock_collection = AsyncMock()
|
| 89 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 90 |
+
mock_collection.find.return_value.limit.return_value.skip.return_value.to_list.return_value = [sample_merchant_data]
|
| 91 |
+
|
| 92 |
+
# Mock Redis
|
| 93 |
+
mock_redis.get.return_value = None # Cache miss
|
| 94 |
+
mock_redis.set.return_value = True
|
| 95 |
+
|
| 96 |
+
result = await get_merchants(limit=10, skip=0)
|
| 97 |
+
|
| 98 |
+
assert len(result) == 1
|
| 99 |
+
assert result[0]["name"] == "Test Hair Salon"
|
| 100 |
+
|
| 101 |
+
class TestHelperService:
|
| 102 |
+
"""Test helper service functionality"""
|
| 103 |
+
|
| 104 |
+
@pytest.mark.asyncio
|
| 105 |
+
async def test_process_free_text_basic(self):
|
| 106 |
+
"""Test basic free text processing"""
|
| 107 |
+
from app.services.helper import process_free_text
|
| 108 |
+
|
| 109 |
+
result = await process_free_text("find a hair salon", 40.7128, -74.0060)
|
| 110 |
+
|
| 111 |
+
assert "query" in result
|
| 112 |
+
assert "extracted_keywords" in result
|
| 113 |
+
assert result["query"] == "find a hair salon"
|
| 114 |
+
|
| 115 |
+
@pytest.mark.asyncio
|
| 116 |
+
async def test_process_free_text_with_location(self):
|
| 117 |
+
"""Test free text processing with location"""
|
| 118 |
+
from app.services.helper import process_free_text
|
| 119 |
+
|
| 120 |
+
result = await process_free_text("salon near me", 40.7128, -74.0060)
|
| 121 |
+
|
| 122 |
+
assert "location" in result or "search_parameters" in result
|
| 123 |
+
assert result["query"] == "salon near me"
|
| 124 |
+
|
| 125 |
+
@pytest.mark.asyncio
|
| 126 |
+
async def test_process_free_text_empty_input(self):
|
| 127 |
+
"""Test free text processing with empty input"""
|
| 128 |
+
from app.services.helper import process_free_text
|
| 129 |
+
|
| 130 |
+
result = await process_free_text("", 40.7128, -74.0060)
|
| 131 |
+
|
| 132 |
+
# Should handle empty input gracefully
|
| 133 |
+
assert "query" in result
|
| 134 |
+
assert result["query"] == ""
|
| 135 |
+
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_extract_keywords(self):
|
| 138 |
+
"""Test keyword extraction functionality"""
|
| 139 |
+
from app.services.helper import extract_keywords
|
| 140 |
+
|
| 141 |
+
keywords = extract_keywords("find the best hair salon with parking")
|
| 142 |
+
|
| 143 |
+
assert isinstance(keywords, list)
|
| 144 |
+
assert len(keywords) > 0
|
| 145 |
+
# Should extract relevant keywords
|
| 146 |
+
assert any("hair" in kw.lower() or "salon" in kw.lower() for kw in keywords)
|
| 147 |
+
|
| 148 |
+
@pytest.mark.asyncio
|
| 149 |
+
async def test_suggest_category(self):
|
| 150 |
+
"""Test category suggestion functionality"""
|
| 151 |
+
from app.services.helper import suggest_category
|
| 152 |
+
|
| 153 |
+
category = suggest_category("hair salon")
|
| 154 |
+
|
| 155 |
+
assert category is not None
|
| 156 |
+
assert category in ["salon", "beauty", "hair_salon"]
|
| 157 |
+
|
| 158 |
+
class TestSearchHelpers:
|
| 159 |
+
"""Test search helper functionality"""
|
| 160 |
+
|
| 161 |
+
@pytest.mark.asyncio
|
| 162 |
+
async def test_build_search_query(self):
|
| 163 |
+
"""Test search query building"""
|
| 164 |
+
from app.services.search_helpers import build_search_query
|
| 165 |
+
|
| 166 |
+
params = {
|
| 167 |
+
"category": "salon",
|
| 168 |
+
"latitude": 40.7128,
|
| 169 |
+
"longitude": -74.0060,
|
| 170 |
+
"radius": 5000
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
query = build_search_query(params)
|
| 174 |
+
|
| 175 |
+
assert isinstance(query, dict)
|
| 176 |
+
assert "location" in query or "category" in query
|
| 177 |
+
|
| 178 |
+
@pytest.mark.asyncio
|
| 179 |
+
async def test_apply_filters(self):
|
| 180 |
+
"""Test filter application"""
|
| 181 |
+
from app.services.search_helpers import apply_filters
|
| 182 |
+
|
| 183 |
+
merchants = [
|
| 184 |
+
{"name": "Salon A", "average_rating": 4.5, "price_range": "$$"},
|
| 185 |
+
{"name": "Salon B", "average_rating": 3.5, "price_range": "$"},
|
| 186 |
+
{"name": "Salon C", "average_rating": 4.8, "price_range": "$$$"}
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
filters = {"min_rating": 4.0, "max_price_range": "$$"}
|
| 190 |
+
|
| 191 |
+
filtered = apply_filters(merchants, filters)
|
| 192 |
+
|
| 193 |
+
assert len(filtered) <= len(merchants)
|
| 194 |
+
# Should only include merchants meeting criteria
|
| 195 |
+
for merchant in filtered:
|
| 196 |
+
assert merchant["average_rating"] >= 4.0
|
| 197 |
+
|
| 198 |
+
@pytest.mark.asyncio
|
| 199 |
+
async def test_sort_results(self):
|
| 200 |
+
"""Test result sorting"""
|
| 201 |
+
from app.services.search_helpers import sort_results
|
| 202 |
+
|
| 203 |
+
merchants = [
|
| 204 |
+
{"name": "Salon A", "average_rating": 4.5, "distance": 1000},
|
| 205 |
+
{"name": "Salon B", "average_rating": 4.8, "distance": 2000},
|
| 206 |
+
{"name": "Salon C", "average_rating": 4.2, "distance": 500}
|
| 207 |
+
]
|
| 208 |
+
|
| 209 |
+
# Sort by rating
|
| 210 |
+
sorted_by_rating = sort_results(merchants, "rating")
|
| 211 |
+
assert sorted_by_rating[0]["average_rating"] >= sorted_by_rating[1]["average_rating"]
|
| 212 |
+
|
| 213 |
+
# Sort by distance
|
| 214 |
+
sorted_by_distance = sort_results(merchants, "distance")
|
| 215 |
+
assert sorted_by_distance[0]["distance"] <= sorted_by_distance[1]["distance"]
|
| 216 |
+
|
| 217 |
+
class TestAdvancedNLPService:
|
| 218 |
+
"""Test advanced NLP service functionality"""
|
| 219 |
+
|
| 220 |
+
@pytest.mark.asyncio
|
| 221 |
+
async def test_nlp_pipeline_initialization(self):
|
| 222 |
+
"""Test NLP pipeline initialization"""
|
| 223 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 224 |
+
|
| 225 |
+
pipeline = AdvancedNLPPipeline()
|
| 226 |
+
|
| 227 |
+
assert pipeline.intent_classifier is not None
|
| 228 |
+
assert pipeline.entity_extractor is not None
|
| 229 |
+
assert pipeline.semantic_matcher is not None
|
| 230 |
+
assert pipeline.context_processor is not None
|
| 231 |
+
|
| 232 |
+
@pytest.mark.asyncio
|
| 233 |
+
async def test_intent_classification(self):
|
| 234 |
+
"""Test intent classification"""
|
| 235 |
+
from app.services.advanced_nlp import IntentClassifier
|
| 236 |
+
|
| 237 |
+
classifier = IntentClassifier()
|
| 238 |
+
|
| 239 |
+
# Test search intent
|
| 240 |
+
intent, confidence = classifier.get_primary_intent("find a hair salon")
|
| 241 |
+
assert intent == "SEARCH_SERVICE"
|
| 242 |
+
assert confidence > 0.0
|
| 243 |
+
|
| 244 |
+
# Test quality filter intent
|
| 245 |
+
intent, confidence = classifier.get_primary_intent("best salon in town")
|
| 246 |
+
assert intent == "FILTER_QUALITY"
|
| 247 |
+
assert confidence > 0.0
|
| 248 |
+
|
| 249 |
+
@pytest.mark.asyncio
|
| 250 |
+
async def test_entity_extraction(self):
|
| 251 |
+
"""Test entity extraction"""
|
| 252 |
+
from app.services.advanced_nlp import BusinessEntityExtractor
|
| 253 |
+
|
| 254 |
+
extractor = BusinessEntityExtractor()
|
| 255 |
+
|
| 256 |
+
entities = extractor.extract_entities("hair salon with parking near me")
|
| 257 |
+
|
| 258 |
+
assert isinstance(entities, dict)
|
| 259 |
+
assert "service_types" in entities or "amenities" in entities
|
| 260 |
+
|
| 261 |
+
@pytest.mark.asyncio
|
| 262 |
+
async def test_semantic_matching(self):
|
| 263 |
+
"""Test semantic matching"""
|
| 264 |
+
from app.services.advanced_nlp import SemanticMatcher
|
| 265 |
+
|
| 266 |
+
matcher = SemanticMatcher()
|
| 267 |
+
|
| 268 |
+
matches = matcher.find_similar_services("hair salon")
|
| 269 |
+
|
| 270 |
+
assert isinstance(matches, list)
|
| 271 |
+
assert len(matches) > 0
|
| 272 |
+
# Should return tuples of (service, similarity_score)
|
| 273 |
+
for match in matches:
|
| 274 |
+
assert len(match) == 2
|
| 275 |
+
assert isinstance(match[1], float)
|
| 276 |
+
assert 0.0 <= match[1] <= 1.0
|
| 277 |
+
|
| 278 |
+
@pytest.mark.asyncio
|
| 279 |
+
async def test_context_processing(self):
|
| 280 |
+
"""Test context-aware processing"""
|
| 281 |
+
from app.services.advanced_nlp import ContextAwareProcessor
|
| 282 |
+
|
| 283 |
+
processor = ContextAwareProcessor()
|
| 284 |
+
|
| 285 |
+
result = await processor.process_with_context(
|
| 286 |
+
"spa treatment",
|
| 287 |
+
{"service_categories": ["spa"]},
|
| 288 |
+
[("spa", 0.9)]
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
assert isinstance(result, dict)
|
| 292 |
+
|
| 293 |
+
@pytest.mark.asyncio
|
| 294 |
+
async def test_complete_nlp_pipeline(self):
|
| 295 |
+
"""Test complete NLP pipeline processing"""
|
| 296 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 297 |
+
|
| 298 |
+
pipeline = AdvancedNLPPipeline()
|
| 299 |
+
|
| 300 |
+
result = await pipeline.process_query("find the best hair salon near me")
|
| 301 |
+
|
| 302 |
+
assert "query" in result
|
| 303 |
+
assert "primary_intent" in result
|
| 304 |
+
assert "entities" in result
|
| 305 |
+
assert "similar_services" in result
|
| 306 |
+
assert "search_parameters" in result
|
| 307 |
+
assert "processing_time" in result
|
| 308 |
+
|
| 309 |
+
@pytest.mark.asyncio
|
| 310 |
+
async def test_nlp_caching(self):
|
| 311 |
+
"""Test NLP result caching"""
|
| 312 |
+
from app.services.advanced_nlp import AsyncNLPProcessor
|
| 313 |
+
|
| 314 |
+
processor = AsyncNLPProcessor()
|
| 315 |
+
|
| 316 |
+
def dummy_processor(text):
|
| 317 |
+
return {"processed": text.upper()}
|
| 318 |
+
|
| 319 |
+
# First call
|
| 320 |
+
result1 = await processor.process_async("test", dummy_processor)
|
| 321 |
+
|
| 322 |
+
# Second call should use cache
|
| 323 |
+
result2 = await processor.process_async("test", dummy_processor)
|
| 324 |
+
|
| 325 |
+
assert result1 == result2
|
| 326 |
+
|
| 327 |
+
@pytest.mark.asyncio
|
| 328 |
+
async def test_nlp_error_handling(self):
|
| 329 |
+
"""Test NLP error handling"""
|
| 330 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 331 |
+
|
| 332 |
+
pipeline = AdvancedNLPPipeline()
|
| 333 |
+
|
| 334 |
+
# Test with empty query
|
| 335 |
+
result = await pipeline.process_query("")
|
| 336 |
+
|
| 337 |
+
assert "query" in result
|
| 338 |
+
assert result["query"] == ""
|
| 339 |
+
# Should handle gracefully without crashing
|
| 340 |
+
|
| 341 |
+
class TestServiceIntegration:
|
| 342 |
+
"""Test service integration scenarios"""
|
| 343 |
+
|
| 344 |
+
@patch('app.services.merchant.search_merchants')
|
| 345 |
+
@patch('app.services.advanced_nlp.advanced_nlp_pipeline')
|
| 346 |
+
@pytest.mark.asyncio
|
| 347 |
+
async def test_nlp_to_merchant_search_integration(self, mock_nlp, mock_search, sample_merchant_data):
|
| 348 |
+
"""Test integration between NLP processing and merchant search"""
|
| 349 |
+
# Mock NLP pipeline response
|
| 350 |
+
mock_nlp.process_query.return_value = {
|
| 351 |
+
"search_parameters": {
|
| 352 |
+
"merchant_category": "salon",
|
| 353 |
+
"radius": 5000,
|
| 354 |
+
"amenities": ["parking"]
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
# Mock merchant search response
|
| 359 |
+
mock_search.return_value = [sample_merchant_data]
|
| 360 |
+
|
| 361 |
+
# Simulate the integration
|
| 362 |
+
nlp_result = await mock_nlp.process_query("salon with parking")
|
| 363 |
+
search_params = nlp_result["search_parameters"]
|
| 364 |
+
|
| 365 |
+
merchants = await mock_search(**search_params)
|
| 366 |
+
|
| 367 |
+
assert len(merchants) == 1
|
| 368 |
+
assert merchants[0]["category"] == "salon"
|
| 369 |
+
|
| 370 |
+
@patch('app.repositories.cache_repository.cache_search_results')
|
| 371 |
+
@patch('app.services.merchant.search_merchants')
|
| 372 |
+
@pytest.mark.asyncio
|
| 373 |
+
async def test_search_result_caching(self, mock_search, mock_cache, sample_merchant_data):
|
| 374 |
+
"""Test search result caching integration"""
|
| 375 |
+
mock_search.return_value = [sample_merchant_data]
|
| 376 |
+
|
| 377 |
+
# Perform search
|
| 378 |
+
results = await mock_search(category="salon")
|
| 379 |
+
|
| 380 |
+
# Cache should be called
|
| 381 |
+
mock_cache.assert_called_once()
|
| 382 |
+
assert len(results) == 1
|
| 383 |
+
|
| 384 |
+
@pytest.mark.asyncio
|
| 385 |
+
async def test_error_propagation(self):
|
| 386 |
+
"""Test error propagation between services"""
|
| 387 |
+
from app.services.merchant import get_merchant_by_id
|
| 388 |
+
|
| 389 |
+
with patch('app.nosql.db') as mock_db:
|
| 390 |
+
mock_collection = AsyncMock()
|
| 391 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 392 |
+
mock_collection.find_one.side_effect = Exception("Database connection failed")
|
| 393 |
+
|
| 394 |
+
with pytest.raises(Exception) as exc_info:
|
| 395 |
+
await get_merchant_by_id("test_id")
|
| 396 |
+
|
| 397 |
+
assert "Database connection failed" in str(exc_info.value)
|
| 398 |
+
|
| 399 |
+
class TestServicePerformance:
|
| 400 |
+
"""Test service performance characteristics"""
|
| 401 |
+
|
| 402 |
+
@pytest.mark.asyncio
|
| 403 |
+
async def test_concurrent_merchant_requests(self, sample_merchant_data):
|
| 404 |
+
"""Test concurrent merchant service requests"""
|
| 405 |
+
import asyncio
|
| 406 |
+
from app.services.merchant import get_merchant_by_id
|
| 407 |
+
|
| 408 |
+
with patch('app.nosql.db') as mock_db:
|
| 409 |
+
mock_collection = AsyncMock()
|
| 410 |
+
mock_db.__getitem__.return_value = mock_collection
|
| 411 |
+
mock_collection.find_one.return_value = sample_merchant_data
|
| 412 |
+
|
| 413 |
+
# Create multiple concurrent requests
|
| 414 |
+
tasks = [
|
| 415 |
+
get_merchant_by_id(f"merchant_{i}")
|
| 416 |
+
for i in range(10)
|
| 417 |
+
]
|
| 418 |
+
|
| 419 |
+
results = await asyncio.gather(*tasks)
|
| 420 |
+
|
| 421 |
+
assert len(results) == 10
|
| 422 |
+
assert all(result["name"] == "Test Hair Salon" for result in results)
|
| 423 |
+
|
| 424 |
+
@pytest.mark.asyncio
|
| 425 |
+
async def test_nlp_processing_performance(self):
|
| 426 |
+
"""Test NLP processing performance"""
|
| 427 |
+
import time
|
| 428 |
+
from app.services.advanced_nlp import AdvancedNLPPipeline
|
| 429 |
+
|
| 430 |
+
pipeline = AdvancedNLPPipeline()
|
| 431 |
+
|
| 432 |
+
start_time = time.time()
|
| 433 |
+
result = await pipeline.process_query("find a hair salon")
|
| 434 |
+
processing_time = time.time() - start_time
|
| 435 |
+
|
| 436 |
+
# Should process within reasonable time
|
| 437 |
+
assert processing_time < 2.0 # 2 seconds max
|
| 438 |
+
assert "processing_time" in result
|
| 439 |
+
assert result["processing_time"] > 0
|