198 lines
9.0 KiB
Python
198 lines
9.0 KiB
Python
import csv
|
|
import gzip
|
|
import io
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pytz import timezone
|
|
|
|
from app import db
|
|
from app.insights import parse_range, readings_query, chart_data, summary
|
|
from app.models import Reading, Profile
|
|
|
|
|
|
def add(timestamp, systolic=120, user_id=1, diastolic=75, heart_rate=65):
|
|
item = Reading(user_id=user_id, timestamp=timestamp, systolic=systolic,
|
|
diastolic=diastolic, heart_rate=heart_rate)
|
|
db.session.add(item)
|
|
db.session.commit()
|
|
return item
|
|
|
|
|
|
@pytest.mark.parametrize('url', ['/', '/dashboard', '/dashboard?view=history', '/dashboard?view=trends',
|
|
'/dashboard?view=history&mode=calendar', '/reading/add', '/user/profile', '/data/',
|
|
'/dashboard/report', '/auth/login', '/auth/signup'])
|
|
def test_pages_render(client, url):
|
|
response = client.get(url, follow_redirects=True)
|
|
assert response.status_code == 200
|
|
assert 'BP Tracker' in response.text
|
|
assert 'private, no-store' == response.headers['Cache-Control']
|
|
|
|
|
|
def test_empty_state(client):
|
|
response = client.get('/dashboard')
|
|
assert 'No readings yet' in response.text
|
|
assert '0 / 0' not in response.text
|
|
assert 'hx-trigger' not in response.text
|
|
|
|
|
|
@pytest.mark.parametrize('day,hours', [('2026-10-04', 23), ('2026-04-05', 25)])
|
|
def test_range_includes_entire_local_day_excludes_next_midnight(app, day, hours):
|
|
selected = parse_range({'start_date': day, 'end_date': day}, timezone('Australia/Sydney'))
|
|
assert (selected.end_utc - selected.start_utc).total_seconds() == hours * 3600
|
|
add(selected.start_utc - timedelta(seconds=1))
|
|
add(selected.start_utc, 110)
|
|
add(selected.end_utc - timedelta(microseconds=1), 130)
|
|
add(selected.end_utc, 160)
|
|
add(selected.start_utc, 200, user_id=2)
|
|
stats = summary(readings_query(1, selected))
|
|
assert stats['count'] == 2
|
|
assert stats['systolic'] == 120
|
|
|
|
|
|
@pytest.mark.parametrize('query', ['start_date=no&end_date=no', 'start_date=2026-09-01',
|
|
'start_date=2026-09-08&end_date=2026-09-01', 'days=all', 'days=-1',
|
|
'start_date=0001-01-01&end_date=9999-12-31'])
|
|
def test_invalid_range_is_actionable(client, query):
|
|
assert client.get('/dashboard?' + query).status_code == 400
|
|
assert client.get('/data/export?' + query).status_code == 400
|
|
|
|
|
|
def test_save_another_and_validation_preserve_timestamp(client):
|
|
values = dict(timestamp='2026-09-08T08:15', systolic='121', diastolic='77', heart_rate='67', action='another')
|
|
result = client.post('/reading/add', data=values, follow_redirects=True)
|
|
assert result.status_code == 200
|
|
assert result.request.path == '/reading/add'
|
|
assert 'Reading added successfully.' in result.text
|
|
assert 'class="notifications"' in result.text
|
|
assert 'value="121"' not in result.text
|
|
saved = Reading.query.one()
|
|
assert saved.timestamp == datetime(2026, 9, 7, 22, 15)
|
|
values['systolic'] = '999'
|
|
result = client.post('/reading/add', data=values)
|
|
assert result.status_code == 200
|
|
assert '2026-09-08T08:15' in result.text
|
|
assert 'value="999"' in result.text
|
|
assert Reading.query.count() == 1
|
|
|
|
|
|
def test_edit_timestamp_and_delete(client):
|
|
item = add(datetime(2026, 9, 7, 22))
|
|
result = client.post(f'/reading/{item.id}/edit', data=dict(timestamp='2026-09-08T09:20', systolic=125, diastolic=80, heart_rate=70))
|
|
assert result.status_code == 302
|
|
assert db.session.get(Reading, item.id).timestamp == datetime(2026, 9, 7, 23, 20)
|
|
assert client.get(f'/reading/{item.id}/confirm_delete').status_code == 200
|
|
result = client.post(f'/reading/{item.id}/delete')
|
|
assert result.status_code == 302
|
|
assert Reading.query.count() == 0
|
|
|
|
|
|
def test_user_isolation_and_export(client):
|
|
add(datetime(2026, 9, 7, 22), 123)
|
|
other = add(datetime(2026, 9, 7, 22), 234, user_id=2)
|
|
add(datetime(2026, 9, 8, 14), 190) # Midnight the following day in Sydney.
|
|
query = 'start_date=2026-09-08&end_date=2026-09-08'
|
|
response = client.get('/data/export?' + query)
|
|
assert response.status_code == 200
|
|
rows = list(csv.reader(io.StringIO(response.text)))
|
|
assert len(rows) == 2
|
|
assert rows[1] == ['2026-09-07 22:00:00', '123', '75', '65']
|
|
assert 'attachment' in response.headers['Content-Disposition']
|
|
report = client.get('/dashboard/report?' + query)
|
|
assert report.status_code == 200
|
|
assert '234' not in report.text and '190</td>' not in report.text
|
|
for suffix in ('edit', 'confirm_delete'):
|
|
assert client.get(f'/reading/{other.id}/{suffix}').status_code == 404
|
|
assert client.post(f'/reading/{other.id}/delete').status_code == 404
|
|
|
|
|
|
def test_saved_preferences(client):
|
|
profile = Profile.query.filter_by(user_id=1).one()
|
|
profile.systolic_threshold = 150
|
|
profile.diastolic_threshold = 100
|
|
profile.dark_mode = True
|
|
db.session.commit()
|
|
add(datetime(2026, 9, 7, 22), 140, diastolic=90)
|
|
response = client.get('/dashboard?view=history&start_date=2026-09-08&end_date=2026-09-08')
|
|
assert 'data-theme="dark"' in response.text
|
|
assert 'Below threshold' in response.text
|
|
assert '150/100' in response.text
|
|
|
|
|
|
def test_large_history_and_payload_budgets(client):
|
|
start = datetime(2020, 1, 1)
|
|
db.session.bulk_save_objects([Reading(user_id=1, timestamp=start + timedelta(hours=i * 5),
|
|
systolic=110 + i % 40, diastolic=65 + i % 25, heart_rate=60 + i % 30) for i in range(10_000)])
|
|
db.session.commit()
|
|
selected = parse_range({'start_date': '2020-01-01', 'end_date': '2026-09-08'}, timezone('Australia/Sydney'))
|
|
query = readings_query(1, selected)
|
|
chart = chart_data(query, selected)
|
|
assert len(chart['points']) <= 180
|
|
assert sum(p['count'] for p in chart['points']) == 10_000
|
|
assert summary(query)['count'] == 10_000
|
|
history = client.get('/dashboard?view=history&start_date=2020-01-01&end_date=2026-09-08')
|
|
assert history.text.count('class="edit-link"') == 25
|
|
assert 'Page 1 of 400' in history.text
|
|
urls = ['/dashboard', '/dashboard?view=trends&start_date=2020-01-01&end_date=2026-09-08',
|
|
'/dashboard?view=history&mode=calendar&anchor=2020-01-01']
|
|
for url in urls:
|
|
response = client.get(url)
|
|
assert response.status_code == 200
|
|
assert len(gzip.compress(response.data)) <= 20_000
|
|
static = Path(__file__).resolve().parents[1] / 'app' / 'static'
|
|
css_size = len(gzip.compress((static / 'css/tailwind.css').read_bytes()))
|
|
js_size = len(gzip.compress((static / 'js/diy-turbo.min.js').read_bytes()))
|
|
assert css_size <= 8_000
|
|
assert js_size <= 5_000
|
|
assert len(gzip.compress(client.get('/dashboard').data)) + css_size + js_size + 276 <= 40_000
|
|
|
|
|
|
def test_compression_and_static_cache(client):
|
|
response = client.get('/dashboard', headers={'Accept-Encoding': 'gzip'})
|
|
assert response.headers['Content-Encoding'] == 'gzip'
|
|
assert b'Overview' in gzip.decompress(response.data)
|
|
import re
|
|
css = re.search(r'href="([^"]+tailwind.css[^"]+)"', client.get('/dashboard').text).group(1)
|
|
response = client.get(css.replace('&', '&'))
|
|
assert 'immutable' in response.headers['Cache-Control']
|
|
|
|
|
|
def test_import_validation_and_atomicity(client):
|
|
payload = b'Timestamp,Systolic,Diastolic,Heart Rate\n2026-09-07 22:00:00,120,75,65\n2026-09-07 23:00:00,999,75,65\n'
|
|
response = client.post('/data/', data={'file': (io.BytesIO(payload), 'readings.csv')}, follow_redirects=True)
|
|
assert 'Check the measurement values on row 3' in response.text
|
|
assert Reading.query.count() == 0
|
|
payload = payload.split(b'2026-09-07 23:00:00')[0]
|
|
response = client.post('/data/', data={'file': (io.BytesIO(payload), 'readings.csv')}, follow_redirects=True)
|
|
assert 'Data imported successfully' in response.text
|
|
assert Reading.query.count() == 1
|
|
|
|
|
|
@pytest.mark.parametrize('local_time', ['2026-10-04T02:30', '2026-04-05T02:30'])
|
|
def test_ambiguous_or_nonexistent_entry_time(client, local_time):
|
|
response = client.post('/reading/add', data=dict(timestamp=local_time, systolic=120, diastolic=75, heart_rate=65))
|
|
assert response.status_code == 200
|
|
assert 'daylight saving' in response.text
|
|
assert Reading.query.count() == 0
|
|
|
|
|
|
def test_csrf_required_for_saves_deletes_and_imports(app, client):
|
|
app.config['WTF_CSRF_ENABLED'] = True
|
|
item = add(datetime(2026, 9, 7, 22))
|
|
response = client.post('/reading/add', data=dict(timestamp='2026-09-08T09:00', systolic=120, diastolic=75, heart_rate=65))
|
|
assert 'CSRF token is missing' in response.text
|
|
client.post(f'/reading/{item.id}/delete')
|
|
client.post('/data/', data={'file': (io.BytesIO(b'Timestamp,Systolic,Diastolic,Heart Rate\n2026-09-07 22:00:00,120,75,65\n'), 'readings.csv')})
|
|
assert Reading.query.count() == 1
|
|
|
|
|
|
@pytest.mark.parametrize('url,fragment', [('/dashboard/list', 'view=history'),
|
|
('/dashboard/table', 'view=history'), ('/dashboard/graph', 'view=trends'),
|
|
('/dashboard/weekly', 'unit=week'), ('/dashboard/monthly', 'unit=month')])
|
|
def test_old_bookmarks_still_work(client, url, fragment):
|
|
response = client.get(url)
|
|
assert response.status_code == 302
|
|
assert fragment in response.location
|
|
assert client.get(response.location).status_code == 200
|