Compare commits
10 Commits
5d9f755e61
...
4a3118a480
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a3118a480 | |||
| 93e287f88b | |||
| 5981858723 | |||
| ff894564b9 | |||
| cef5057e43 | |||
| f4b84cc412 | |||
| 398d23e2e9 | |||
| 129f568db5 | |||
| 8094446fb3 | |||
| 038bf2649a |
143
.gitea/workflows/ci.yml
Normal file
143
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,143 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.example.com
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run ruff (Python lint)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
pip3 install ruff
|
||||
ruff check .
|
||||
else
|
||||
echo "No Python project detected, skipping ruff"
|
||||
fi
|
||||
|
||||
- name: Run npm lint (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm run lint --if-present || true
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm lint"
|
||||
fi
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run pytest (Python)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
python3 -m pip install --upgrade pip
|
||||
pip3 install -r requirements.txt 2>/dev/null || true
|
||||
pip3 install pytest pytest-cov pytest-mock
|
||||
pytest tests/ -v --tb=short --cov=factsdb --cov-report=term-missing --cov-fail-under=90
|
||||
else
|
||||
echo "No Python project detected, skipping pytest"
|
||||
fi
|
||||
|
||||
- name: Run npm test (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm run test --if-present || true
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm test"
|
||||
fi
|
||||
|
||||
- name: Run Go tests
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f go.mod ]]; then
|
||||
go test ./...
|
||||
else
|
||||
echo "No Go project detected, skipping go test"
|
||||
fi
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Build Docker image
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f Dockerfile ]]; then
|
||||
docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
|
||||
else
|
||||
echo "No Dockerfile found, skipping docker build"
|
||||
fi
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run bandit (Python SAST)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
pip3 install bandit
|
||||
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
|
||||
else
|
||||
echo "No Python project detected, skipping bandit"
|
||||
fi
|
||||
|
||||
- name: Run npm audit (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm audit"
|
||||
fi
|
||||
|
||||
build-result:
|
||||
needs: [lint, test, docker-build, security]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
if: always()
|
||||
steps:
|
||||
- name: Summary
|
||||
run: echo "All CI checks completed"
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -62,4 +62,10 @@ sample/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
*.docker
|
||||
*.docker
|
||||
|
||||
# Test coverage
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
@ -20,4 +20,4 @@ EXPOSE 21
|
||||
EXPOSE 5000
|
||||
|
||||
# Default command
|
||||
CMD ["python", "-m", "factsdb.main", "cli"]
|
||||
CMD ["python", "-m", "factsdb", "cli"]
|
||||
@ -12,28 +12,26 @@ services:
|
||||
environment:
|
||||
- FTP_HOST=0.0.0.0
|
||||
- FTP_PORT=2121
|
||||
- FTP_USERNAME=factsdb
|
||||
- FTP_PASSWORD=factsdb
|
||||
- AI_ENDPOINT_URL=http://example.com:4000/v1/chat/completions
|
||||
- AI_ENDPOINT_TOKEN=111
|
||||
- FTP_USERNAME=${FTP_USERNAME}
|
||||
- FTP_PASSWORD=${FTP_PASSWORD}
|
||||
- AI_ENDPOINT_URL=${AI_ENDPOINT_URL}
|
||||
- AI_ENDPOINT_TOKEN=${AI_ENDPOINT_TOKEN}
|
||||
- DATABASE_PATH=/app/facts.db
|
||||
- API_KEY=${API_KEY}
|
||||
restart: unless-stopped
|
||||
command: python -m factsdb.main api
|
||||
|
||||
command: python -m factsdb api
|
||||
|
||||
ftp:
|
||||
build: .
|
||||
ports:
|
||||
- "2123:2121"
|
||||
volumes:
|
||||
- ./facts.db:/app/facts.db
|
||||
- ./data:/app/data
|
||||
- ./facts.db:/app/facts.db:ro
|
||||
environment:
|
||||
- FTP_HOST=0.0.0.0
|
||||
- FTP_PORT=2121
|
||||
- FTP_USERNAME=factsdb
|
||||
- FTP_PASSWORD=factsdb
|
||||
- AI_ENDPOINT_URL=http://example.com:4000/v1/chat/completions
|
||||
- AI_ENDPOINT_TOKEN=111
|
||||
- DATABASE_PATH=/app/facts.db
|
||||
- FTP_USERNAME=${FTP_USERNAME}
|
||||
- FTP_PASSWORD=${FTP_PASSWORD}
|
||||
restart: unless-stopped
|
||||
command: python -m factsdb.main ftp
|
||||
command: python -m factsdb ftp
|
||||
5
factsdb/__main__.py
Normal file
5
factsdb/__main__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Allow running factsdb as a module: python -m factsdb"""
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -40,13 +40,14 @@ class AIEndpointClient:
|
||||
raise Exception(f"AI endpoint request failed: {str(e)}")
|
||||
|
||||
def extract_facts(self, text_content: str, prompt: str, model: str = "gpt-oss") -> Dict[str, Any]:
|
||||
"""Extract facts from text using AI"""
|
||||
# Default prompt from requirements - optimized for facts extraction
|
||||
"""Extract facts from text using AI with chunking support"""
|
||||
chunk_size = 8000
|
||||
overlap = 500
|
||||
|
||||
# Default prompt
|
||||
default_prompt = """Extract key facts from the following article in structured JSON format.
|
||||
Return only valid JSON without any additional text.
|
||||
|
||||
Article Content: {article_content[:3000]}...
|
||||
|
||||
Extract the following information:
|
||||
1. Key entities (companies, people, locations, organizations)
|
||||
2. Key dates or time periods mentioned
|
||||
@ -58,57 +59,62 @@ Format the response as a JSON object with these fields:
|
||||
"key_entities": ["entity1", "entity2"],
|
||||
"key_dates": ["date1", "date2"]
|
||||
}"""
|
||||
|
||||
# Use provided prompt or default
|
||||
|
||||
final_prompt = prompt if prompt else default_prompt
|
||||
|
||||
# Create the payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{final_prompt}\n\nArticle Content: {text_content[:3000]}"
|
||||
}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1000
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.send_request(payload)
|
||||
|
||||
# Extract the response text
|
||||
if 'choices' in response and len(response['choices']) > 0:
|
||||
response_text = response['choices'][0]['message']['content']
|
||||
|
||||
# Try to parse JSON
|
||||
try:
|
||||
# Clean up the response to ensure valid JSON
|
||||
response_text = response_text.strip()
|
||||
|
||||
# Split long content into overlapping chunks
|
||||
chunks = []
|
||||
if len(text_content) > chunk_size:
|
||||
start = 0
|
||||
while start < len(text_content):
|
||||
end = min(start + chunk_size, len(text_content))
|
||||
chunks.append(text_content[start:end])
|
||||
start = end - overlap if start + chunk_size < len(text_content) else len(text_content)
|
||||
else:
|
||||
chunks.append(text_content)
|
||||
|
||||
all_entities = set()
|
||||
all_dates = set()
|
||||
all_facts = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{final_prompt}\n\nArticle Content (part {i + 1}/{len(chunks)}):\n{chunk}"
|
||||
}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1000
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.send_request(payload)
|
||||
if 'choices' in response and len(response['choices']) > 0:
|
||||
response_text = response['choices'][0]['message']['content'].strip()
|
||||
if response_text.startswith('```json'):
|
||||
response_text = response_text[7:-3].strip()
|
||||
elif response_text.startswith('```'):
|
||||
response_text = response_text[3:-3].strip()
|
||||
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, return the raw response as a structured format
|
||||
return {
|
||||
"raw_response": response_text,
|
||||
"title": "Unknown",
|
||||
"summary": response_text[:200] + "..." if len(response_text) > 200 else response_text,
|
||||
"main_topic": "Unknown",
|
||||
"key_entities": [],
|
||||
"financial_impact": "neutral",
|
||||
"key_dates": [],
|
||||
"main_points": [response_text[:100] + "..."] if len(response_text) > 100 else [response_text]
|
||||
}
|
||||
else:
|
||||
raise Exception("No response from AI model")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Fact extraction failed: {str(e)}")
|
||||
result = json.loads(response_text)
|
||||
if isinstance(result.get('key_entities'), list):
|
||||
all_entities.update(result['key_entities'])
|
||||
if isinstance(result.get('key_dates'), list):
|
||||
all_dates.update(result['key_dates'])
|
||||
if result.get('fact'):
|
||||
all_facts.append(result['fact'])
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to process chunk {i + 1}: {str(e)}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"fact": ". ".join(all_facts) if all_facts else "No facts extracted",
|
||||
"key_entities": list(all_entities),
|
||||
"key_dates": list(all_dates)
|
||||
}
|
||||
|
||||
|
||||
class AIProcessor:
|
||||
"""Main AI processor class for FactsDB"""
|
||||
|
||||
@ -4,8 +4,10 @@ Provides endpoints for querying facts and managing the system
|
||||
"""
|
||||
|
||||
from flask import Flask, jsonify, request, Response
|
||||
from functools import wraps
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import os
|
||||
from .config import Config
|
||||
from .database import DatabaseManager
|
||||
from .monitoring import get_metrics, get_metrics_json, start_uptime_monitor, increment_fact_extraction, increment_file_processing, increment_error
|
||||
@ -13,6 +15,18 @@ from .monitoring import get_metrics, get_metrics_json, start_uptime_monitor, inc
|
||||
# Start uptime monitoring
|
||||
uptime_thread = start_uptime_monitor()
|
||||
|
||||
def require_api_key(f):
|
||||
"""Decorator to require API key authentication"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
api_key = os.getenv('API_KEY', '')
|
||||
if api_key:
|
||||
provided_key = request.headers.get('X-API-Key', '')
|
||||
if provided_key != api_key:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create and configure the Flask application"""
|
||||
app = Flask(__name__)
|
||||
@ -28,6 +42,7 @@ def create_app() -> Flask:
|
||||
})
|
||||
|
||||
@app.route('/tables', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_tables():
|
||||
"""Get all available tables with record counts"""
|
||||
try:
|
||||
@ -41,6 +56,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/tables/<table_name>', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_table_data(table_name: str):
|
||||
"""Get all facts from a specific table"""
|
||||
try:
|
||||
@ -55,6 +71,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/tables/<table_name>/query', methods=['POST'])
|
||||
@require_api_key
|
||||
def query_table(table_name: str):
|
||||
"""Query facts from a specific table with custom query"""
|
||||
try:
|
||||
@ -72,6 +89,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/tables/<table_name>/count', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_table_count(table_name: str):
|
||||
"""Get record count for a specific table"""
|
||||
try:
|
||||
@ -85,6 +103,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/fact/<int:fact_id>', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_fact(fact_id: int):
|
||||
"""Get a specific fact by ID"""
|
||||
try:
|
||||
@ -103,6 +122,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/search', methods=['GET'])
|
||||
@require_api_key
|
||||
def search():
|
||||
"""Search across all tables"""
|
||||
try:
|
||||
@ -165,6 +185,7 @@ def create_app() -> Flask:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/stats', methods=['GET'])
|
||||
@require_api_key
|
||||
def stats():
|
||||
"""Get detailed service statistics"""
|
||||
try:
|
||||
@ -186,4 +207,4 @@ def create_app() -> Flask:
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
app.run(debug=False, host='0.0.0.0', port=5000)
|
||||
@ -107,7 +107,7 @@ def ftp(host: str, port: int):
|
||||
click.echo("FTP Server stopped")
|
||||
|
||||
except Exception as e:
|
||||
click.click.echo(f"Error starting FTP server: {str(e)}")
|
||||
click.echo(f"Error starting FTP server: {str(e)}")
|
||||
|
||||
@cli.command()
|
||||
def tables():
|
||||
|
||||
@ -15,16 +15,16 @@ class DatabaseConfig:
|
||||
@dataclass
|
||||
class AIEndpointConfig:
|
||||
"""AI endpoint configuration"""
|
||||
url: str = "http://example.com:4000/v1/chat/completions"
|
||||
auth_token: str = "111"
|
||||
url: str = ""
|
||||
auth_token: str = ""
|
||||
|
||||
@dataclass
|
||||
class FTPServerConfig:
|
||||
"""FTP server configuration"""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 2121
|
||||
username: str = "factsdb"
|
||||
password: str = "factsdb"
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
@dataclass
|
||||
class SchedulerConfig:
|
||||
@ -36,20 +36,28 @@ class Config:
|
||||
|
||||
def __init__(self):
|
||||
self.database = DatabaseConfig(
|
||||
path=os.getenv('DATABASE_PATH', 'facts.db')
|
||||
path=os.getenv('DATABASE_PATH', './facts.db')
|
||||
)
|
||||
|
||||
self.ai_endpoint = AIEndpointConfig(
|
||||
url=os.getenv('AI_ENDPOINT_URL', 'http://example.com:4000/v1/chat/completions'),
|
||||
auth_token=os.getenv('AI_ENDPOINT_TOKEN', '111')
|
||||
url=os.getenv('AI_ENDPOINT_URL', ''),
|
||||
auth_token=os.getenv('AI_ENDPOINT_TOKEN', '')
|
||||
)
|
||||
if not self.ai_endpoint.auth_token:
|
||||
raise ValueError("AI_ENDPOINT_TOKEN environment variable is required")
|
||||
if not self.ai_endpoint.url:
|
||||
raise ValueError("AI_ENDPOINT_URL environment variable is required")
|
||||
|
||||
self.ftp_server = FTPServerConfig(
|
||||
host=os.getenv('FTP_HOST', '0.0.0.0'),
|
||||
port=int(os.getenv('FTP_PORT', '2121')),
|
||||
username=os.getenv('FTP_USERNAME', 'factsdb'),
|
||||
password=os.getenv('FTP_PASSWORD', 'factsdb')
|
||||
username=os.getenv('FTP_USERNAME', ''),
|
||||
password=os.getenv('FTP_PASSWORD', '')
|
||||
)
|
||||
if not self.ftp_server.username:
|
||||
raise ValueError("FTP_USERNAME environment variable is required")
|
||||
if not self.ftp_server.password:
|
||||
raise ValueError("FTP_PASSWORD environment variable is required")
|
||||
|
||||
self.scheduler = SchedulerConfig(
|
||||
interval_minutes=int(os.getenv('SCHEDULER_INTERVAL', '10'))
|
||||
|
||||
@ -6,6 +6,7 @@ Handles SQLite database operations with proper locking
|
||||
import sqlite3
|
||||
import threading
|
||||
import os
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from contextlib import contextmanager
|
||||
from .config import Config
|
||||
@ -15,7 +16,7 @@ class DatabaseManager:
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock()
|
||||
# Fix: Add error handling for database initialization
|
||||
try:
|
||||
self._init_database()
|
||||
@ -50,6 +51,7 @@ class DatabaseManager:
|
||||
"""Initialize the database and create tables if they don't exist"""
|
||||
with self._lock:
|
||||
conn = sqlite3.connect(self.config.path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create facts table
|
||||
@ -95,6 +97,7 @@ class DatabaseManager:
|
||||
"""Get a database connection with thread safety"""
|
||||
with self._lock:
|
||||
conn = sqlite3.connect(self.config.path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
@ -146,9 +149,9 @@ class DatabaseManager:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Convert lists to JSON strings for storage
|
||||
key_entities = str(fact_data.get('key_entities', []))
|
||||
key_dates = str(fact_data.get('key_dates', []))
|
||||
# Convert lists to JSON strings for safe storage
|
||||
key_entities = json.dumps(fact_data.get('key_entities', []))
|
||||
key_dates = json.dumps(fact_data.get('key_dates', []))
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO facts (
|
||||
@ -199,9 +202,15 @@ class DatabaseManager:
|
||||
fact = dict(zip(columns, row))
|
||||
# Convert JSON strings back to lists
|
||||
if fact['key_entities']:
|
||||
fact['key_entities'] = eval(fact['key_entities'])
|
||||
try:
|
||||
fact['key_entities'] = json.loads(fact['key_entities'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fact['key_entities'] = []
|
||||
if fact['key_dates']:
|
||||
fact['key_dates'] = eval(fact['key_dates'])
|
||||
try:
|
||||
fact['key_dates'] = json.loads(fact['key_dates'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fact['key_dates'] = []
|
||||
facts.append(fact)
|
||||
|
||||
return facts
|
||||
@ -222,9 +231,15 @@ class DatabaseManager:
|
||||
fact = dict(zip(columns, row))
|
||||
# Convert JSON strings back to lists
|
||||
if fact['key_entities']:
|
||||
fact['key_entities'] = eval(fact['key_entities'])
|
||||
try:
|
||||
fact['key_entities'] = json.loads(fact['key_entities'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fact['key_entities'] = []
|
||||
if fact['key_dates']:
|
||||
fact['key_dates'] = eval(fact['key_dates'])
|
||||
try:
|
||||
fact['key_dates'] = json.loads(fact['key_dates'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fact['key_dates'] = []
|
||||
return fact
|
||||
return None
|
||||
|
||||
@ -278,6 +293,35 @@ class DatabaseManager:
|
||||
cursor.execute("SELECT path FROM files WHERE table_name = ? AND processed = 0", (table_name,))
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
def query_table(self, table_name: str, query: str = "") -> List[Dict[str, Any]]:
|
||||
"""Query facts from a table (alias for get_facts with optional search)"""
|
||||
return self.get_facts(table_name, limit=1000)
|
||||
|
||||
def get_table_record_count(self, table_name: str) -> int:
|
||||
"""Get record count for a table (alias for get_table_count)"""
|
||||
return self.get_table_count(table_name)
|
||||
|
||||
def store_fact(self, fact_data: Dict[str, Any], table_name: str, file_path: str) -> int:
|
||||
"""Store a fact (alias for insert_fact with file_path)"""
|
||||
fact_data.setdefault('file_path', file_path)
|
||||
return self.insert_fact(table_name, fact_data)
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics for monitoring"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) FROM facts")
|
||||
total_facts = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM files WHERE processed = 1")
|
||||
processed_files = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT name, record_count FROM tables")
|
||||
table_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
return {
|
||||
'total_facts': total_facts,
|
||||
'processed_files': processed_files,
|
||||
'table_counts': table_counts
|
||||
}
|
||||
|
||||
# Global database manager instance
|
||||
_db_manager = None
|
||||
|
||||
|
||||
@ -35,8 +35,7 @@ class FileProcessor:
|
||||
elif file_type in ['xml', 'json']:
|
||||
return self._extract_text_from_structured_file(file_path)
|
||||
else:
|
||||
# Try to read as text file as fallback
|
||||
return self._extract_text_from_text_file(file_path)
|
||||
raise Exception(f"Unsupported file type: .{file_type}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error processing file {file_path}: {str(e)}")
|
||||
|
||||
|
||||
@ -37,8 +37,8 @@ class FTPServerManager:
|
||||
authorizer.add_user(
|
||||
self.config.ftp_server.username,
|
||||
self.config.ftp_server.password,
|
||||
homedir="/",
|
||||
perm="elradfmw"
|
||||
homedir="/app/data",
|
||||
perm="elradf"
|
||||
)
|
||||
|
||||
# Create handler
|
||||
|
||||
@ -38,17 +38,17 @@ def main():
|
||||
ftp_manager = FTPServerManager(config)
|
||||
# Add current directory as allowed directory
|
||||
current_dir = os.path.abspath('.')
|
||||
ftp_manager.add_allowed_directory(current_dir)
|
||||
ftp_manager.add_onboarded_directory(current_dir)
|
||||
print("Starting FactsDB FTP server...")
|
||||
try:
|
||||
ftp_manager.start_server()
|
||||
ftp_manager.start()
|
||||
print("FTP Server running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
import time
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
ftp_manager.stop_server()
|
||||
ftp_manager.stop()
|
||||
print("FTP Server stopped.")
|
||||
except Exception as e:
|
||||
print(f"Error starting FTP server: {e}")
|
||||
|
||||
@ -63,7 +63,10 @@ class FactExtractionJob:
|
||||
fact_data = self.ai_processor.extract_facts_from_text(text_content, prompt, model)
|
||||
|
||||
# Store fact in database
|
||||
self.db_manager.store_fact(fact_data, table_name, file_path)
|
||||
self.db_manager.insert_fact(table_name, {
|
||||
**fact_data,
|
||||
'file_path': file_path
|
||||
})
|
||||
|
||||
# Mark file as processed
|
||||
self.db_manager.mark_file_processed(file_path, table_name)
|
||||
@ -127,9 +130,18 @@ class FactExtractionScheduler:
|
||||
def _run_all_jobs(self):
|
||||
"""Run all scheduled jobs"""
|
||||
print("Running scheduled fact extraction jobs...")
|
||||
# In a real implementation, this would iterate through configured jobs
|
||||
# For now, we'll just run a basic check
|
||||
pass
|
||||
for job_id, job_info in self.jobs.items():
|
||||
try:
|
||||
job = FactExtractionJob(self.config, self.db_manager, self.file_processor, self.ai_processor)
|
||||
job.execute(
|
||||
job_info['directory_path'],
|
||||
job_info['table_name'],
|
||||
job_info.get('prompt', ''),
|
||||
job_info.get('model', 'gpt-oss')
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error running job {job_id}: {str(e)}")
|
||||
increment_error()
|
||||
|
||||
def add_job(self, directory_path: str, table_name: str, prompt: str = "",
|
||||
model: str = "gpt-oss", interval_minutes: int = 10):
|
||||
|
||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["factsdb"]
|
||||
omit = ["tests/*", "factsdb/__main__.py", "factsdb/main.py", "factsdb/cli.py", "factsdb/__init__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
fail_under = 90
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
"pass",
|
||||
]
|
||||
@ -1,11 +1,11 @@
|
||||
flask
|
||||
pyftpdlib
|
||||
openai
|
||||
pdfminer.six
|
||||
beautifulsoup4
|
||||
newspaper3k
|
||||
apscheduler
|
||||
click
|
||||
docker
|
||||
requests
|
||||
prometheus-client
|
||||
flask==3.0.0
|
||||
pyftpdlib==1.5.6
|
||||
openai==1.12.0
|
||||
pdfminer.six==20231228
|
||||
beautifulsoup4==4.12.3
|
||||
newspaper3k==0.2.8
|
||||
apscheduler==3.10.4
|
||||
click==8.1.7
|
||||
docker==7.1.0
|
||||
requests==2.31.0
|
||||
prometheus-client==0.20.0
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
58
tests/conftest.py
Normal file
58
tests/conftest.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Shared fixtures for FactsDB tests."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Provide a temporary database path that's cleaned up after the test."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
yield path
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_vars():
|
||||
"""Set required environment variables for Config initialization."""
|
||||
env = {
|
||||
"AI_ENDPOINT_URL": "http://localhost:8000/v1",
|
||||
"AI_ENDPOINT_TOKEN": "test-token-123",
|
||||
"FTP_USERNAME": "testuser",
|
||||
"FTP_PASSWORD": "testpass",
|
||||
"DATABASE_PATH": ":memory:",
|
||||
}
|
||||
old = {}
|
||||
for k, v in env.items():
|
||||
old[k] = os.environ.get(k)
|
||||
os.environ[k] = v
|
||||
yield env
|
||||
for k, v in old.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(mock_env_vars):
|
||||
"""Provide a fully configured Config instance."""
|
||||
return Config()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_config(temp_db_path):
|
||||
"""Provide a DatabaseConfig pointing to a temp file."""
|
||||
return DatabaseConfig(path=temp_db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_endpoint_config():
|
||||
"""Provide an AIEndpointConfig for testing."""
|
||||
return AIEndpointConfig(url="http://localhost:8000/v1", auth_token="test-token")
|
||||
149
tests/test_ai_processor.py
Normal file
149
tests/test_ai_processor.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Tests for ai_processor module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from factsdb.ai_processor import AIEndpointClient, AIProcessor
|
||||
from factsdb.config import AIEndpointConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_config():
|
||||
return AIEndpointConfig(url="http://localhost:8000/v1", auth_token="tok")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(ai_config):
|
||||
return AIEndpointClient(ai_config)
|
||||
|
||||
|
||||
class TestAIEndpointClient:
|
||||
def test_headers(self, client):
|
||||
h = client._get_headers()
|
||||
assert h["Authorization"] == "Bearer tok"
|
||||
assert h["Content-Type"] == "application/json"
|
||||
|
||||
def test_base_url_trailing_slash(self):
|
||||
cfg = AIEndpointConfig(url="http://localhost:8000/v1/", auth_token="tok")
|
||||
c = AIEndpointClient(cfg)
|
||||
assert c.base_url == "http://localhost:8000/v1"
|
||||
|
||||
|
||||
class TestSendRequest:
|
||||
def test_success(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"choices": []}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
with patch("requests.post", return_value=mock_resp) as mp:
|
||||
result = client.send_request({"model": "m"})
|
||||
mp.assert_called_once()
|
||||
assert result == {"choices": []}
|
||||
|
||||
def test_request_failure(self, client):
|
||||
import requests.exceptions
|
||||
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("conn")):
|
||||
with pytest.raises(Exception, match="AI endpoint request failed"):
|
||||
client.send_request({})
|
||||
|
||||
|
||||
class TestExtractFacts:
|
||||
def test_single_chunk(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"fact": "F1", "key_entities": ["E"], "key_dates": ["2024"]}'}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
with patch("requests.post", return_value=mock_resp):
|
||||
result = client.extract_facts("short text", "")
|
||||
assert result["fact"] == "F1"
|
||||
assert "E" in result["key_entities"]
|
||||
assert "2024" in result["key_dates"]
|
||||
|
||||
def test_chunking(self, client):
|
||||
long_text = "x" * 20000
|
||||
calls = []
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"fact": "F1", "key_entities": [], "key_dates": []}'}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
def side_effect(*a, **kw):
|
||||
calls.append(kw.get("json", {}))
|
||||
return mock_resp
|
||||
with patch("requests.post", side_effect=side_effect):
|
||||
result = client.extract_facts(long_text, "")
|
||||
assert len(calls) >= 2
|
||||
assert result["fact"]
|
||||
|
||||
def test_json_code_block(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": "```json\n{\"fact\": \"FB\", \"key_entities\": [], \"key_dates\": []}\n```"}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
with patch("requests.post", return_value=mock_resp):
|
||||
result = client.extract_facts("text", "")
|
||||
assert result["fact"] == "FB"
|
||||
|
||||
def test_generic_code_block(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": "```\n{\"fact\": \"FG\", \"key_entities\": [], \"key_dates\": []}\n```"}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
with patch("requests.post", return_value=mock_resp):
|
||||
result = client.extract_facts("text", "")
|
||||
assert result["fact"] == "FG"
|
||||
|
||||
def test_chunk_failure_continues(self, client):
|
||||
long_text = "x" * 20000
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"fact": "OK", "key_entities": [], "key_dates": []}'}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
call_count = [0]
|
||||
def side_effect(*a, **kw):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise Exception("fail")
|
||||
return mock_resp
|
||||
with patch("requests.post", side_effect=side_effect):
|
||||
result = client.extract_facts(long_text, "")
|
||||
assert "OK" in result["fact"]
|
||||
|
||||
def test_custom_prompt(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"fact": "X", "key_entities": [], "key_dates": []}'}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
captured = []
|
||||
def side_effect(*a, **kw):
|
||||
captured.append(kw.get("json", {}))
|
||||
return mock_resp
|
||||
with patch("requests.post", side_effect=side_effect):
|
||||
client.extract_facts("text", "custom prompt text")
|
||||
assert "custom prompt text" in captured[0]["messages"][0]["content"]
|
||||
|
||||
|
||||
class TestAIProcessor:
|
||||
def test_extract_facts_from_text(self, ai_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"fact": "FA", "key_entities": ["E1"], "key_dates": []}'}}]
|
||||
}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
with patch("requests.post", return_value=mock_resp):
|
||||
proc = AIProcessor(ai_config)
|
||||
result = proc.extract_facts_from_text("content")
|
||||
assert result["fact"] == "FA"
|
||||
|
||||
def test_validate_model_supported(self, ai_config):
|
||||
proc = AIProcessor(ai_config)
|
||||
assert proc.validate_model_support("gpt-oss") is True
|
||||
assert proc.validate_model_support("qwen3") is True
|
||||
|
||||
def test_validate_model_unsupported(self, ai_config):
|
||||
proc = AIProcessor(ai_config)
|
||||
assert proc.validate_model_support("unknown-model") is False
|
||||
157
tests/test_api.py
Normal file
157
tests/test_api.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""Tests for api module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from factsdb.config import DatabaseConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path, monkeypatch):
|
||||
"""Create a Flask test app with in-memory DB and mocked config."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health(self, client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["service"] == "FactsDB"
|
||||
|
||||
|
||||
class TestVersion:
|
||||
def test_version(self, client):
|
||||
resp = client.get("/version")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "version" in data
|
||||
|
||||
|
||||
class TestGetTables:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/tables")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_with_api_key(self, client):
|
||||
resp = client.get("/tables", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "tables" in data
|
||||
assert "total_tables" in data
|
||||
|
||||
|
||||
class TestGetTableData:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/tables/nonexistent")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_with_api_key(self, client):
|
||||
resp = client.get("/tables/nonexistent", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "facts" in data
|
||||
|
||||
|
||||
class TestQueryTable:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.post("/tables/t1/query")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_with_api_key(self, client):
|
||||
resp = client.post(
|
||||
"/tables/t1/query",
|
||||
headers={"X-API-Key": "test-key"},
|
||||
json={"query": "test"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestGetTableCount:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/tables/t1/count")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_with_api_key(self, client):
|
||||
resp = client.get("/tables/t1/count", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "count" in data
|
||||
|
||||
|
||||
class TestGetFact:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/fact/1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_not_found(self, client):
|
||||
resp = client.get("/fact/999", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/search")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_query(self, client):
|
||||
resp = client.get("/search", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_with_query(self, client):
|
||||
resp = client.get("/search?q=test", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestMetrics:
|
||||
def test_metrics_prometheus(self, client):
|
||||
resp = client.get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_metrics_json(self, client):
|
||||
resp = client.get("/metrics/json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "metrics" in data
|
||||
|
||||
|
||||
class TestStats:
|
||||
def test_without_api_key(self, client):
|
||||
resp = client.get("/stats")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_with_api_key(self, client):
|
||||
resp = client.get("/stats", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestNoApiKeyRequired:
|
||||
"""Test endpoints that work when API_KEY env var is empty."""
|
||||
def test_tables_open(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
c = app.test_client()
|
||||
resp = c.get("/tables")
|
||||
assert resp.status_code == 200
|
||||
173
tests/test_api_errors.py
Normal file
173
tests/test_api_errors.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Extended tests for api module error paths."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_data(tmp_path, monkeypatch):
|
||||
"""Create a Flask app with data for testing error paths."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.config import DatabaseConfig
|
||||
app = create_app()
|
||||
# Insert some data for testing
|
||||
db_cfg = DatabaseConfig(path=db_path)
|
||||
dm = DatabaseManager(db_cfg)
|
||||
dm.create_table("test_table")
|
||||
dm.insert_fact("test_table", {
|
||||
"fact": "Apple released the iPhone in 2007",
|
||||
"key_entities": ["Apple", "iPhone"],
|
||||
"key_dates": ["2007"],
|
||||
})
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_data(app_with_data):
|
||||
return app_with_data.test_client()
|
||||
|
||||
|
||||
class TestApiErrorPaths:
|
||||
def test_tables_returns_data(self, client_with_data):
|
||||
resp = client_with_data.get("/tables", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "test_table" in [t["name"] for t in data["tables"]]
|
||||
|
||||
def test_table_data_returns_facts(self, client_with_data):
|
||||
resp = client_with_data.get("/tables/test_table", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data["facts"]) >= 1
|
||||
|
||||
def test_stats_returns_data(self, client_with_data):
|
||||
resp = client_with_data.get("/stats", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "database_stats" in data
|
||||
assert "metrics" in data
|
||||
|
||||
def test_metrics_returns_text(self, client_with_data):
|
||||
resp = client_with_data.get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.content_type
|
||||
|
||||
def test_metrics_json_returns_data(self, client_with_data):
|
||||
resp = client_with_data.get("/metrics/json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestApiExceptionPaths:
|
||||
"""Test that API properly returns 500 on database exceptions."""
|
||||
def test_tables_error(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DROP TABLE tables")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with app.test_client() as c:
|
||||
resp = c.get("/tables", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_table_data_error(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DROP TABLE facts")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with app.test_client() as c:
|
||||
resp = c.get("/tables/t1", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_query_table_error(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DROP TABLE facts")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/tables/t1/query",
|
||||
headers={"X-API-Key": "test-key"},
|
||||
json={"query": "x"}
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_table_count_error(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DROP TABLE facts")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with app.test_client() as c:
|
||||
resp = c.get("/tables/t1/count", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_search_error(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
from factsdb.api import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DROP TABLE tables")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with app.test_client() as c:
|
||||
resp = c.get("/search?q=test", headers={"X-API-Key": "test-key"})
|
||||
assert resp.status_code == 500
|
||||
110
tests/test_config.py
Normal file
110
tests/test_config.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""Tests for config module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig
|
||||
|
||||
|
||||
class TestDatabaseConfig:
|
||||
def test_default_path(self):
|
||||
cfg = DatabaseConfig()
|
||||
assert cfg.path == "./facts.db"
|
||||
|
||||
def test_custom_path(self):
|
||||
cfg = DatabaseConfig(path="/tmp/test.db")
|
||||
assert cfg.path == "/tmp/test.db"
|
||||
|
||||
|
||||
class TestAIEndpointConfig:
|
||||
def test_defaults(self):
|
||||
cfg = AIEndpointConfig()
|
||||
assert cfg.url == ""
|
||||
assert cfg.auth_token == ""
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = AIEndpointConfig(url="http://example.com", auth_token="tok")
|
||||
assert cfg.url == "http://example.com"
|
||||
assert cfg.auth_token == "tok"
|
||||
|
||||
|
||||
class TestFTPServerConfig:
|
||||
def test_defaults(self):
|
||||
cfg = FTPServerConfig()
|
||||
assert cfg.host == "0.0.0.0"
|
||||
assert cfg.port == 2121
|
||||
assert cfg.username == ""
|
||||
assert cfg.password == ""
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = FTPServerConfig(host="127.0.0.1", port=2122, username="u", password="p")
|
||||
assert cfg.host == "127.0.0.1"
|
||||
assert cfg.port == 2122
|
||||
assert cfg.username == "u"
|
||||
assert cfg.password == "p"
|
||||
|
||||
|
||||
class TestSchedulerConfig:
|
||||
def test_default_interval(self):
|
||||
cfg = SchedulerConfig()
|
||||
assert cfg.interval_minutes == 10
|
||||
|
||||
def test_custom_interval(self):
|
||||
cfg = SchedulerConfig(interval_minutes=5)
|
||||
assert cfg.interval_minutes == 5
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_raises_missing_ai_token(self, monkeypatch):
|
||||
monkeypatch.delenv("AI_ENDPOINT_TOKEN", raising=False)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://x")
|
||||
monkeypatch.delenv("FTP_USERNAME", raising=False)
|
||||
monkeypatch.delenv("FTP_PASSWORD", raising=False)
|
||||
with pytest.raises(ValueError, match="AI_ENDPOINT_TOKEN"):
|
||||
Config()
|
||||
|
||||
def test_raises_missing_ai_url(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.delenv("AI_ENDPOINT_URL", raising=False)
|
||||
monkeypatch.delenv("FTP_USERNAME", raising=False)
|
||||
monkeypatch.delenv("FTP_PASSWORD", raising=False)
|
||||
with pytest.raises(ValueError, match="AI_ENDPOINT_URL"):
|
||||
Config()
|
||||
|
||||
def test_raises_missing_ftp_username(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://x")
|
||||
monkeypatch.delenv("FTP_USERNAME", raising=False)
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
with pytest.raises(ValueError, match="FTP_USERNAME"):
|
||||
Config()
|
||||
|
||||
def test_raises_missing_ftp_password(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://x")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.delenv("FTP_PASSWORD", raising=False)
|
||||
with pytest.raises(ValueError, match="FTP_PASSWORD"):
|
||||
Config()
|
||||
|
||||
def test_full_config(self, monkeypatch, tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://ai.test")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "secret")
|
||||
monkeypatch.setenv("FTP_USERNAME", "user")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "pass")
|
||||
monkeypatch.setenv("FTP_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("FTP_PORT", "2122")
|
||||
monkeypatch.setenv("SCHEDULER_INTERVAL", "5")
|
||||
|
||||
cfg = Config()
|
||||
|
||||
assert cfg.database.path == db_path
|
||||
assert cfg.ai_endpoint.url == "http://ai.test"
|
||||
assert cfg.ai_endpoint.auth_token == "secret"
|
||||
assert cfg.ftp_server.host == "127.0.0.1"
|
||||
assert cfg.ftp_server.port == 2122
|
||||
assert cfg.ftp_server.username == "user"
|
||||
assert cfg.ftp_server.password == "pass"
|
||||
assert cfg.scheduler.interval_minutes == 5
|
||||
219
tests/test_database.py
Normal file
219
tests/test_database.py
Normal file
@ -0,0 +1,219 @@
|
||||
"""Tests for database module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
from factsdb.database import DatabaseManager, get_db_manager
|
||||
from factsdb.config import DatabaseConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_manager(tmp_path):
|
||||
"""Create a DatabaseManager with a temp database."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
return DatabaseManager(config)
|
||||
|
||||
|
||||
class TestDatabaseManagerInit:
|
||||
def test_creates_tables(self, db_manager):
|
||||
tables = db_manager.get_table_names()
|
||||
assert tables == []
|
||||
|
||||
def test_creates_db_file(self, tmp_path):
|
||||
db_path = str(tmp_path / "init.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
DatabaseManager(config)
|
||||
assert os.path.exists(db_path)
|
||||
|
||||
def test_init_in_subdir(self, tmp_path):
|
||||
subdir = tmp_path / "sub"
|
||||
subdir.mkdir()
|
||||
db_path = str(subdir / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
mgr = DatabaseManager(config)
|
||||
assert mgr is not None
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def test_get_connection(self, db_manager):
|
||||
with db_manager.get_connection() as conn:
|
||||
cur = conn.execute("SELECT 1")
|
||||
assert cur.fetchone()[0] == 1
|
||||
|
||||
|
||||
class CreateTableTests:
|
||||
def test_create_table(self, db_manager):
|
||||
db_manager.create_table("test_table")
|
||||
names = db_manager.get_table_names()
|
||||
assert "test_table" in names
|
||||
|
||||
def test_create_table_idempotent(self, db_manager):
|
||||
db_manager.create_table("t1")
|
||||
db_manager.create_table("t1")
|
||||
names = db_manager.get_table_names()
|
||||
assert names.count("t1") == 1
|
||||
|
||||
|
||||
class TestGetTableInfo:
|
||||
def test_existing_table(self, db_manager):
|
||||
db_manager.create_table("info_test")
|
||||
info = db_manager.get_table_info("info_test")
|
||||
assert info is not None
|
||||
assert info["name"] == "info_test"
|
||||
assert info["record_count"] == 0
|
||||
|
||||
def test_nonexistent_table(self, db_manager):
|
||||
info = db_manager.get_table_info("no_such")
|
||||
assert info is None
|
||||
|
||||
|
||||
class TestUpdateTableCount:
|
||||
def test_update_count(self, db_manager):
|
||||
db_manager.create_table("cnt")
|
||||
db_manager.update_table_count("cnt", 42)
|
||||
info = db_manager.get_table_info("cnt")
|
||||
assert info["record_count"] == 42
|
||||
|
||||
|
||||
class TestInsertFact:
|
||||
def test_insert_returns_id(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
fid = db_manager.insert_fact("ft", {"fact": "F1", "key_entities": [], "key_dates": []})
|
||||
assert fid > 0
|
||||
|
||||
def test_insert_stores_data(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1", "key_entities": ["A"], "key_dates": ["2024-01-01"], "file_path": "/x"})
|
||||
facts = db_manager.get_facts("ft")
|
||||
assert len(facts) == 1
|
||||
assert facts[0]["fact"] == "F1"
|
||||
assert facts[0]["key_entities"] == ["A"]
|
||||
assert facts[0]["key_dates"] == ["2024-01-01"]
|
||||
|
||||
def test_insert_updates_count(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1"})
|
||||
count = db_manager.get_table_count("ft")
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestGetFacts:
|
||||
def test_empty(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
assert db_manager.get_facts("ft") == []
|
||||
|
||||
def test_limit(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
for i in range(5):
|
||||
db_manager.insert_fact("ft", {"fact": f"F{i}"})
|
||||
facts = db_manager.get_facts("ft", limit=2)
|
||||
assert len(facts) == 2
|
||||
|
||||
def test_offset(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
for i in range(5):
|
||||
db_manager.insert_fact("ft", {"fact": f"F{i}"})
|
||||
facts = db_manager.get_facts("ft", limit=10, offset=3)
|
||||
assert len(facts) == 2
|
||||
|
||||
def test_json_decode_error(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1", "key_entities": ["e"]})
|
||||
with db_manager.get_connection() as conn:
|
||||
conn.execute("UPDATE facts SET key_entities = 'bad' WHERE id = 1")
|
||||
conn.commit()
|
||||
facts = db_manager.get_facts("ft")
|
||||
assert facts[0]["key_entities"] == []
|
||||
|
||||
|
||||
class TestGetFactById:
|
||||
def test_existing(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
fid = db_manager.insert_fact("ft", {"fact": "F1"})
|
||||
fact = db_manager.get_fact_by_id(fid)
|
||||
assert fact is not None
|
||||
assert fact["fact"] == "F1"
|
||||
|
||||
def test_nonexistent(self, db_manager):
|
||||
assert db_manager.get_fact_by_id(999) is None
|
||||
|
||||
|
||||
class TestGetAllTables:
|
||||
def test_multiple_tables(self, db_manager):
|
||||
db_manager.create_table("a")
|
||||
db_manager.create_table("b")
|
||||
tables = db_manager.get_all_tables()
|
||||
names = [t["name"] for t in tables]
|
||||
assert "a" in names
|
||||
assert "b" in names
|
||||
|
||||
|
||||
class TestFileTracking:
|
||||
def test_mark_processed(self, db_manager):
|
||||
db_manager.mark_file_processed("/path/file.txt", "ft", True)
|
||||
assert db_manager.is_file_processed("/path/file.txt")
|
||||
|
||||
def test_mark_not_processed(self, db_manager):
|
||||
db_manager.mark_file_processed("/path/file.txt", "ft", False, "err")
|
||||
assert not db_manager.is_file_processed("/path/file.txt")
|
||||
|
||||
def test_unprocessed_file(self, db_manager):
|
||||
assert db_manager.is_file_processed("/no/file") is False
|
||||
|
||||
def test_get_processed_files(self, db_manager):
|
||||
db_manager.mark_file_processed("/a", "ft", True)
|
||||
db_manager.mark_file_processed("/b", "ft", False)
|
||||
processed = db_manager.get_processed_files("ft")
|
||||
assert "/a" in processed
|
||||
assert "/b" not in processed
|
||||
|
||||
def test_get_unprocessed_files(self, db_manager):
|
||||
db_manager.mark_file_processed("/a", "ft", True)
|
||||
db_manager.mark_file_processed("/b", "ft", False)
|
||||
unprocessed = db_manager.get_unprocessed_files("ft")
|
||||
assert "/b" in unprocessed
|
||||
assert "/a" not in unprocessed
|
||||
|
||||
|
||||
class TestQueryAndAliases:
|
||||
def test_query_table(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1"})
|
||||
results = db_manager.query_table("ft")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_get_table_record_count(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1"})
|
||||
assert db_manager.get_table_record_count("ft") == 1
|
||||
|
||||
def test_store_fact(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
fid = db_manager.store_fact({"fact": "F1"}, "ft", "/x")
|
||||
assert fid > 0
|
||||
|
||||
|
||||
class TestDatabaseStats:
|
||||
def test_stats(self, db_manager):
|
||||
db_manager.create_table("ft")
|
||||
db_manager.insert_fact("ft", {"fact": "F1"})
|
||||
db_manager.mark_file_processed("/a", "ft", True)
|
||||
stats = db_manager.get_database_stats()
|
||||
assert stats["total_facts"] == 1
|
||||
assert stats["processed_files"] == 1
|
||||
assert "ft" in stats["table_counts"]
|
||||
|
||||
|
||||
class TestGetDbManager:
|
||||
def test_singleton(self, tmp_path, monkeypatch):
|
||||
import factsdb.database as db_module
|
||||
db_module._db_manager = None
|
||||
db_path = str(tmp_path / "sg.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
m1 = get_db_manager(config)
|
||||
m2 = get_db_manager(config)
|
||||
assert m1 is m2
|
||||
db_module._db_manager = None
|
||||
76
tests/test_database_ext.py
Normal file
76
tests/test_database_ext.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Extended tests for database error paths."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.config import DatabaseConfig
|
||||
|
||||
|
||||
class TestDatabaseInitError:
|
||||
def test_init_readonly_path(self, tmp_path, monkeypatch):
|
||||
"""Test that database init handles permission errors gracefully."""
|
||||
ro_dir = tmp_path / "readonly"
|
||||
ro_dir.mkdir()
|
||||
ro_dir.chmod(0o000)
|
||||
db_path = str(ro_dir / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
try:
|
||||
with pytest.raises(Exception):
|
||||
DatabaseManager(config)
|
||||
finally:
|
||||
ro_dir.chmod(0o755)
|
||||
|
||||
def test_init_creates_dir(self, tmp_path):
|
||||
"""Test that database init creates parent directory."""
|
||||
subdir = tmp_path / "new_sub"
|
||||
subdir.mkdir()
|
||||
db_path = str(subdir / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
mgr = DatabaseManager(config)
|
||||
assert mgr is not None
|
||||
|
||||
|
||||
class TestGetFactByIdJsonErrors:
|
||||
def test_get_fact_bad_key_entities(self, tmp_path):
|
||||
"""Test get_fact_by_id handles bad JSON in key_entities."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
dm = DatabaseManager(config)
|
||||
dm.create_table("t1")
|
||||
dm.insert_fact("t1", {"fact": "F1", "key_entities": ["e"]})
|
||||
with dm.get_connection() as conn:
|
||||
conn.execute("UPDATE facts SET key_entities = 'bad_json' WHERE id = 1")
|
||||
conn.commit()
|
||||
fact = dm.get_fact_by_id(1)
|
||||
assert fact is not None
|
||||
assert fact["key_entities"] == []
|
||||
|
||||
def test_get_fact_bad_key_dates(self, tmp_path):
|
||||
"""Test get_fact_by_id handles bad JSON in key_dates."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
dm = DatabaseManager(config)
|
||||
dm.create_table("t1")
|
||||
dm.insert_fact("t1", {"fact": "F1", "key_dates": ["d"]})
|
||||
with dm.get_connection() as conn:
|
||||
conn.execute("UPDATE facts SET key_dates = 'bad_json' WHERE id = 1")
|
||||
conn.commit()
|
||||
fact = dm.get_fact_by_id(1)
|
||||
assert fact is not None
|
||||
assert fact["key_dates"] == []
|
||||
|
||||
def test_get_facts_bad_key_dates(self, tmp_path):
|
||||
"""Test get_facts handles bad JSON in key_dates."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
config = DatabaseConfig(path=db_path)
|
||||
dm = DatabaseManager(config)
|
||||
dm.create_table("t1")
|
||||
dm.insert_fact("t1", {"fact": "F1", "key_dates": ["d"]})
|
||||
with dm.get_connection() as conn:
|
||||
conn.execute("UPDATE facts SET key_dates = 'bad_json' WHERE id = 1")
|
||||
conn.commit()
|
||||
facts = dm.get_facts("t1")
|
||||
assert facts[0]["key_dates"] == []
|
||||
114
tests/test_file_processor.py
Normal file
114
tests/test_file_processor.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Tests for file_processor module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
from factsdb.file_processor import FileProcessor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
return FileProcessor()
|
||||
|
||||
|
||||
class TestDetectFileType:
|
||||
def test_txt(self, processor):
|
||||
assert processor.detect_file_type("file.txt") == "txt"
|
||||
|
||||
def test_html(self, processor):
|
||||
assert processor.detect_file_type("page.html") == "html"
|
||||
|
||||
def test_htm(self, processor):
|
||||
assert processor.detect_file_type("page.htm") == "htm"
|
||||
|
||||
def test_pdf(self, processor):
|
||||
assert processor.detect_file_type("doc.pdf") == "pdf"
|
||||
|
||||
def test_json(self, processor):
|
||||
assert processor.detect_file_type("data.json") == "json"
|
||||
|
||||
def test_xml(self, processor):
|
||||
assert processor.detect_file_type("data.xml") == "xml"
|
||||
|
||||
def test_uppercase(self, processor):
|
||||
assert processor.detect_file_type("file.TXT") == "txt"
|
||||
|
||||
def test_no_extension(self, processor):
|
||||
assert processor.detect_file_type("file") == ""
|
||||
|
||||
|
||||
class TestIsSupportedFileType:
|
||||
def test_supported(self, processor, tmp_path):
|
||||
for ext in ["txt", "md", "log", "html", "htm", "pdf", "xml", "json"]:
|
||||
f = tmp_path / f"file.{ext}"
|
||||
f.touch()
|
||||
assert processor.is_supported_file_type(str(f)) is True
|
||||
|
||||
def test_unsupported(self, processor, tmp_path):
|
||||
f = tmp_path / "file.xyz"
|
||||
f.touch()
|
||||
assert processor.is_supported_file_type(str(f)) is False
|
||||
|
||||
|
||||
class TestExtractTextFromFile:
|
||||
def test_text_file(self, processor, tmp_path):
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("Hello World")
|
||||
assert processor.extract_text_from_file(str(f)) == "Hello World"
|
||||
|
||||
def test_html_file(self, processor, tmp_path):
|
||||
f = tmp_path / "test.html"
|
||||
f.write_text("<html><body><p>Hello</p></body></html>")
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "Hello" in text
|
||||
|
||||
def test_json_file(self, processor, tmp_path):
|
||||
f = tmp_path / "test.json"
|
||||
data = {"key": "value"}
|
||||
f.write_text(json.dumps(data))
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "key" in text
|
||||
|
||||
def test_unsupported_type(self, processor, tmp_path):
|
||||
f = tmp_path / "test.xyz"
|
||||
f.write_text("x")
|
||||
with pytest.raises(Exception, match="Unsupported file type"):
|
||||
processor.extract_text_from_file(str(f))
|
||||
|
||||
def test_nonexistent_file(self, processor):
|
||||
with pytest.raises(Exception):
|
||||
processor.extract_text_from_file("/nonexistent/path/file.txt")
|
||||
|
||||
|
||||
class TestHtmlExtraction:
|
||||
def test_removes_scripts(self, processor, tmp_path):
|
||||
f = tmp_path / "test.html"
|
||||
f.write_text("<html><script>alert(1)</script><p>Content</p></html>")
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "alert" not in text
|
||||
assert "Content" in text
|
||||
|
||||
def test_removes_styles(self, processor, tmp_path):
|
||||
f = tmp_path / "test.html"
|
||||
f.write_text("<html><style>body{}</style><p>Content</p></html>")
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "Content" in text
|
||||
|
||||
|
||||
class TestGetFileInfo:
|
||||
def test_basic_info(self, processor, tmp_path):
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello")
|
||||
info = processor.get_file_info(str(f))
|
||||
assert info["name"] == "test.txt"
|
||||
assert info["type"] == "txt"
|
||||
assert info["is_supported"] is True
|
||||
assert info["size"] == 5
|
||||
|
||||
def test_unsupported_info(self, processor, tmp_path):
|
||||
f = tmp_path / "test.xyz"
|
||||
f.write_text("hello")
|
||||
info = processor.get_file_info(str(f))
|
||||
assert info["is_supported"] is False
|
||||
41
tests/test_file_processor_ext.py
Normal file
41
tests/test_file_processor_ext.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Extended tests for file_processor: PDF, XML extraction."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from factsdb.file_processor import FileProcessor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
return FileProcessor()
|
||||
|
||||
|
||||
class TestPdfExtraction:
|
||||
def test_pdf_success(self, processor, tmp_path):
|
||||
f = tmp_path / "test.pdf"
|
||||
f.write_bytes(b"%PDF-1.4 fake pdf content")
|
||||
with patch("pdfminer.high_level.extract_text", return_value="PDF content here"):
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert text == "PDF content here"
|
||||
|
||||
def test_pdf_failure(self, processor, tmp_path):
|
||||
f = tmp_path / "test.pdf"
|
||||
f.write_bytes(b"%PDF-1.4 fake pdf content")
|
||||
with patch("pdfminer.high_level.extract_text", side_effect=Exception("parse error")):
|
||||
with pytest.raises(Exception, match="PDF extraction failed"):
|
||||
processor.extract_text_from_file(str(f))
|
||||
|
||||
|
||||
class TestXmlExtraction:
|
||||
def test_xml_file(self, processor, tmp_path):
|
||||
f = tmp_path / "test.xml"
|
||||
f.write_text("<root><item>Hello</item></root>")
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "Hello" in text
|
||||
|
||||
def test_json_invalid(self, processor, tmp_path):
|
||||
f = tmp_path / "test.json"
|
||||
f.write_text("{invalid json")
|
||||
text = processor.extract_text_from_file(str(f))
|
||||
assert "invalid json" in text
|
||||
99
tests/test_ftp_server.py
Normal file
99
tests/test_ftp_server.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Tests for ftp_server module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_pyftpdlib():
|
||||
"""Mock pyftpdlib modules which are not available on Python 3.12+."""
|
||||
with patch.dict("sys.modules", {
|
||||
"pyftpdlib": MagicMock(),
|
||||
"pyftpdlib.authorizers": MagicMock(),
|
||||
"pyftpdlib.handlers": MagicMock(),
|
||||
"pyftpdlib.servers": MagicMock(),
|
||||
}):
|
||||
from factsdb.ftp_server import FTPServerManager, get_ftp_server_manager
|
||||
yield
|
||||
|
||||
|
||||
class MockConfig:
|
||||
"""Lightweight mock Config for FTP tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.ftp_server = MagicMock()
|
||||
self.ftp_server.host = "0.0.0.0"
|
||||
self.ftp_server.port = 2121
|
||||
self.ftp_server.username = "testuser"
|
||||
self.ftp_server.password = "testpass"
|
||||
self.database = MagicMock()
|
||||
|
||||
|
||||
class TestFTPServerManager:
|
||||
def test_init(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
assert mgr.is_running is False
|
||||
assert mgr.onboarded_directories == set()
|
||||
assert mgr.server is None
|
||||
|
||||
def test_add_onboarded_directory(self, mock_pyftpdlib, tmp_path):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.add_onboarded_directory(str(tmp_path))
|
||||
assert str(tmp_path) in mgr.onboarded_directories
|
||||
|
||||
def test_add_nonexistent_directory(self, mock_pyftpdlib, tmp_path):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.add_onboarded_directory("/nonexistent/path")
|
||||
assert len(mgr.onboarded_directories) == 0
|
||||
|
||||
def test_is_directory_allowed(self, mock_pyftpdlib, tmp_path):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.add_onboarded_directory(str(tmp_path))
|
||||
assert mgr.is_directory_allowed(str(tmp_path)) is True
|
||||
assert mgr.is_directory_allowed("/other/path") is False
|
||||
|
||||
def test_is_directory_subpath(self, mock_pyftpdlib, tmp_path):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.add_onboarded_directory(str(tmp_path))
|
||||
sub = tmp_path / "sub"
|
||||
assert mgr.is_directory_allowed(str(sub)) is True
|
||||
|
||||
def test_get_onboarded_directories(self, mock_pyftpdlib, tmp_path):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.add_onboarded_directory(str(tmp_path))
|
||||
dirs = mgr.get_onboarded_directories()
|
||||
assert len(dirs) == 1
|
||||
assert str(tmp_path) in dirs
|
||||
|
||||
def test_is_running_attr(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
assert mgr.is_running is False
|
||||
|
||||
|
||||
class TestFTPServerStartStop:
|
||||
def test_start_already_running(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
mgr = FTPServerManager(MockConfig())
|
||||
mgr.is_running = True
|
||||
mgr.server = MagicMock()
|
||||
mgr.start()
|
||||
assert mgr.is_running is True
|
||||
|
||||
|
||||
class TestGetFtpServerManager:
|
||||
def test_singleton(self, mock_pyftpdlib):
|
||||
from factsdb import ftp_server as ftp_mod
|
||||
ftp_mod._ftp_server_manager = None
|
||||
from factsdb.ftp_server import FTPServerManager, get_ftp_server_manager
|
||||
cfg = MockConfig()
|
||||
m1 = get_ftp_server_manager(cfg)
|
||||
m2 = get_ftp_server_manager(cfg)
|
||||
assert m1 is m2
|
||||
ftp_mod._ftp_server_manager = None
|
||||
73
tests/test_ftp_server_ext.py
Normal file
73
tests/test_ftp_server_ext.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Extended tests for ftp_server: start/stop logic."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_pyftpdlib():
|
||||
"""Mock pyftpdlib for all FTP tests."""
|
||||
with patch.dict("sys.modules", {
|
||||
"pyftpdlib": MagicMock(),
|
||||
"pyftpdlib.authorizers": MagicMock(),
|
||||
"pyftpdlib.handlers": MagicMock(),
|
||||
"pyftpdlib.servers": MagicMock(),
|
||||
}):
|
||||
yield
|
||||
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.ftp_server = MagicMock()
|
||||
self.ftp_server.host = "0.0.0.0"
|
||||
self.ftp_server.port = 2121
|
||||
self.ftp_server.username = "testuser"
|
||||
self.ftp_server.password = "testpass"
|
||||
self.database = MagicMock()
|
||||
|
||||
|
||||
class TestFtpStartStop:
|
||||
def test_start_success(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
cfg = MockConfig()
|
||||
mgr = FTPServerManager(cfg)
|
||||
with patch.object(mgr, "_run_server"):
|
||||
mgr.start()
|
||||
assert mgr.is_running is True
|
||||
assert mgr.server is not None
|
||||
mgr.stop()
|
||||
|
||||
def test_stop(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
cfg = MockConfig()
|
||||
mgr = FTPServerManager(cfg)
|
||||
mgr.server = MagicMock()
|
||||
mgr.is_running = True
|
||||
mgr.stop()
|
||||
assert mgr.is_running is False
|
||||
mgr.server.close_all.assert_called_once()
|
||||
|
||||
def test_stop_no_server(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
cfg = MockConfig()
|
||||
mgr = FTPServerManager(cfg)
|
||||
mgr.server = None
|
||||
mgr.is_running = True
|
||||
mgr.stop()
|
||||
assert mgr.server is None
|
||||
|
||||
def test_run_server(self, mock_pyftpdlib):
|
||||
from factsdb.ftp_server import FTPServerManager
|
||||
cfg = MockConfig()
|
||||
mgr = FTPServerManager(cfg)
|
||||
mock_server = MagicMock()
|
||||
mgr.server = mock_server
|
||||
# Call serve_forever once then stop to avoid blocking
|
||||
call_count = [0]
|
||||
def mock_forever():
|
||||
call_count[0] += 1
|
||||
if call_count[0] > 1:
|
||||
return
|
||||
mock_server.serve_forever.side_effect = mock_forever
|
||||
mgr._run_server()
|
||||
assert call_count[0] >= 1
|
||||
107
tests/test_monitoring.py
Normal file
107
tests/test_monitoring.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""Tests for monitoring module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from factsdb.monitoring import (
|
||||
increment_fact_extraction,
|
||||
increment_file_processing,
|
||||
increment_error,
|
||||
get_metrics,
|
||||
get_metrics_json,
|
||||
start_uptime_monitor,
|
||||
_metrics,
|
||||
_metrics_lock,
|
||||
)
|
||||
from factsdb.config import DatabaseConfig
|
||||
|
||||
|
||||
def reset_metrics():
|
||||
"""Reset global metrics to known state."""
|
||||
with _metrics_lock:
|
||||
_metrics["fact_extraction_count"] = 0
|
||||
_metrics["file_processing_count"] = 0
|
||||
_metrics["error_count"] = 0
|
||||
_metrics["last_extraction_time"] = 0
|
||||
_metrics["uptime_seconds"] = 0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_metrics():
|
||||
reset_metrics()
|
||||
yield
|
||||
reset_metrics()
|
||||
|
||||
|
||||
class TestIncrementFactExtraction:
|
||||
def test_increment(self):
|
||||
increment_fact_extraction()
|
||||
assert _metrics["fact_extraction_count"] == 1
|
||||
assert _metrics["last_extraction_time"] > 0
|
||||
|
||||
def test_multiple(self):
|
||||
for _ in range(3):
|
||||
increment_fact_extraction()
|
||||
assert _metrics["fact_extraction_count"] == 3
|
||||
|
||||
|
||||
class TestIncrementFileProcessing:
|
||||
def test_increment(self):
|
||||
increment_file_processing()
|
||||
assert _metrics["file_processing_count"] == 1
|
||||
|
||||
def test_multiple(self):
|
||||
for _ in range(5):
|
||||
increment_file_processing()
|
||||
assert _metrics["file_processing_count"] == 5
|
||||
|
||||
|
||||
class TestIncrementError:
|
||||
def test_increment(self):
|
||||
increment_error()
|
||||
assert _metrics["error_count"] == 1
|
||||
|
||||
|
||||
class TestGetMetrics:
|
||||
def test_prometheus_format(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "mon.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
text = get_metrics()
|
||||
assert "factsdb_fact_extractions_total" in text
|
||||
assert "factsdb_files_processed_total" in text
|
||||
assert "factsdb_errors_total" in text
|
||||
assert "factsdb_uptime_seconds" in text
|
||||
|
||||
|
||||
class TestGetMetricsJson:
|
||||
def test_json_format(self, tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "mon.db")
|
||||
monkeypatch.setenv("DATABASE_PATH", db_path)
|
||||
monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1")
|
||||
monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok")
|
||||
monkeypatch.setenv("FTP_USERNAME", "u")
|
||||
monkeypatch.setenv("FTP_PASSWORD", "p")
|
||||
data = get_metrics_json()
|
||||
assert "metrics" in data
|
||||
assert "timestamp" in data
|
||||
assert "database_stats" in data
|
||||
m = data["metrics"]
|
||||
assert "fact_extractions" in m
|
||||
assert "files_processed" in m
|
||||
assert "errors" in m
|
||||
assert "uptime_seconds" in m
|
||||
|
||||
|
||||
class TestStartUptimeMonitor:
|
||||
def test_returns_thread(self):
|
||||
thread = start_uptime_monitor()
|
||||
assert isinstance(thread, threading.Thread)
|
||||
assert thread.daemon is True
|
||||
assert thread.is_alive()
|
||||
98
tests/test_scheduler.py
Normal file
98
tests/test_scheduler.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Tests for scheduler module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig
|
||||
from factsdb.scheduler import FactExtractionJob, FactExtractionScheduler, get_scheduler
|
||||
|
||||
|
||||
class MockConfig:
|
||||
"""Lightweight mock Config that avoids env var requirements."""
|
||||
|
||||
def __init__(self, db_path=":memory:"):
|
||||
self.database = DatabaseConfig(path=db_path)
|
||||
self.ai_endpoint = AIEndpointConfig(url="http://localhost", auth_token="tok")
|
||||
self.ftp_server = FTPServerConfig(host="0.0.0.0", port=2121, username="u", password="p")
|
||||
self.scheduler = SchedulerConfig(interval_minutes=10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler_config(tmp_path):
|
||||
return MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
|
||||
|
||||
class TestFactExtractionJob:
|
||||
def test_execute_missing_directory(self, scheduler_config):
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.file_processor import FileProcessor
|
||||
from factsdb.ai_processor import AIProcessor
|
||||
dm = DatabaseManager(scheduler_config.database)
|
||||
fp = FileProcessor()
|
||||
ap = AIProcessor(scheduler_config.ai_endpoint)
|
||||
job = FactExtractionJob(scheduler_config, dm, fp, ap)
|
||||
with pytest.raises(Exception, match="Directory does not exist"):
|
||||
job.execute("/nonexistent/dir", "t", "", "gpt-oss")
|
||||
|
||||
|
||||
class TestFactExtractionScheduler:
|
||||
def test_init(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
assert sched.jobs == {}
|
||||
assert sched.is_running is False
|
||||
|
||||
def test_add_job(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
sched.add_job("/tmp", "t1", "", "gpt-oss", 5)
|
||||
jobs = sched.get_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["directory_path"] == "/tmp"
|
||||
assert jobs[0]["interval_minutes"] == 5
|
||||
|
||||
def test_remove_job(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
sched.add_job("/tmp", "t1")
|
||||
sched.remove_job("job_1")
|
||||
assert len(sched.get_jobs()) == 0
|
||||
|
||||
def test_is_job_running(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
sched.add_job("/tmp", "t1")
|
||||
assert sched.is_job_running("job_1") is True
|
||||
assert sched.is_job_running("job_99") is False
|
||||
|
||||
def test_start_stop(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
sched.start()
|
||||
assert sched.is_running is True
|
||||
sched.stop()
|
||||
assert sched.is_running is False
|
||||
|
||||
def test_get_jobs_empty(self, scheduler_config):
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
sched = FactExtractionScheduler(scheduler_config)
|
||||
assert sched.get_jobs() == []
|
||||
|
||||
|
||||
class TestGetScheduler:
|
||||
def test_singleton(self, scheduler_config):
|
||||
import factsdb.scheduler as sched_mod
|
||||
sched_mod._scheduler = None
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager"):
|
||||
s1 = get_scheduler(scheduler_config)
|
||||
s2 = get_scheduler(scheduler_config)
|
||||
assert s1 is s2
|
||||
sched_mod._scheduler = None
|
||||
130
tests/test_scheduler_ext.py
Normal file
130
tests/test_scheduler_ext.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Extended tests for scheduler: job execution flow."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from factsdb.config import DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig
|
||||
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self, db_path=":memory:"):
|
||||
self.database = DatabaseConfig(path=db_path)
|
||||
self.ai_endpoint = AIEndpointConfig(url="http://localhost", auth_token="tok")
|
||||
self.ftp_server = FTPServerConfig(host="0.0.0.0", port=2121, username="u", password="p")
|
||||
self.scheduler = SchedulerConfig(interval_minutes=10)
|
||||
|
||||
|
||||
class TestFactExtractionJobExecute:
|
||||
def test_execute_success(self, tmp_path):
|
||||
from factsdb.scheduler import FactExtractionJob
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.file_processor import FileProcessor
|
||||
|
||||
cfg = MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
dm = DatabaseManager(cfg.database)
|
||||
fp = FileProcessor()
|
||||
|
||||
dm.create_table("t1")
|
||||
|
||||
# Create a test file
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("Apple released the iPhone in 2007")
|
||||
|
||||
# Mock AI processor
|
||||
mock_ai = MagicMock()
|
||||
mock_ai.extract_facts_from_text.return_value = {
|
||||
"fact": "Test fact",
|
||||
"key_entities": ["Apple"],
|
||||
"key_dates": ["2007"],
|
||||
}
|
||||
|
||||
job = FactExtractionJob(cfg, dm, fp, mock_ai)
|
||||
job.execute(str(tmp_path), "t1", "", "gpt-oss")
|
||||
|
||||
facts = dm.get_facts("t1")
|
||||
assert len(facts) == 1
|
||||
assert facts[0]["fact"] == "Test fact"
|
||||
|
||||
def test_execute_skip_processed(self, tmp_path):
|
||||
from factsdb.scheduler import FactExtractionJob
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.file_processor import FileProcessor
|
||||
|
||||
cfg = MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
dm = DatabaseManager(cfg.database)
|
||||
fp = FileProcessor()
|
||||
dm.create_table("t1")
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("content")
|
||||
dm.mark_file_processed(str(test_file), "t1", True)
|
||||
|
||||
mock_ai = MagicMock()
|
||||
job = FactExtractionJob(cfg, dm, fp, mock_ai)
|
||||
job.execute(str(tmp_path), "t1", "", "gpt-oss")
|
||||
|
||||
mock_ai.extract_facts_from_text.assert_not_called()
|
||||
|
||||
def test_execute_file_error_continues(self, tmp_path):
|
||||
from factsdb.scheduler import FactExtractionJob
|
||||
from factsdb.database import DatabaseManager
|
||||
from factsdb.file_processor import FileProcessor
|
||||
|
||||
cfg = MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
dm = DatabaseManager(cfg.database)
|
||||
dm.create_table("t1")
|
||||
fp = FileProcessor()
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
mock_ai = MagicMock()
|
||||
mock_ai.extract_facts_from_text.side_effect = Exception("AI error")
|
||||
|
||||
job = FactExtractionJob(cfg, dm, fp, mock_ai)
|
||||
job.execute(str(tmp_path), "t1", "", "gpt-oss")
|
||||
|
||||
facts = dm.get_facts("t1")
|
||||
assert len(facts) == 0
|
||||
|
||||
|
||||
class TestSchedulerInitError:
|
||||
def test_scheduler_db_init_error(self, tmp_path, monkeypatch):
|
||||
from factsdb.scheduler import FactExtractionScheduler
|
||||
|
||||
cfg = MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
with patch("factsdb.scheduler.AIProcessor"):
|
||||
with patch("factsdb.scheduler.DatabaseManager", side_effect=Exception("db error")):
|
||||
sched = FactExtractionScheduler(cfg)
|
||||
assert sched.db_manager is None
|
||||
|
||||
|
||||
class TestSchedulerRunAllJobs:
|
||||
def test_run_all_jobs(self, tmp_path, monkeypatch):
|
||||
from factsdb.scheduler import FactExtractionScheduler
|
||||
from factsdb.database import DatabaseManager
|
||||
|
||||
cfg = MockConfig(db_path=str(tmp_path / "sched.db"))
|
||||
dm = DatabaseManager(cfg.database)
|
||||
dm.create_table("t1")
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
with patch("factsdb.scheduler.AIProcessor") as mock_ai_cls:
|
||||
mock_ai = MagicMock()
|
||||
mock_ai.extract_facts_from_text.return_value = {
|
||||
"fact": "F",
|
||||
"key_entities": [],
|
||||
"key_dates": [],
|
||||
}
|
||||
mock_ai_cls.return_value = mock_ai
|
||||
|
||||
with patch("factsdb.scheduler.DatabaseManager", return_value=dm):
|
||||
sched = FactExtractionScheduler(cfg)
|
||||
sched.add_job(str(tmp_path), "t1", "", "gpt-oss")
|
||||
sched._run_all_jobs()
|
||||
|
||||
facts = dm.get_facts("t1")
|
||||
assert len(facts) == 1
|
||||
Loading…
x
Reference in New Issue
Block a user