Mass ruff auto-fix:
- ruff check --fix: 109 issues fixed (F401 unused imports,
I001 unsorted imports, UP037 quoted annotations, SIM105
suppressible exception, RUF100 unused-noqa)
- ruff check --fix --unsafe-fixes: 22 additional issues
- ruff format: 70 files reformatted
- Manual pass: fix 16 misplaced import httpx lines
- Manual pass: fix remaining E402 (import-after-docstring)
Result: 283 errors -> 30 errors.
The remaining 30 are real issues that need manual review:
5 F401 unused-import (likely auto-generated stubs)
5 F821 undefined-name (real bugs in code that references
redis/pydantic/LLMRegistry without imports)
3 BLE001 (the compliance LLM fallback is intentional; the
other two are real)
3 RUF012 mutable-class-default
3 SIM105, 3 SIM117, 2 E722, 2 E741
1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)
Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
218 lines
6.3 KiB
Python
218 lines
6.3 KiB
Python
"""Tests for the db module (SQLAlchemy foundation).
|
|
|
|
Uses a temp directory for PRY_DATA_DIR so we don't pollute the user's
|
|
real data dir. Tests verify schema creation, basic CRUD, and the
|
|
JSON store importer.
|
|
"""
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_data_dir(monkeypatch, tmp_path):
|
|
"""Set PRY_DATA_DIR to a temp dir and reload db to pick up the change."""
|
|
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{tmp_path}/test.db")
|
|
# Force re-import of paths and db
|
|
import db
|
|
import paths
|
|
|
|
importlib.reload(paths)
|
|
importlib.reload(db)
|
|
# Reset the cached engine
|
|
db._engine = None
|
|
db._SessionLocal = None
|
|
yield tmp_path
|
|
# Cleanup
|
|
db._engine = None
|
|
db._SessionLocal = None
|
|
|
|
|
|
def test_get_engine_creates_db_file(temp_data_dir):
|
|
import db
|
|
|
|
eng = db.get_engine()
|
|
assert eng is not None
|
|
# The DB file should exist now
|
|
assert (temp_data_dir / "test.db").exists()
|
|
|
|
|
|
def test_all_tables_created(temp_data_dir):
|
|
from sqlalchemy import inspect
|
|
|
|
import db
|
|
|
|
db.get_engine()
|
|
inspector = inspect(db.get_engine())
|
|
tables = inspector.get_table_names()
|
|
# Should have all 24 model tables
|
|
expected_tables = {
|
|
"quality_checks",
|
|
"review_items",
|
|
"intel_snapshots",
|
|
"costing_entries",
|
|
"freshness_snapshots",
|
|
"structure_snapshots",
|
|
"seo_snapshots",
|
|
"monitors",
|
|
"monitor_runs",
|
|
"accounts",
|
|
"browser_sessions",
|
|
"reports",
|
|
"training_datasets",
|
|
"pipelines",
|
|
"pipeline_runs",
|
|
"gdpr_requests",
|
|
"agencies",
|
|
"agency_clients",
|
|
"referral_clicks",
|
|
"actors",
|
|
"actor_runs",
|
|
"llm_usage",
|
|
"webhooks",
|
|
"x402_receipts",
|
|
}
|
|
assert expected_tables.issubset(set(tables)), f"missing: {expected_tables - set(tables)}"
|
|
|
|
|
|
def test_session_scope_commits_on_success(temp_data_dir):
|
|
import db
|
|
|
|
with db.session_scope() as s:
|
|
s.add(
|
|
db.QualityCheck(
|
|
extraction_id="test-1",
|
|
url="https://example.com",
|
|
completeness=0.9,
|
|
)
|
|
)
|
|
# Verify it was committed
|
|
with db.session_scope() as s:
|
|
from sqlalchemy import select
|
|
|
|
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-1"))
|
|
row = result.scalar_one_or_none()
|
|
assert row is not None
|
|
assert row.url == "https://example.com"
|
|
assert row.completeness == 0.9
|
|
|
|
|
|
def test_session_scope_rolls_back_on_error(temp_data_dir):
|
|
import db
|
|
|
|
try:
|
|
with db.session_scope() as s:
|
|
s.add(
|
|
db.QualityCheck(extraction_id="test-2", url="https://example.com", completeness=0.5)
|
|
)
|
|
raise ValueError("intentional")
|
|
except ValueError:
|
|
pass
|
|
# Verify it was NOT committed
|
|
with db.session_scope() as s:
|
|
from sqlalchemy import select
|
|
|
|
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-2"))
|
|
assert result.scalar_one_or_none() is None
|
|
|
|
|
|
def test_import_json_stores_reads_existing_data(temp_data_dir):
|
|
"""The importer should read existing JSON files and write to SQL tables."""
|
|
# Set up some JSON files
|
|
intel_dir = temp_data_dir / "intel"
|
|
intel_dir.mkdir(parents=True, exist_ok=True)
|
|
(intel_dir / "snapshots.jsonl").write_text( # use the canonical filename
|
|
json.dumps(
|
|
{
|
|
"competitor_id": "acme",
|
|
"competitor_name": "Acme Corp",
|
|
"url": "https://acme.com",
|
|
"fields": {"price": 99.99},
|
|
}
|
|
)
|
|
+ "\n"
|
|
+ json.dumps(
|
|
{
|
|
"competitor_id": "acme",
|
|
"competitor_name": "Acme Corp",
|
|
"url": "https://acme.com/pricing",
|
|
"fields": {"price": 89.99},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
monitor_dir = temp_data_dir / "monitors"
|
|
monitor_dir.mkdir(parents=True, exist_ok=True)
|
|
(monitor_dir / "m1.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"monitor_id": "m1",
|
|
"url": "https://example.com",
|
|
"name": "Example Monitor",
|
|
"schedule_cron": "0 * * * *",
|
|
"active": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
import db
|
|
|
|
counts = db.import_json_stores(data_dir=temp_data_dir)
|
|
assert counts.get("intel") == 2
|
|
assert counts.get("monitors") == 1
|
|
assert counts.get("actors") == 0 # empty dir
|
|
|
|
# Verify in SQL
|
|
with db.session_scope() as s:
|
|
from sqlalchemy import select
|
|
|
|
intel_rows = (
|
|
s.execute(select(db.IntelSnapshot).where(db.IntelSnapshot.competitor_id == "acme"))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert len(intel_rows) == 2
|
|
monitor_row = s.execute(
|
|
select(db.Monitor).where(db.Monitor.monitor_id == "m1")
|
|
).scalar_one_or_none()
|
|
assert monitor_row is not None
|
|
# 'name' from JSON was renamed to 'monitor_name' in SQL
|
|
assert monitor_row.monitor_name == "Example Monitor"
|
|
|
|
|
|
def test_db_health_returns_dict(temp_data_dir):
|
|
import db
|
|
|
|
health = db.db_health()
|
|
assert isinstance(health, dict)
|
|
assert "available" in health
|
|
assert health["available"] is True
|
|
assert "url" in health
|
|
|
|
|
|
def test_models_have_unique_constraints(temp_data_dir):
|
|
"""Tables that map to JSON stores with _id fields should have unique indexes
|
|
so that re-importing the same data is idempotent (or at least detectable)."""
|
|
from sqlalchemy import inspect
|
|
|
|
import db
|
|
|
|
db.get_engine()
|
|
inspector = inspect(db.get_engine())
|
|
# monitor_id should be unique in monitors
|
|
monitors_idx = inspector.get_indexes("monitors")
|
|
assert any("monitor_id" in i.get("column_names", []) and i.get("unique") for i in monitors_idx)
|
|
# Same for actors.actor_id
|
|
actors_idx = inspector.get_indexes("actors")
|
|
assert any("actor_id" in i.get("column_names", []) and i.get("unique") for i in actors_idx)
|