Replace mock dashboard data with real database queries and report-derived timelines
- Replace mock statistics in stats_summarizer.py with real SQLAlchemy queries against DMARCReport, ReportRecord, and Domain models - Replace random compliance timeline in domains.py with real data from ReportStore - Remove unused `import random` from domains.py - Add comprehensive tests for StatsSummarizer (global/domain/caching) - Add tests for compliance timeline (deterministic, multi-report aggregation) - Mark Dashboard Visualizations as complete in TODO.md Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/ea784ce3-2c1c-45f8-ad6d-46e92cf15ed7 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Tests for the domain compliance timeline with real data."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
|
||||
def _add_report_to_store(
|
||||
domain, report_id, begin_ts, end_ts, total, passed, failed, org_name="test.org"
|
||||
):
|
||||
"""Helper to add a report to the ReportStore with integer timestamps."""
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(
|
||||
{
|
||||
"domain": domain,
|
||||
"report_id": report_id,
|
||||
"org_name": org_name,
|
||||
"begin_date": begin_ts,
|
||||
"end_date": end_ts,
|
||||
"begin_timestamp": begin_ts,
|
||||
"end_timestamp": end_ts,
|
||||
"policy": "none",
|
||||
"records": [],
|
||||
"summary": {
|
||||
"total_count": total,
|
||||
"passed_count": passed,
|
||||
"failed_count": failed,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestComplianceTimeline:
|
||||
"""Tests that the compliance timeline returns real data instead of mock."""
|
||||
|
||||
def test_timeline_has_entries_after_upload(self, client: TestClient):
|
||||
"""When a domain has reports, the timeline should have entries."""
|
||||
_add_report_to_store("example.com", "rpt-001", 1597449600, 1597535999, 10, 8, 2)
|
||||
|
||||
response = client.get("/api/v1/domains/example.com/reports?limit=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
timeline = data["compliance_timeline"]
|
||||
assert len(timeline) >= 1
|
||||
|
||||
def test_timeline_uses_real_dates(self, client: TestClient):
|
||||
"""Timeline dates should come from actual report begin_dates."""
|
||||
_add_report_to_store("example.com", "rpt-001", 1597449600, 1597535999, 10, 8, 2)
|
||||
|
||||
response = client.get("/api/v1/domains/example.com/reports?limit=10")
|
||||
data = response.json()
|
||||
timeline = data["compliance_timeline"]
|
||||
|
||||
# The begin_date=1597449600 is 2020-08-15
|
||||
dates = [point["date"] for point in timeline]
|
||||
assert "2020-08-15" in dates
|
||||
|
||||
def test_timeline_compliance_rate_is_deterministic(self, client: TestClient):
|
||||
"""Compliance rate should be deterministic, not random."""
|
||||
_add_report_to_store("example.com", "rpt-001", 1597449600, 1597535999, 10, 8, 2)
|
||||
|
||||
resp1 = client.get("/api/v1/domains/example.com/reports?limit=10")
|
||||
resp2 = client.get("/api/v1/domains/example.com/reports?limit=10")
|
||||
|
||||
timeline1 = resp1.json()["compliance_timeline"]
|
||||
timeline2 = resp2.json()["compliance_timeline"]
|
||||
assert timeline1 == timeline2
|
||||
|
||||
def test_timeline_empty_for_domain_with_no_valid_dates(self, client: TestClient):
|
||||
"""A domain with begin_date=0 should have an empty timeline."""
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(
|
||||
{
|
||||
"domain": "empty-timeline.com",
|
||||
"report_id": "rpt-empty",
|
||||
"org_name": "test",
|
||||
"begin_date": 0,
|
||||
"end_date": 0,
|
||||
"policy": "none",
|
||||
"records": [],
|
||||
"summary": {"total_count": 0, "passed_count": 0, "failed_count": 0},
|
||||
}
|
||||
)
|
||||
|
||||
response = client.get("/api/v1/domains/empty-timeline.com/reports?limit=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["compliance_timeline"] == []
|
||||
|
||||
def test_timeline_handles_iso_string_dates(self, client: TestClient):
|
||||
"""Timeline should handle reports with ISO-format string dates."""
|
||||
from app.api.api_v1.endpoints.domains import _build_compliance_timeline
|
||||
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(
|
||||
{
|
||||
"domain": "isodate.com",
|
||||
"report_id": "rpt-iso",
|
||||
"org_name": "test",
|
||||
"begin_date": "2020-08-15T00:00:00",
|
||||
"end_date": "2020-08-15T23:59:59",
|
||||
"policy": "none",
|
||||
"records": [],
|
||||
"summary": {"total_count": 10, "passed_count": 9, "failed_count": 1},
|
||||
}
|
||||
)
|
||||
|
||||
timeline = _build_compliance_timeline(store, "isodate.com")
|
||||
assert len(timeline) == 1
|
||||
assert timeline[0].date == "2020-08-15"
|
||||
assert timeline[0].compliance_rate == 90.0
|
||||
|
||||
|
||||
class TestBuildComplianceTimelineMultipleReports:
|
||||
"""Test timeline aggregation with multiple reports."""
|
||||
|
||||
def test_multiple_reports_same_day(self, client: TestClient):
|
||||
"""Multiple reports on the same day should be aggregated."""
|
||||
_add_report_to_store("multi.com", "rpt-1", 1597449600, 1597535999, 10, 8, 2)
|
||||
_add_report_to_store("multi.com", "rpt-2", 1597449600, 1597535999, 10, 6, 4)
|
||||
|
||||
response = client.get("/api/v1/domains/multi.com/reports?limit=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
timeline = data["compliance_timeline"]
|
||||
|
||||
assert len(timeline) == 1
|
||||
assert timeline[0]["date"] == "2020-08-15"
|
||||
# Aggregated: 14 passed out of 20 total = 70%
|
||||
assert timeline[0]["compliance_rate"] == 70.0
|
||||
|
||||
def test_reports_on_different_days(self, client: TestClient):
|
||||
"""Reports on different days should produce separate timeline points."""
|
||||
_add_report_to_store("days.com", "rpt-d1", 1597449600, 1597535999, 10, 10, 0)
|
||||
_add_report_to_store("days.com", "rpt-d2", 1597536000, 1597622399, 10, 5, 5)
|
||||
|
||||
response = client.get("/api/v1/domains/days.com/reports?limit=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
timeline = data["compliance_timeline"]
|
||||
|
||||
assert len(timeline) == 2
|
||||
# Sorted by date
|
||||
assert timeline[0]["date"] == "2020-08-15"
|
||||
assert timeline[0]["compliance_rate"] == 100.0
|
||||
assert timeline[1]["date"] == "2020-08-16"
|
||||
assert timeline[1]["compliance_rate"] == 50.0
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the StatsSummarizer with real database queries."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import app.models.domain # noqa: F401
|
||||
import app.models.report # noqa: F401
|
||||
import app.models.user # noqa: F401
|
||||
from app.core.database import Base
|
||||
from app.models.domain import Domain
|
||||
from app.models.report import DMARCReport, ReportRecord
|
||||
from app.utils.stats_summarizer import StatsSummarizer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
"""Create a fresh in-memory SQLite database session."""
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def summarizer():
|
||||
"""Create a StatsSummarizer with a temp cache directory."""
|
||||
cache_dir = tempfile.mkdtemp()
|
||||
s = StatsSummarizer(cache_dir=cache_dir)
|
||||
yield s
|
||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _seed_domain_and_reports(db, domain_name="example.com"):
|
||||
"""Insert a domain with reports and records into the database."""
|
||||
domain = Domain(name=domain_name)
|
||||
db.add(domain)
|
||||
db.flush()
|
||||
|
||||
# Report 1: 2 records, 1 fully passing, 1 failing
|
||||
report1 = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id="rpt-001",
|
||||
org_name="google.com",
|
||||
begin_date=1597449600, # 2020-08-15
|
||||
end_date=1597535999,
|
||||
policy="none",
|
||||
)
|
||||
db.add(report1)
|
||||
db.flush()
|
||||
|
||||
# Record: 5 emails, both pass
|
||||
rec1 = ReportRecord(
|
||||
report_id=report1.id,
|
||||
source_ip="203.0.113.1",
|
||||
count=5,
|
||||
disposition="none",
|
||||
dkim="pass",
|
||||
spf="pass",
|
||||
)
|
||||
# Record: 3 emails, both fail
|
||||
rec2 = ReportRecord(
|
||||
report_id=report1.id,
|
||||
source_ip="198.51.100.1",
|
||||
count=3,
|
||||
disposition="quarantine",
|
||||
dkim="fail",
|
||||
spf="fail",
|
||||
)
|
||||
db.add_all([rec1, rec2])
|
||||
db.flush()
|
||||
return domain
|
||||
|
||||
|
||||
class TestStatsSummarizerGlobal:
|
||||
"""Tests for global statistics."""
|
||||
|
||||
def test_empty_database_returns_zeros(self, db_session, summarizer):
|
||||
stats = summarizer.calculate_summary_statistics(db_session)
|
||||
assert stats["total_domains"] == 0
|
||||
assert stats["total_emails"] == 0
|
||||
assert stats["compliance_rate"] == 0.0
|
||||
assert stats["reports_processed"] == 0
|
||||
assert stats["top_sources"] == []
|
||||
assert stats["compliance_trend"] == []
|
||||
|
||||
def test_global_stats_with_data(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session, "example.com")
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session)
|
||||
assert stats["total_domains"] == 1
|
||||
assert stats["total_emails"] == 8 # 5 + 3
|
||||
assert stats["compliant_emails"] == 5 # only rec1 passes
|
||||
assert stats["compliance_rate"] == 62.5 # 5/8 * 100
|
||||
assert stats["reports_processed"] == 1
|
||||
|
||||
def test_global_top_sources(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session)
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session)
|
||||
assert len(stats["top_sources"]) == 2
|
||||
# Sorted by count descending
|
||||
assert stats["top_sources"][0]["ip"] == "203.0.113.1"
|
||||
assert stats["top_sources"][0]["count"] == 5
|
||||
|
||||
def test_multiple_domains(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session, "example.com")
|
||||
_seed_domain_and_reports(db_session, "test.org")
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session)
|
||||
assert stats["total_domains"] == 2
|
||||
assert stats["total_emails"] == 16 # 8 * 2
|
||||
assert stats["reports_processed"] == 2
|
||||
|
||||
|
||||
class TestStatsSummarizerDomain:
|
||||
"""Tests for domain-specific statistics."""
|
||||
|
||||
def test_nonexistent_domain(self, db_session, summarizer):
|
||||
stats = summarizer.calculate_summary_statistics(db_session, domain_id="nope.com")
|
||||
assert stats["domain"] == "nope.com"
|
||||
assert stats["total_emails"] == 0
|
||||
assert stats["compliance_rate"] == 0.0
|
||||
|
||||
def test_domain_stats_with_data(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session, "example.com")
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
|
||||
assert stats["domain"] == "example.com"
|
||||
assert stats["total_emails"] == 8
|
||||
assert stats["compliant_emails"] == 5
|
||||
assert stats["compliance_rate"] == 62.5
|
||||
assert stats["reports_processed"] == 1
|
||||
|
||||
def test_domain_sources(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session, "example.com")
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
|
||||
assert len(stats["sources"]) == 2
|
||||
# First source should be the highest count
|
||||
assert stats["sources"][0]["ip"] == "203.0.113.1"
|
||||
assert stats["sources"][0]["count"] == 5
|
||||
|
||||
def test_domain_isolation(self, db_session, summarizer):
|
||||
"""Stats for one domain should not include data from another."""
|
||||
_seed_domain_and_reports(db_session, "example.com")
|
||||
_seed_domain_and_reports(db_session, "other.org")
|
||||
db_session.commit()
|
||||
|
||||
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
|
||||
assert stats["total_emails"] == 8 # Only example.com's data
|
||||
|
||||
|
||||
class TestStatsSummarizerCaching:
|
||||
"""Tests for the caching layer."""
|
||||
|
||||
def test_caching_returns_same_data(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session)
|
||||
db_session.commit()
|
||||
|
||||
stats1 = summarizer.calculate_summary_statistics(db_session)
|
||||
stats2 = summarizer.calculate_summary_statistics(db_session)
|
||||
assert stats1 == stats2
|
||||
|
||||
def test_invalidate_cache(self, db_session, summarizer):
|
||||
_seed_domain_and_reports(db_session)
|
||||
db_session.commit()
|
||||
|
||||
summarizer.calculate_summary_statistics(db_session)
|
||||
summarizer.invalidate_cache()
|
||||
# Should recalculate after invalidation
|
||||
stats = summarizer.calculate_summary_statistics(db_session)
|
||||
assert stats["total_domains"] == 1
|
||||
Reference in New Issue
Block a user