feat: pagination totals, request logging, data retention
Add shared paginate() helper with total count to all list endpoints. Add request logging middleware (method, path, status, duration, IP). Add data retention service with configurable thresholds and CLI command.
This commit is contained in:
128
tests/test_api/test_pagination.py
Normal file
128
tests/test_api/test_pagination.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Pagination tests for all list endpoints."""
|
||||
from datetime import datetime, UTC
|
||||
from esp32_web.extensions import db
|
||||
from esp32_web.models import Sensor, Device, Alert, Event, Probe
|
||||
|
||||
|
||||
def _create_sensors(app, n):
|
||||
with app.app_context():
|
||||
for i in range(n):
|
||||
db.session.add(Sensor(hostname=f'sensor-{i:03d}', ip=f'192.168.1.{i}'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_sensors_pagination_defaults(client, app):
|
||||
"""Sensors endpoint returns total, limit, offset."""
|
||||
_create_sensors(app, 3)
|
||||
resp = client.get('/api/v1/sensors')
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['total'] == 3
|
||||
assert resp.json['limit'] == 100
|
||||
assert resp.json['offset'] == 0
|
||||
assert len(resp.json['sensors']) == 3
|
||||
|
||||
|
||||
def test_sensors_pagination_limit(client, app):
|
||||
"""Sensors limit param restricts returned items."""
|
||||
_create_sensors(app, 5)
|
||||
resp = client.get('/api/v1/sensors?limit=2')
|
||||
assert resp.json['total'] == 5
|
||||
assert resp.json['limit'] == 2
|
||||
assert len(resp.json['sensors']) == 2
|
||||
|
||||
|
||||
def test_sensors_pagination_offset(client, app):
|
||||
"""Sensors offset param skips items."""
|
||||
_create_sensors(app, 5)
|
||||
resp = client.get('/api/v1/sensors?limit=2&offset=3')
|
||||
assert resp.json['total'] == 5
|
||||
assert resp.json['offset'] == 3
|
||||
assert len(resp.json['sensors']) == 2
|
||||
|
||||
|
||||
def test_sensors_pagination_max_limit(client, app):
|
||||
"""Limit is capped at 1000."""
|
||||
_create_sensors(app, 1)
|
||||
resp = client.get('/api/v1/sensors?limit=5000')
|
||||
assert resp.json['limit'] == 1000
|
||||
|
||||
|
||||
def test_devices_pagination(client, app):
|
||||
"""Devices endpoint includes total count."""
|
||||
with app.app_context():
|
||||
for i in range(3):
|
||||
db.session.add(Device(
|
||||
mac=f'aa:bb:cc:dd:ee:{i:02x}',
|
||||
device_type='wifi',
|
||||
last_seen=datetime.now(UTC),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get('/api/v1/devices?limit=2')
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['total'] == 3
|
||||
assert len(resp.json['devices']) == 2
|
||||
|
||||
|
||||
def test_alerts_pagination(client, app):
|
||||
"""Alerts endpoint includes total count."""
|
||||
with app.app_context():
|
||||
sensor = Sensor(hostname='s1', ip='10.0.0.1')
|
||||
db.session.add(sensor)
|
||||
db.session.flush()
|
||||
for _ in range(4):
|
||||
db.session.add(Alert(
|
||||
sensor_id=sensor.id,
|
||||
alert_type='deauth',
|
||||
timestamp=datetime.now(UTC),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get('/api/v1/alerts?limit=2&hours=1')
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['total'] == 4
|
||||
assert len(resp.json['alerts']) == 2
|
||||
|
||||
|
||||
def test_events_pagination(client, app):
|
||||
"""Events endpoint includes total count."""
|
||||
with app.app_context():
|
||||
sensor = Sensor(hostname='s1', ip='10.0.0.1')
|
||||
db.session.add(sensor)
|
||||
db.session.flush()
|
||||
for _ in range(3):
|
||||
db.session.add(Event(
|
||||
sensor_id=sensor.id,
|
||||
event_type='presence',
|
||||
timestamp=datetime.now(UTC),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get('/api/v1/events?hours=1')
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['total'] == 3
|
||||
|
||||
|
||||
def test_probes_pagination(client, app):
|
||||
"""Probes endpoint includes total count."""
|
||||
with app.app_context():
|
||||
sensor = Sensor(hostname='s1', ip='10.0.0.1')
|
||||
device = Device(mac='aa:bb:cc:dd:ee:ff', device_type='wifi',
|
||||
last_seen=datetime.now(UTC))
|
||||
db.session.add_all([sensor, device])
|
||||
db.session.flush()
|
||||
for _ in range(3):
|
||||
db.session.add(Probe(
|
||||
device_id=device.id,
|
||||
sensor_id=sensor.id,
|
||||
ssid='TestNet',
|
||||
rssi=-50,
|
||||
channel=6,
|
||||
timestamp=datetime.now(UTC),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get('/api/v1/probes?hours=1&limit=2')
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['total'] == 3
|
||||
assert len(resp.json['probes']) == 2
|
||||
@@ -8,7 +8,10 @@ def test_list_sensors_empty(client):
|
||||
"""Test listing sensors when empty."""
|
||||
response = client.get('/api/v1/sensors')
|
||||
assert response.status_code == 200
|
||||
assert response.json == {'sensors': []}
|
||||
assert response.json['sensors'] == []
|
||||
assert response.json['total'] == 0
|
||||
assert response.json['limit'] == 100
|
||||
assert response.json['offset'] == 0
|
||||
|
||||
|
||||
def test_get_sensor_not_found(client):
|
||||
|
||||
0
tests/test_services/__init__.py
Normal file
0
tests/test_services/__init__.py
Normal file
127
tests/test_services/test_retention.py
Normal file
127
tests/test_services/test_retention.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Data retention service tests."""
|
||||
from datetime import datetime, UTC, timedelta
|
||||
from esp32_web.extensions import db
|
||||
from esp32_web.models import Sensor, Device, Sighting, Probe, Event, Alert
|
||||
from esp32_web.services.retention import cleanup_old_data
|
||||
|
||||
|
||||
def _setup_sensor_and_device(app):
|
||||
"""Create a sensor and device for FK references."""
|
||||
with app.app_context():
|
||||
sensor = Sensor(hostname='s1', ip='10.0.0.1')
|
||||
device = Device(mac='aa:bb:cc:dd:ee:ff', device_type='wifi',
|
||||
last_seen=datetime.now(UTC))
|
||||
db.session.add_all([sensor, device])
|
||||
db.session.commit()
|
||||
return sensor.id, device.id
|
||||
|
||||
|
||||
def test_cleanup_deletes_old_sightings(app):
|
||||
"""Sightings older than retention period are deleted."""
|
||||
sensor_id, device_id = _setup_sensor_and_device(app)
|
||||
with app.app_context():
|
||||
# Old sighting (30 days ago, retention=14)
|
||||
db.session.add(Sighting(
|
||||
device_id=device_id, sensor_id=sensor_id, rssi=-50,
|
||||
timestamp=datetime.now(UTC) - timedelta(days=30),
|
||||
))
|
||||
# Recent sighting (1 day ago)
|
||||
db.session.add(Sighting(
|
||||
device_id=device_id, sensor_id=sensor_id, rssi=-60,
|
||||
timestamp=datetime.now(UTC) - timedelta(days=1),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
counts = cleanup_old_data()
|
||||
assert counts['sightings'] == 1
|
||||
|
||||
remaining = db.session.scalar(
|
||||
db.select(db.func.count(Sighting.id))
|
||||
)
|
||||
assert remaining == 1
|
||||
|
||||
|
||||
def test_cleanup_deletes_old_probes(app):
|
||||
"""Probes older than retention period are deleted."""
|
||||
sensor_id, device_id = _setup_sensor_and_device(app)
|
||||
with app.app_context():
|
||||
db.session.add(Probe(
|
||||
device_id=device_id, sensor_id=sensor_id,
|
||||
ssid='OldNet', rssi=-50, channel=6,
|
||||
timestamp=datetime.now(UTC) - timedelta(days=30),
|
||||
))
|
||||
db.session.add(Probe(
|
||||
device_id=device_id, sensor_id=sensor_id,
|
||||
ssid='NewNet', rssi=-40, channel=1,
|
||||
timestamp=datetime.now(UTC) - timedelta(days=1),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
counts = cleanup_old_data()
|
||||
assert counts['probes'] == 1
|
||||
|
||||
remaining = db.session.scalar(
|
||||
db.select(db.func.count(Probe.id))
|
||||
)
|
||||
assert remaining == 1
|
||||
|
||||
|
||||
def test_cleanup_deletes_old_events(app):
|
||||
"""Events older than 60 days are deleted."""
|
||||
sensor_id, _ = _setup_sensor_and_device(app)
|
||||
with app.app_context():
|
||||
db.session.add(Event(
|
||||
sensor_id=sensor_id, event_type='presence',
|
||||
timestamp=datetime.now(UTC) - timedelta(days=90),
|
||||
))
|
||||
db.session.add(Event(
|
||||
sensor_id=sensor_id, event_type='presence',
|
||||
timestamp=datetime.now(UTC) - timedelta(days=10),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
counts = cleanup_old_data()
|
||||
assert counts['events'] == 1
|
||||
|
||||
remaining = db.session.scalar(
|
||||
db.select(db.func.count(Event.id))
|
||||
)
|
||||
assert remaining == 1
|
||||
|
||||
|
||||
def test_cleanup_deletes_old_alerts(app):
|
||||
"""Alerts older than 365 days are deleted."""
|
||||
sensor_id, _ = _setup_sensor_and_device(app)
|
||||
with app.app_context():
|
||||
db.session.add(Alert(
|
||||
sensor_id=sensor_id, alert_type='deauth',
|
||||
timestamp=datetime.now(UTC) - timedelta(days=400),
|
||||
))
|
||||
db.session.add(Alert(
|
||||
sensor_id=sensor_id, alert_type='deauth',
|
||||
timestamp=datetime.now(UTC) - timedelta(days=100),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
counts = cleanup_old_data()
|
||||
assert counts['alerts'] == 1
|
||||
|
||||
remaining = db.session.scalar(
|
||||
db.select(db.func.count(Alert.id))
|
||||
)
|
||||
assert remaining == 1
|
||||
|
||||
|
||||
def test_cleanup_no_expired_data(app):
|
||||
"""Cleanup with no expired data returns zero counts."""
|
||||
with app.app_context():
|
||||
counts = cleanup_old_data()
|
||||
assert all(v == 0 for v in counts.values())
|
||||
|
||||
|
||||
def test_cleanup_cli_command(app):
|
||||
"""CLI command runs and outputs results."""
|
||||
runner = app.test_cli_runner()
|
||||
result = runner.invoke(args=['cleanup-data'])
|
||||
assert result.exit_code == 0
|
||||
assert 'No expired data found' in result.output
|
||||
Reference in New Issue
Block a user