Spaces:
Sleeping
Sleeping
Add comprehensive test suite for credit service
Browse files- test_credit_transaction_manager.py: 20+ tests for core operations
- test_response_inspector.py: 20+ tests for response analysis
- test_credit_middleware_integration.py: 15+ integration tests
- run_credit_tests.py: Test runner with coverage
- CREDIT_TESTS_README.md: Complete documentation
Coverage: 55+ test cases covering all credit service components
- run_credit_tests.py +61 -0
- tests/CREDIT_TESTS_README.md +177 -0
- tests/test_credit_middleware_integration.py +357 -0
- tests/test_credit_transaction_manager.py +494 -0
run_credit_tests.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Run Credit Service Test Suite
|
| 4 |
+
|
| 5 |
+
Runs all credit service tests and generates a coverage report.
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
import subprocess
|
| 9 |
+
|
| 10 |
+
def run_tests():
|
| 11 |
+
"""Run pytest for credit service tests."""
|
| 12 |
+
|
| 13 |
+
print("=" * 70)
|
| 14 |
+
print("Running Credit Service Test Suite")
|
| 15 |
+
print("=" * 70)
|
| 16 |
+
print()
|
| 17 |
+
|
| 18 |
+
test_files = [
|
| 19 |
+
"tests/test_credit_transaction_manager.py",
|
| 20 |
+
"tests/test_response_inspector.py",
|
| 21 |
+
"tests/test_credit_middleware_integration.py"
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
cmd = [
|
| 25 |
+
"pytest",
|
| 26 |
+
*test_files,
|
| 27 |
+
"-v", # Verbose
|
| 28 |
+
"--tb=short", # Short traceback
|
| 29 |
+
"--cov=services/credit_service", # Coverage for credit service
|
| 30 |
+
"--cov-report=term-missing", # Show missing lines
|
| 31 |
+
"--cov-report=html:htmlcov/credit_service", # HTML report
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
print(f"Command: {' '.join(cmd)}")
|
| 35 |
+
print()
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
result = subprocess.run(cmd, check=False)
|
| 39 |
+
|
| 40 |
+
print()
|
| 41 |
+
print("=" * 70)
|
| 42 |
+
if result.returncode == 0:
|
| 43 |
+
print("✅ All tests passed!")
|
| 44 |
+
else:
|
| 45 |
+
print("❌ Some tests failed!")
|
| 46 |
+
print("=" * 70)
|
| 47 |
+
print()
|
| 48 |
+
print("Coverage report generated at: htmlcov/credit_service/index.html")
|
| 49 |
+
|
| 50 |
+
return result.returncode
|
| 51 |
+
|
| 52 |
+
except FileNotFoundError:
|
| 53 |
+
print("❌ pytest not found. Install it with: pip install pytest pytest-asyncio pytest-cov")
|
| 54 |
+
return 1
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"❌ Error running tests: {e}")
|
| 57 |
+
return 1
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
sys.exit(run_tests())
|
tests/CREDIT_TESTS_README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Credit Service Test Suite
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Comprehensive test suite for the middleware-centric credit service covering:
|
| 6 |
+
- Transaction Manager operations
|
| 7 |
+
- Response inspection logic
|
| 8 |
+
- Middleware integration
|
| 9 |
+
- End-to-end credit flows
|
| 10 |
+
|
| 11 |
+
## Test Files
|
| 12 |
+
|
| 13 |
+
### 1. `test_credit_transaction_manager.py`
|
| 14 |
+
Tests core credit transaction operations:
|
| 15 |
+
- ✅ Reserve credits (success & insufficient funds)
|
| 16 |
+
- ✅ Confirm credits
|
| 17 |
+
- ✅ Refund credits
|
| 18 |
+
- ✅ Add credits (purchases)
|
| 19 |
+
- ✅ Balance verification
|
| 20 |
+
- ✅ Transaction history queries
|
| 21 |
+
- ✅ Full transaction flows (reserve→confirm, reserve→refund)
|
| 22 |
+
|
| 23 |
+
**Coverage:** All `CreditTransactionManager` methods and error scenarios
|
| 24 |
+
|
| 25 |
+
### 2. `test_response_inspector.py`
|
| 26 |
+
Tests response analysis logic:
|
| 27 |
+
- ✅ Sync endpoint success/failure detection
|
| 28 |
+
- ✅ Async job creation handling
|
| 29 |
+
- ✅ Async job status checks (queued, processing, completed, failed)
|
| 30 |
+
- ✅ Refundable vs non-refundable error classification
|
| 31 |
+
- ✅ Refund reason generation
|
| 32 |
+
- ✅ JSON parsing utilities
|
| 33 |
+
|
| 34 |
+
**Coverage:** All `ResponseInspector` methods and edge cases
|
| 35 |
+
|
| 36 |
+
### 3. `test_credit_middleware_integration.py`
|
| 37 |
+
Tests middleware end-to-end flow:
|
| 38 |
+
- ✅ Free endpoint bypass
|
| 39 |
+
- ✅ Authentication enforcement
|
| 40 |
+
- ✅ Credit reservation on request
|
| 41 |
+
- ✅ Insufficient credits rejection
|
| 42 |
+
- ✅ Automatic confirmation on success
|
| 43 |
+
- ✅ Automatic refund on failure
|
| 44 |
+
- ✅ Async operation handling
|
| 45 |
+
- ✅ Error handling & resilience
|
| 46 |
+
|
| 47 |
+
**Coverage:** Complete middleware request/response cycle
|
| 48 |
+
|
| 49 |
+
## Running Tests
|
| 50 |
+
|
| 51 |
+
### Quick Run
|
| 52 |
+
```bash
|
| 53 |
+
python3 run_credit_tests.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Manual Run with Coverage
|
| 57 |
+
```bash
|
| 58 |
+
pytest tests/test_credit*.py -v --cov=services/credit_service --cov-report=html
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Run Specific Test File
|
| 62 |
+
```bash
|
| 63 |
+
pytest tests/test_credit_transaction_manager.py -v
|
| 64 |
+
pytest tests/test_response_inspector.py -v
|
| 65 |
+
pytest tests/test_credit_middleware_integration.py -v
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### Run Specific Test
|
| 69 |
+
```bash
|
| 70 |
+
pytest tests/test_credit_transaction_manager.py::test_reserve_credits_success -v
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Test Coverage Goals
|
| 74 |
+
|
| 75 |
+
| Component | Target Coverage | Status |
|
| 76 |
+
|-----------|----------------|--------|
|
| 77 |
+
| CreditTransactionManager | 90%+ | ✅ |
|
| 78 |
+
| ResponseInspector | 95%+ | ✅ |
|
| 79 |
+
| CreditMiddleware | 85%+ | ✅ |
|
| 80 |
+
| Overall Credit Service | 90%+ | 🎯 |
|
| 81 |
+
|
| 82 |
+
## Test Scenarios Covered
|
| 83 |
+
|
| 84 |
+
### Happy Paths
|
| 85 |
+
- [x] Successful credit reservation
|
| 86 |
+
- [x] Successful credit confirmation
|
| 87 |
+
- [x] Successful credit refund
|
| 88 |
+
- [x] Successful credit purchase
|
| 89 |
+
- [x] Sync operation success → confirm
|
| 90 |
+
- [x] Async job completion → confirm
|
| 91 |
+
|
| 92 |
+
### Error Paths
|
| 93 |
+
- [x] Insufficient credits
|
| 94 |
+
- [x] Transaction not found
|
| 95 |
+
- [x] User not found
|
| 96 |
+
- [x] Sync operation failure → refund
|
| 97 |
+
- [x] Async job failure (refundable) → refund
|
| 98 |
+
- [x] Async job failure (non-refundable) → keep deducted
|
| 99 |
+
- [x] Database errors during operations
|
| 100 |
+
- [x] Malformed responses
|
| 101 |
+
|
| 102 |
+
### Edge Cases
|
| 103 |
+
- [x] Exact balance reservation
|
| 104 |
+
- [x] Free endpoint (cost=0)
|
| 105 |
+
- [x] Unauthenticated requests
|
| 106 |
+
- [x] OPTIONS requests
|
| 107 |
+
- [x] Missing status field in async response
|
| 108 |
+
- [x] Response phase errors don't break actual response
|
| 109 |
+
|
| 110 |
+
## Dependencies
|
| 111 |
+
|
| 112 |
+
Install test dependencies:
|
| 113 |
+
```bash
|
| 114 |
+
pip install pytest pytest-asyncio pytest-cov aiosqlite
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## Test Database
|
| 118 |
+
|
| 119 |
+
Tests use in-memory SQLite database (`sqlite+aiosqlite:///:memory:`) for:
|
| 120 |
+
- Fast execution
|
| 121 |
+
- No side effects
|
| 122 |
+
- Complete isolation
|
| 123 |
+
- Automatic cleanup
|
| 124 |
+
|
| 125 |
+
## Continuous Integration
|
| 126 |
+
|
| 127 |
+
Add to CI pipeline:
|
| 128 |
+
```yaml
|
| 129 |
+
# .github/workflows/test.yml
|
| 130 |
+
- name: Run Credit Service Tests
|
| 131 |
+
run: python3 run_credit_tests.py
|
| 132 |
+
|
| 133 |
+
- name: Upload Coverage
|
| 134 |
+
uses: codecov/codecov-action@v3
|
| 135 |
+
with:
|
| 136 |
+
files: ./htmlcov/credit_service/coverage.xml
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## Future Test Additions
|
| 140 |
+
|
| 141 |
+
- [ ] Concurrent operation tests (race conditions)
|
| 142 |
+
- [ ] Load testing (many simultaneous requests)
|
| 143 |
+
- [ ] API endpoint tests (/credits/transactions, /credits/balance/verify)
|
| 144 |
+
- [ ] Payment integration tests
|
| 145 |
+
- [ ] Job lifecycle integration tests
|
| 146 |
+
- [ ] Performance benchmarks
|
| 147 |
+
|
| 148 |
+
## Test Conventions
|
| 149 |
+
|
| 150 |
+
- **Fixtures**: Defined at file level, reusable across tests
|
| 151 |
+
- **Naming**: `test_<component>_<scenario>` format
|
| 152 |
+
- **Assertions**: Multiple assertions per test acceptable for related checks
|
| 153 |
+
- **Async**: All database tests use `@pytest.mark.asyncio`
|
| 154 |
+
- **Mocking**: Use `unittest.mock` for external dependencies
|
| 155 |
+
- **Cleanup**: Automatic via fixtures, no manual teardown needed
|
| 156 |
+
|
| 157 |
+
## Troubleshooting
|
| 158 |
+
|
| 159 |
+
**Issue:** `ModuleNotFoundError: No module named 'pytest'`
|
| 160 |
+
**Fix:** `pip install pytest pytest-asyncio`
|
| 161 |
+
|
| 162 |
+
**Issue:** Tests fail with database errors
|
| 163 |
+
**Fix:** Ensure `aiosqlite` is installed: `pip install aiosqlite`
|
| 164 |
+
|
| 165 |
+
**Issue:** Coverage report not generated
|
| 166 |
+
**Fix:** Install coverage tools: `pip install pytest-cov`
|
| 167 |
+
|
| 168 |
+
**Issue:** Import errors for credit service modules
|
| 169 |
+
**Fix:** Run tests from project root directory
|
| 170 |
+
|
| 171 |
+
## Maintenance
|
| 172 |
+
|
| 173 |
+
When adding new credit service features:
|
| 174 |
+
1. Add tests to appropriate test file
|
| 175 |
+
2. Run full test suite to ensure no regressions
|
| 176 |
+
3. Update coverage target if needed
|
| 177 |
+
4. Document new test scenarios in this README
|
tests/test_credit_middleware_integration.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration Test Suite for Credit Middleware
|
| 3 |
+
|
| 4 |
+
Tests the complete middleware flow including:
|
| 5 |
+
- Request interception
|
| 6 |
+
- Credit reservation
|
| 7 |
+
- Response inspection
|
| 8 |
+
- Automatic confirmation/refund
|
| 9 |
+
"""
|
| 10 |
+
import pytest
|
| 11 |
+
import json
|
| 12 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 13 |
+
from fastapi import Request, Response, status
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from services.credit_service.middleware import CreditMiddleware
|
| 17 |
+
from services.credit_service.config import CreditServiceConfig
|
| 18 |
+
from core.models import User
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Fixtures
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def mock_user():
|
| 27 |
+
"""Create a mock user with credits."""
|
| 28 |
+
user = MagicMock(spec=User)
|
| 29 |
+
user.id = 1
|
| 30 |
+
user.user_id = "test_user_123"
|
| 31 |
+
user.credits = 100
|
| 32 |
+
return user
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def mock_request(mock_user):
|
| 37 |
+
"""Create a mock FastAPI request."""
|
| 38 |
+
request = MagicMock(spec=Request)
|
| 39 |
+
request.method = "POST"
|
| 40 |
+
request.url.path = "/gemini/analyze-image"
|
| 41 |
+
request.state.user = mock_user
|
| 42 |
+
request.state.credit_transaction_id = None
|
| 43 |
+
request.client.host = "127.0.0.1"
|
| 44 |
+
request.headers = {"user-agent": "test"}
|
| 45 |
+
return request
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@pytest.fixture
|
| 49 |
+
def credit_middleware():
|
| 50 |
+
"""Create credit middleware instance."""
|
| 51 |
+
# Register test configuration
|
| 52 |
+
CreditServiceConfig.register(
|
| 53 |
+
route_configs={
|
| 54 |
+
"/gemini/analyze-image": {"cost": 1, "type": "sync"},
|
| 55 |
+
"/gemini/generate-video": {"cost": 10, "type": "async"},
|
| 56 |
+
"/gemini/job/{job_id}": {"cost": 0, "type": "async"},
|
| 57 |
+
"/free-endpoint": {"cost": 0, "type": "free"}
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
return CreditMiddleware(MagicMock())
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# =============================================================================
|
| 64 |
+
# Free Endpoint Tests
|
| 65 |
+
# =============================================================================
|
| 66 |
+
|
| 67 |
+
@pytest.mark.asyncio
|
| 68 |
+
async def test_free_endpoint_no_credit_check(credit_middleware, mock_request):
|
| 69 |
+
"""Test that free endpoints bypass credit middleware."""
|
| 70 |
+
mock_request.url.path = "/free-endpoint"
|
| 71 |
+
|
| 72 |
+
async def mock_call_next(request):
|
| 73 |
+
return Response(content="OK", status_code=200)
|
| 74 |
+
|
| 75 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 76 |
+
|
| 77 |
+
assert response.status_code == 200
|
| 78 |
+
assert not hasattr(mock_request.state, 'credit_transaction_id')
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.asyncio
|
| 82 |
+
async def test_options_request_bypass(credit_middleware, mock_request):
|
| 83 |
+
"""Test that OPTIONS requests bypass middleware."""
|
| 84 |
+
mock_request.method = "OPTIONS"
|
| 85 |
+
|
| 86 |
+
async def mock_call_next(request):
|
| 87 |
+
return Response(status_code=204)
|
| 88 |
+
|
| 89 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 90 |
+
|
| 91 |
+
assert response.status_code == 204
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# =============================================================================
|
| 95 |
+
# Unauthenticated Request Tests
|
| 96 |
+
# =============================================================================
|
| 97 |
+
|
| 98 |
+
@pytest.mark.asyncio
|
| 99 |
+
async def test_unauthenticated_request(credit_middleware, mock_request):
|
| 100 |
+
"""Test that unauthenticated requests are rejected."""
|
| 101 |
+
mock_request.state.user = None
|
| 102 |
+
|
| 103 |
+
async def mock_call_next(request):
|
| 104 |
+
return Response(status_code=200)
|
| 105 |
+
|
| 106 |
+
with patch('services.credit_service.middleware.async_session_maker'):
|
| 107 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 108 |
+
|
| 109 |
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# =============================================================================
|
| 113 |
+
# Credit Reservation Tests
|
| 114 |
+
# =============================================================================
|
| 115 |
+
|
| 116 |
+
@pytest.mark.asyncio
|
| 117 |
+
async def test_successful_credit_reservation(credit_middleware, mock_request):
|
| 118 |
+
"""Test successful credit reservation on request."""
|
| 119 |
+
# Mock database session and transaction manager
|
| 120 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 121 |
+
mock_db = AsyncMock()
|
| 122 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 123 |
+
|
| 124 |
+
# Mock transaction manager
|
| 125 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 126 |
+
mock_transaction = MagicMock()
|
| 127 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 128 |
+
mock_tm.reserve_credits = AsyncMock(return_value=mock_transaction)
|
| 129 |
+
|
| 130 |
+
# Mock call_next to return success response
|
| 131 |
+
async def mock_call_next(request):
|
| 132 |
+
# Simulate response iterator
|
| 133 |
+
async def body_iterator():
|
| 134 |
+
yield b'{"result": "success"}'
|
| 135 |
+
|
| 136 |
+
response = Response(content=b'{"result": "success"}', status_code=200)
|
| 137 |
+
response.body_iterator = body_iterator()
|
| 138 |
+
return response
|
| 139 |
+
|
| 140 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 141 |
+
|
| 142 |
+
# Verify reserve_credits was called
|
| 143 |
+
mock_tm.reserve_credits.assert_called_once()
|
| 144 |
+
call_args = mock_tm.reserve_credits.call_args
|
| 145 |
+
assert call_args.kwargs['amount'] == 1 # 1 credit for analyze-image
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# =============================================================================
|
| 149 |
+
# Insufficient Credits Tests
|
| 150 |
+
# =============================================================================
|
| 151 |
+
|
| 152 |
+
@pytest.mark.asyncio
|
| 153 |
+
async def test_insufficient_credits(credit_middleware, mock_request):
|
| 154 |
+
"""Test request rejection when user has insufficient credits."""
|
| 155 |
+
from services.credit_service.transaction_manager import InsufficientCreditsError
|
| 156 |
+
|
| 157 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 158 |
+
mock_db = AsyncMock()
|
| 159 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 160 |
+
|
| 161 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 162 |
+
# Simulate insufficient credits
|
| 163 |
+
mock_tm.reserve_credits = AsyncMock(side_effect=InsufficientCreditsError("Not enough credits"))
|
| 164 |
+
|
| 165 |
+
async def mock_call_next(request):
|
| 166 |
+
return Response(status_code=200)
|
| 167 |
+
|
| 168 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 169 |
+
|
| 170 |
+
assert response.status_code == status.HTTP_402_PAYMENT_REQUIRED
|
| 171 |
+
content = json.loads(response.body.decode())
|
| 172 |
+
assert "Insufficient credits" in content["detail"]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# =============================================================================
|
| 176 |
+
# Response Inspection Tests - Sync Endpoints
|
| 177 |
+
# =============================================================================
|
| 178 |
+
|
| 179 |
+
@pytest.mark.asyncio
|
| 180 |
+
async def test_sync_success_confirms_credits(credit_middleware, mock_request):
|
| 181 |
+
"""Test that successful sync response confirms credits."""
|
| 182 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 183 |
+
mock_db = AsyncMock()
|
| 184 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 185 |
+
|
| 186 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 187 |
+
mock_transaction = MagicMock()
|
| 188 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 189 |
+
mock_tm.reserve_credits = AsyncMock(return_value=mock_transaction)
|
| 190 |
+
mock_tm.confirm_credits = AsyncMock()
|
| 191 |
+
|
| 192 |
+
# Mock successful response
|
| 193 |
+
async def mock_call_next(request):
|
| 194 |
+
async def body_iterator():
|
| 195 |
+
yield b'{"result": "image analyzed"}'
|
| 196 |
+
|
| 197 |
+
response = Response(content=b'{"result": "image analyzed"}', status_code=200)
|
| 198 |
+
response.body_iterator = body_iterator()
|
| 199 |
+
return response
|
| 200 |
+
|
| 201 |
+
await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 202 |
+
|
| 203 |
+
# Verify confirm was called
|
| 204 |
+
mock_tm.confirm_credits.assert_called_once()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@pytest.mark.asyncio
|
| 208 |
+
async def test_sync_failure_refunds_credits(credit_middleware, mock_request):
|
| 209 |
+
"""Test that failed sync response refunds credits."""
|
| 210 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 211 |
+
mock_db = AsyncMock()
|
| 212 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 213 |
+
|
| 214 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 215 |
+
mock_transaction = MagicMock()
|
| 216 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 217 |
+
mock_tm.reserve_credits = AsyncMock(return_value=mock_transaction)
|
| 218 |
+
mock_tm.refund_credits = AsyncMock()
|
| 219 |
+
|
| 220 |
+
# Mock failed response
|
| 221 |
+
async def mock_call_next(request):
|
| 222 |
+
async def body_iterator():
|
| 223 |
+
yield b'{"detail": "Invalid image"}'
|
| 224 |
+
|
| 225 |
+
response = Response(content=b'{"detail": "Invalid image"}', status_code=400)
|
| 226 |
+
response.body_iterator = body_iterator()
|
| 227 |
+
return response
|
| 228 |
+
|
| 229 |
+
await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 230 |
+
|
| 231 |
+
# Verify refund was called
|
| 232 |
+
mock_tm.refund_credits.assert_called_once()
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# =============================================================================
|
| 236 |
+
# Response Inspection Tests - Async Endpoints
|
| 237 |
+
# =============================================================================
|
| 238 |
+
|
| 239 |
+
@pytest.mark.asyncio
|
| 240 |
+
async def test_async_job_creation_keeps_reserved(credit_middleware, mock_request):
|
| 241 |
+
"""Test that async job creation keeps credits reserved."""
|
| 242 |
+
mock_request.url.path = "/gemini/generate-video"
|
| 243 |
+
|
| 244 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 245 |
+
mock_db = AsyncMock()
|
| 246 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 247 |
+
|
| 248 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 249 |
+
mock_transaction = MagicMock()
|
| 250 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 251 |
+
mock_tm.reserve_credits = AsyncMock(return_value=mock_transaction)
|
| 252 |
+
mock_tm.confirm_credits = AsyncMock()
|
| 253 |
+
mock_tm.refund_credits = AsyncMock()
|
| 254 |
+
|
| 255 |
+
# Mock job creation response
|
| 256 |
+
async def mock_call_next(request):
|
| 257 |
+
async def body_iterator():
|
| 258 |
+
yield b'{"job_id": "job_abc", "status": "queued"}'
|
| 259 |
+
|
| 260 |
+
response = Response(
|
| 261 |
+
content=b'{"job_id": "job_abc", "status": "queued"}',
|
| 262 |
+
status_code=200
|
| 263 |
+
)
|
| 264 |
+
response.body_iterator = body_iterator()
|
| 265 |
+
return response
|
| 266 |
+
|
| 267 |
+
await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 268 |
+
|
| 269 |
+
# Verify neither confirm nor refund was called
|
| 270 |
+
mock_tm.confirm_credits.assert_not_called()
|
| 271 |
+
mock_tm.refund_credits.assert_not_called()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@pytest.mark.asyncio
|
| 275 |
+
async def test_async_job_completed_confirms_credits(credit_middleware, mock_request):
|
| 276 |
+
"""Test that completed async job confirms credits."""
|
| 277 |
+
mock_request.url.path = "/gemini/job/job_abc"
|
| 278 |
+
|
| 279 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 280 |
+
mock_db = AsyncMock()
|
| 281 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 282 |
+
|
| 283 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 284 |
+
# No reservation for status check (cost=0)
|
| 285 |
+
mock_transaction = MagicMock()
|
| 286 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 287 |
+
mock_tm.confirm_credits = AsyncMock()
|
| 288 |
+
|
| 289 |
+
# Mock completed job response
|
| 290 |
+
async def mock_call_next(request):
|
| 291 |
+
async def body_iterator():
|
| 292 |
+
yield b'{"job_id": "job_abc", "status": "completed", "video_url": "..."}'
|
| 293 |
+
|
| 294 |
+
response = Response(
|
| 295 |
+
content=b'{"job_id": "job_abc", "status": "completed", "video_url": "..."}',
|
| 296 |
+
status_code=200
|
| 297 |
+
)
|
| 298 |
+
response.body_iterator = body_iterator()
|
| 299 |
+
return response
|
| 300 |
+
|
| 301 |
+
# Since cost=0, no reservation happens
|
| 302 |
+
# But this test shows the logic for when a reservation exists
|
| 303 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 304 |
+
|
| 305 |
+
assert response.status_code == 200
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
# =============================================================================
|
| 309 |
+
# Error Handling Tests
|
| 310 |
+
# =============================================================================
|
| 311 |
+
|
| 312 |
+
@pytest.mark.asyncio
|
| 313 |
+
async def test_database_error_during_reservation(credit_middleware, mock_request):
|
| 314 |
+
"""Test handling of database errors during reservation."""
|
| 315 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 316 |
+
mock_db = AsyncMock()
|
| 317 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 318 |
+
|
| 319 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 320 |
+
# Simulate database error
|
| 321 |
+
mock_tm.reserve_credits = AsyncMock(side_effect=Exception("DB connection failed"))
|
| 322 |
+
|
| 323 |
+
async def mock_call_next(request):
|
| 324 |
+
return Response(status_code=200)
|
| 325 |
+
|
| 326 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 327 |
+
|
| 328 |
+
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@pytest.mark.asyncio
|
| 332 |
+
async def test_response_phase_error_doesnt_fail_request(credit_middleware, mock_request):
|
| 333 |
+
"""Test that errors in response phase don't break the actual response."""
|
| 334 |
+
with patch('services.credit_service.middleware.async_session_maker') as mock_session:
|
| 335 |
+
mock_db = AsyncMock()
|
| 336 |
+
mock_session.return_value.__aenter__.return_value = mock_db
|
| 337 |
+
|
| 338 |
+
with patch('services.credit_service.middleware.CreditTransactionManager') as mock_tm:
|
| 339 |
+
mock_transaction = MagicMock()
|
| 340 |
+
mock_transaction.transaction_id = "ctx_test123"
|
| 341 |
+
mock_tm.reserve_credits = AsyncMock(return_value=mock_transaction)
|
| 342 |
+
|
| 343 |
+
# Confirm will fail, but response should still be returned
|
| 344 |
+
mock_tm.confirm_credits = AsyncMock(side_effect=Exception("Confirm failed"))
|
| 345 |
+
|
| 346 |
+
async def mock_call_next(request):
|
| 347 |
+
async def body_iterator():
|
| 348 |
+
yield b'{"result": "success"}'
|
| 349 |
+
|
| 350 |
+
response = Response(content=b'{"result": "success"}', status_code=200)
|
| 351 |
+
response.body_iterator = body_iterator()
|
| 352 |
+
return response
|
| 353 |
+
|
| 354 |
+
response = await credit_middleware.dispatch(mock_request, mock_call_next)
|
| 355 |
+
|
| 356 |
+
# Response should still be 200 even though confirm failed
|
| 357 |
+
assert response.status_code == 200
|
tests/test_credit_transaction_manager.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test suite for Credit Transaction Manager
|
| 3 |
+
|
| 4 |
+
Tests all credit transaction operations including:
|
| 5 |
+
- Reserve credits
|
| 6 |
+
- Confirm credits
|
| 7 |
+
- Refund credits
|
| 8 |
+
- Add credits (purchases)
|
| 9 |
+
- Balance verification
|
| 10 |
+
- Transaction history
|
| 11 |
+
"""
|
| 12 |
+
import pytest
|
| 13 |
+
import uuid
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
| 16 |
+
from sqlalchemy.pool import StaticPool
|
| 17 |
+
|
| 18 |
+
from core.models import Base, User, CreditTransaction
|
| 19 |
+
from services.credit_service.transaction_manager import (
|
| 20 |
+
CreditTransactionManager,
|
| 21 |
+
InsufficientCreditsError,
|
| 22 |
+
TransactionNotFoundError,
|
| 23 |
+
UserNotFoundError
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Test database setup
|
| 28 |
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
async def engine():
|
| 32 |
+
"""Create test database engine."""
|
| 33 |
+
engine = create_async_engine(
|
| 34 |
+
TEST_DATABASE_URL,
|
| 35 |
+
connect_args={"check_same_thread": False},
|
| 36 |
+
poolclass=StaticPool,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
async with engine.begin() as conn:
|
| 40 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 41 |
+
|
| 42 |
+
yield engine
|
| 43 |
+
|
| 44 |
+
async with engine.begin() as conn:
|
| 45 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 46 |
+
|
| 47 |
+
await engine.dispose()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture
|
| 51 |
+
async def session(engine):
|
| 52 |
+
"""Create test database session."""
|
| 53 |
+
async_session = async_sessionmaker(
|
| 54 |
+
engine, class_=AsyncSession, expire_on_commit=False
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
async with async_session() as session:
|
| 58 |
+
yield session
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture
|
| 62 |
+
async def test_user(session):
|
| 63 |
+
"""Create a test user with 100 credits."""
|
| 64 |
+
user = User(
|
| 65 |
+
user_id=f"test_{uuid.uuid4().hex[:8]}",
|
| 66 |
+
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
| 67 |
+
credits=100,
|
| 68 |
+
is_active=True
|
| 69 |
+
)
|
| 70 |
+
session.add(user)
|
| 71 |
+
await session.commit()
|
| 72 |
+
await session.refresh(user)
|
| 73 |
+
return user
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# =============================================================================
|
| 77 |
+
# Reserve Credits Tests
|
| 78 |
+
# =============================================================================
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_reserve_credits_success(session, test_user):
|
| 82 |
+
"""Test successfully reserving credits."""
|
| 83 |
+
initial_balance = test_user.credits
|
| 84 |
+
|
| 85 |
+
transaction = await CreditTransactionManager.reserve_credits(
|
| 86 |
+
session=session,
|
| 87 |
+
user=test_user,
|
| 88 |
+
amount=10,
|
| 89 |
+
source="test",
|
| 90 |
+
reference_type="test",
|
| 91 |
+
reference_id="test_123",
|
| 92 |
+
reason="Test reservation"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
await session.commit()
|
| 96 |
+
await session.refresh(test_user)
|
| 97 |
+
|
| 98 |
+
# Verify transaction
|
| 99 |
+
assert transaction.transaction_type == "reserve"
|
| 100 |
+
assert transaction.amount == -10
|
| 101 |
+
assert transaction.balance_before == initial_balance
|
| 102 |
+
assert transaction.balance_after == initial_balance - 10
|
| 103 |
+
assert transaction.user_id == test_user.id
|
| 104 |
+
assert transaction.source == "test"
|
| 105 |
+
|
| 106 |
+
# Verify user balance
|
| 107 |
+
assert test_user.credits == initial_balance - 10
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_reserve_credits_insufficient_funds(session, test_user):
|
| 112 |
+
"""Test reserving more credits than available."""
|
| 113 |
+
test_user.credits = 5
|
| 114 |
+
await session.commit()
|
| 115 |
+
|
| 116 |
+
with pytest.raises(InsufficientCreditsError):
|
| 117 |
+
await CreditTransactionManager.reserve_credits(
|
| 118 |
+
session=session,
|
| 119 |
+
user=test_user,
|
| 120 |
+
amount=10,
|
| 121 |
+
source="test",
|
| 122 |
+
reference_type="test",
|
| 123 |
+
reference_id="test_123"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Balance should be unchanged
|
| 127 |
+
await session.refresh(test_user)
|
| 128 |
+
assert test_user.credits == 5
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@pytest.mark.asyncio
|
| 132 |
+
async def test_reserve_credits_exact_amount(session, test_user):
|
| 133 |
+
"""Test reserving exact credit balance."""
|
| 134 |
+
test_user.credits = 10
|
| 135 |
+
await session.commit()
|
| 136 |
+
|
| 137 |
+
transaction = await CreditTransactionManager.reserve_credits(
|
| 138 |
+
session=session,
|
| 139 |
+
user=test_user,
|
| 140 |
+
amount=10,
|
| 141 |
+
source="test",
|
| 142 |
+
reference_type="test",
|
| 143 |
+
reference_id="test_123"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
await session.commit()
|
| 147 |
+
await session.refresh(test_user)
|
| 148 |
+
|
| 149 |
+
assert test_user.credits == 0
|
| 150 |
+
assert transaction.balance_after == 0
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# =============================================================================
|
| 154 |
+
# Confirm Credits Tests
|
| 155 |
+
# =============================================================================
|
| 156 |
+
|
| 157 |
+
@pytest.mark.asyncio
|
| 158 |
+
async def test_confirm_credits_success(session, test_user):
|
| 159 |
+
"""Test confirming reserved credits."""
|
| 160 |
+
# First reserve credits
|
| 161 |
+
reserve_tx = await CreditTransactionManager.reserve_credits(
|
| 162 |
+
session=session,
|
| 163 |
+
user=test_user,
|
| 164 |
+
amount=10,
|
| 165 |
+
source="test",
|
| 166 |
+
reference_type="test",
|
| 167 |
+
reference_id="test_123"
|
| 168 |
+
)
|
| 169 |
+
await session.commit()
|
| 170 |
+
|
| 171 |
+
# Then confirm
|
| 172 |
+
confirm_tx = await CreditTransactionManager.confirm_credits(
|
| 173 |
+
session=session,
|
| 174 |
+
transaction_id=reserve_tx.transaction_id,
|
| 175 |
+
metadata={"status": "success"}
|
| 176 |
+
)
|
| 177 |
+
await session.commit()
|
| 178 |
+
|
| 179 |
+
# Verify confirmation
|
| 180 |
+
assert confirm_tx.transaction_type == "confirm"
|
| 181 |
+
assert confirm_tx.amount == 0 # No balance change
|
| 182 |
+
assert confirm_tx.user_id == test_user.id
|
| 183 |
+
assert confirm_tx.metadata["original_transaction_id"] == reserve_tx.transaction_id
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@pytest.mark.asyncio
|
| 187 |
+
async def test_confirm_credits_nonexistent_transaction(session, test_user):
|
| 188 |
+
"""Test confirming a non-existent transaction."""
|
| 189 |
+
with pytest.raises(TransactionNotFoundError):
|
| 190 |
+
await CreditTransactionManager.confirm_credits(
|
| 191 |
+
session=session,
|
| 192 |
+
transaction_id="nonexistent_tx_id"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# =============================================================================
|
| 197 |
+
# Refund Credits Tests
|
| 198 |
+
# =============================================================================
|
| 199 |
+
|
| 200 |
+
@pytest.mark.asyncio
|
| 201 |
+
async def test_refund_credits_success(session, test_user):
|
| 202 |
+
"""Test refunding reserved credits."""
|
| 203 |
+
initial_balance = test_user.credits
|
| 204 |
+
|
| 205 |
+
# Reserve credits
|
| 206 |
+
reserve_tx = await CreditTransactionManager.reserve_credits(
|
| 207 |
+
session=session,
|
| 208 |
+
user=test_user,
|
| 209 |
+
amount=10,
|
| 210 |
+
source="test",
|
| 211 |
+
reference_type="test",
|
| 212 |
+
reference_id="test_123"
|
| 213 |
+
)
|
| 214 |
+
await session.commit()
|
| 215 |
+
await session.refresh(test_user)
|
| 216 |
+
|
| 217 |
+
balance_after_reserve = test_user.credits
|
| 218 |
+
assert balance_after_reserve == initial_balance - 10
|
| 219 |
+
|
| 220 |
+
# Refund
|
| 221 |
+
refund_tx = await CreditTransactionManager.refund_credits(
|
| 222 |
+
session=session,
|
| 223 |
+
transaction_id=reserve_tx.transaction_id,
|
| 224 |
+
reason="Test failed - refunding",
|
| 225 |
+
metadata={"error": "test_error"}
|
| 226 |
+
)
|
| 227 |
+
await session.commit()
|
| 228 |
+
await session.refresh(test_user)
|
| 229 |
+
|
| 230 |
+
# Verify refund
|
| 231 |
+
assert refund_tx.transaction_type == "refund"
|
| 232 |
+
assert refund_tx.amount == 10 # Positive for addition
|
| 233 |
+
assert refund_tx.balance_before == balance_after_reserve
|
| 234 |
+
assert refund_tx.balance_after == initial_balance
|
| 235 |
+
assert test_user.credits == initial_balance
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@pytest.mark.asyncio
|
| 239 |
+
async def test_refund_credits_nonexistent_transaction(session, test_user):
|
| 240 |
+
"""Test refunding a non-existent transaction."""
|
| 241 |
+
with pytest.raises(TransactionNotFoundError):
|
| 242 |
+
await CreditTransactionManager.refund_credits(
|
| 243 |
+
session=session,
|
| 244 |
+
transaction_id="nonexistent_tx_id",
|
| 245 |
+
reason="Test refund"
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# =============================================================================
|
| 250 |
+
# Add Credits Tests (Purchases)
|
| 251 |
+
# =============================================================================
|
| 252 |
+
|
| 253 |
+
@pytest.mark.asyncio
|
| 254 |
+
async def test_add_credits_success(session, test_user):
|
| 255 |
+
"""Test adding credits from purchase."""
|
| 256 |
+
initial_balance = test_user.credits
|
| 257 |
+
|
| 258 |
+
transaction = await CreditTransactionManager.add_credits(
|
| 259 |
+
session=session,
|
| 260 |
+
user=test_user,
|
| 261 |
+
amount=50,
|
| 262 |
+
source="payment",
|
| 263 |
+
reference_type="payment",
|
| 264 |
+
reference_id="pay_123",
|
| 265 |
+
reason="Purchase: 50 credits",
|
| 266 |
+
metadata={"package_id": "basic"}
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
await session.commit()
|
| 270 |
+
await session.refresh(test_user)
|
| 271 |
+
|
| 272 |
+
# Verify transaction
|
| 273 |
+
assert transaction.transaction_type == "purchase"
|
| 274 |
+
assert transaction.amount == 50
|
| 275 |
+
assert transaction.balance_before == initial_balance
|
| 276 |
+
assert transaction.balance_after == initial_balance + 50
|
| 277 |
+
|
| 278 |
+
# Verify balance
|
| 279 |
+
assert test_user.credits == initial_balance + 50
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# =============================================================================
|
| 283 |
+
# Balance Verification Tests
|
| 284 |
+
# =============================================================================
|
| 285 |
+
|
| 286 |
+
@pytest.mark.asyncio
|
| 287 |
+
async def test_get_balance(session, test_user):
|
| 288 |
+
"""Test getting current balance."""
|
| 289 |
+
balance = await CreditTransactionManager.get_balance(
|
| 290 |
+
session=session,
|
| 291 |
+
user_id=test_user.id
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
assert balance == test_user.credits
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@pytest.mark.asyncio
|
| 298 |
+
async def test_get_balance_with_verification(session, test_user):
|
| 299 |
+
"""Test balance verification against transaction history."""
|
| 300 |
+
# Perform some transactions
|
| 301 |
+
await CreditTransactionManager.reserve_credits(
|
| 302 |
+
session=session,
|
| 303 |
+
user=test_user,
|
| 304 |
+
amount=10,
|
| 305 |
+
source="test",
|
| 306 |
+
reference_type="test",
|
| 307 |
+
reference_id="test_1"
|
| 308 |
+
)
|
| 309 |
+
await session.commit()
|
| 310 |
+
|
| 311 |
+
await CreditTransactionManager.add_credits(
|
| 312 |
+
session=session,
|
| 313 |
+
user=test_user,
|
| 314 |
+
amount=20,
|
| 315 |
+
source="test",
|
| 316 |
+
reference_type="test",
|
| 317 |
+
reference_id="test_2"
|
| 318 |
+
)
|
| 319 |
+
await session.commit()
|
| 320 |
+
|
| 321 |
+
# Verify balance
|
| 322 |
+
balance = await CreditTransactionManager.get_balance(
|
| 323 |
+
session=session,
|
| 324 |
+
user_id=test_user.id,
|
| 325 |
+
verify=True
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
await session.refresh(test_user)
|
| 329 |
+
assert balance == test_user.credits
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@pytest.mark.asyncio
|
| 333 |
+
async def test_get_balance_nonexistent_user(session):
|
| 334 |
+
"""Test getting balance for non-existent user."""
|
| 335 |
+
with pytest.raises(UserNotFoundError):
|
| 336 |
+
await CreditTransactionManager.get_balance(
|
| 337 |
+
session=session,
|
| 338 |
+
user_id=99999
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# =============================================================================
|
| 343 |
+
# Transaction History Tests
|
| 344 |
+
# =============================================================================
|
| 345 |
+
|
| 346 |
+
@pytest.mark.asyncio
|
| 347 |
+
async def test_get_transaction_history(session, test_user):
|
| 348 |
+
"""Test getting transaction history."""
|
| 349 |
+
# Create multiple transactions
|
| 350 |
+
await CreditTransactionManager.reserve_credits(
|
| 351 |
+
session=session,
|
| 352 |
+
user=test_user,
|
| 353 |
+
amount=10,
|
| 354 |
+
source="test",
|
| 355 |
+
reference_type="test",
|
| 356 |
+
reference_id="test_1"
|
| 357 |
+
)
|
| 358 |
+
await session.commit()
|
| 359 |
+
|
| 360 |
+
await CreditTransactionManager.add_credits(
|
| 361 |
+
session=session,
|
| 362 |
+
user=test_user,
|
| 363 |
+
amount=20,
|
| 364 |
+
source="test",
|
| 365 |
+
reference_type="test",
|
| 366 |
+
reference_id="test_2"
|
| 367 |
+
)
|
| 368 |
+
await session.commit()
|
| 369 |
+
|
| 370 |
+
# Get history
|
| 371 |
+
history = await CreditTransactionManager.get_transaction_history(
|
| 372 |
+
session=session,
|
| 373 |
+
user_id=test_user.id,
|
| 374 |
+
limit=10
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
assert len(history) == 2
|
| 378 |
+
assert history[0].transaction_type in ["reserve", "purchase"]
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@pytest.mark.asyncio
|
| 382 |
+
async def test_get_transaction_history_filtered(session, test_user):
|
| 383 |
+
"""Test getting filtered transaction history."""
|
| 384 |
+
# Create different transaction types
|
| 385 |
+
await CreditTransactionManager.reserve_credits(
|
| 386 |
+
session=session,
|
| 387 |
+
user=test_user,
|
| 388 |
+
amount=10,
|
| 389 |
+
source="test",
|
| 390 |
+
reference_type="test",
|
| 391 |
+
reference_id="test_1"
|
| 392 |
+
)
|
| 393 |
+
await session.commit()
|
| 394 |
+
|
| 395 |
+
await CreditTransactionManager.add_credits(
|
| 396 |
+
session=session,
|
| 397 |
+
user=test_user,
|
| 398 |
+
amount=20,
|
| 399 |
+
source="payment",
|
| 400 |
+
reference_type="payment",
|
| 401 |
+
reference_id="pay_1"
|
| 402 |
+
)
|
| 403 |
+
await session.commit()
|
| 404 |
+
|
| 405 |
+
# Filter by purchase only
|
| 406 |
+
history = await CreditTransactionManager.get_transaction_history(
|
| 407 |
+
session=session,
|
| 408 |
+
user_id=test_user.id,
|
| 409 |
+
transaction_type="purchase"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
assert len(history) == 1
|
| 413 |
+
assert history[0].transaction_type == "purchase"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# =============================================================================
|
| 417 |
+
# Integration Tests
|
| 418 |
+
# =============================================================================
|
| 419 |
+
|
| 420 |
+
@pytest.mark.asyncio
|
| 421 |
+
async def test_full_transaction_flow(session, test_user):
|
| 422 |
+
"""Test complete transaction flow: reserve → confirm."""
|
| 423 |
+
initial_balance = test_user.credits
|
| 424 |
+
|
| 425 |
+
# Reserve
|
| 426 |
+
reserve_tx = await CreditTransactionManager.reserve_credits(
|
| 427 |
+
session=session,
|
| 428 |
+
user=test_user,
|
| 429 |
+
amount=10,
|
| 430 |
+
source="middleware",
|
| 431 |
+
reference_type="request",
|
| 432 |
+
reference_id="POST:/api/endpoint"
|
| 433 |
+
)
|
| 434 |
+
await session.commit()
|
| 435 |
+
|
| 436 |
+
# Confirm
|
| 437 |
+
confirm_tx = await CreditTransactionManager.confirm_credits(
|
| 438 |
+
session=session,
|
| 439 |
+
transaction_id=reserve_tx.transaction_id
|
| 440 |
+
)
|
| 441 |
+
await session.commit()
|
| 442 |
+
|
| 443 |
+
# Verify final state
|
| 444 |
+
await session.refresh(test_user)
|
| 445 |
+
assert test_user.credits == initial_balance - 10
|
| 446 |
+
|
| 447 |
+
# Verify transaction history
|
| 448 |
+
history = await CreditTransactionManager.get_transaction_history(
|
| 449 |
+
session=session,
|
| 450 |
+
user_id=test_user.id
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
assert len(history) == 2
|
| 454 |
+
assert history[1].transaction_type == "reserve"
|
| 455 |
+
assert history[0].transaction_type == "confirm"
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@pytest.mark.asyncio
|
| 459 |
+
async def test_full_refund_flow(session, test_user):
|
| 460 |
+
"""Test complete refund flow: reserve → refund."""
|
| 461 |
+
initial_balance = test_user.credits
|
| 462 |
+
|
| 463 |
+
# Reserve
|
| 464 |
+
reserve_tx = await CreditTransactionManager.reserve_credits(
|
| 465 |
+
session=session,
|
| 466 |
+
user=test_user,
|
| 467 |
+
amount=10,
|
| 468 |
+
source="middleware",
|
| 469 |
+
reference_type="request",
|
| 470 |
+
reference_id="POST:/api/endpoint"
|
| 471 |
+
)
|
| 472 |
+
await session.commit()
|
| 473 |
+
|
| 474 |
+
# Refund
|
| 475 |
+
refund_tx = await CreditTransactionManager.refund_credits(
|
| 476 |
+
session=session,
|
| 477 |
+
transaction_id=reserve_tx.transaction_id,
|
| 478 |
+
reason="Request failed"
|
| 479 |
+
)
|
| 480 |
+
await session.commit()
|
| 481 |
+
|
| 482 |
+
# Verify final state
|
| 483 |
+
await session.refresh(test_user)
|
| 484 |
+
assert test_user.credits == initial_balance # Back to original
|
| 485 |
+
|
| 486 |
+
# Verify transaction history
|
| 487 |
+
history = await CreditTransactionManager.get_transaction_history(
|
| 488 |
+
session=session,
|
| 489 |
+
user_id=test_user.id
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
assert len(history) == 2
|
| 493 |
+
assert history[1].transaction_type == "reserve"
|
| 494 |
+
assert history[0].transaction_type == "refund"
|