1
0
Fork 0
mirror of https://github.com/EDeev/tablo.git synced 2026-10-07 20:49:31 +03:00

Тесты на PostgreSQL: права доступа, экспорт, ссылки, вход, разбор периодов и времени

24 теста pytest на настоящей базе (адрес — TEST_DATABASE_URL): владелец, посторонний,
редактор и ссылка на просмотр; экспорт по id только для владельца и редакторов;
регистрация и вход, защита next; периоды через Новый год, сортировка времени,
сборка дня и слияние расписаний. SQLAlchemy и alembic закреплены по продакшену:
SQLAlchemy 2.1 по умолчанию требует другой драйвер PostgreSQL (psycopg 3).
This commit is contained in:
Деев Егор Викторович 2026-10-02 13:24:18 +00:00
parent 3a21ef916a
commit ac8859d9d3
7 changed files with 264 additions and 1 deletions

View file

@ -11,7 +11,7 @@ ignore = [
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S105", "S106"] "tests/*" = ["S101", "S105", "S106", "S107"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View file

@ -1,5 +1,7 @@
Flask==3.1.0 Flask==3.1.0
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
SQLAlchemy==2.0.49
alembic==1.18.4
Flask-Migrate==4.0.7 Flask-Migrate==4.0.7
Flask-Login==0.6.3 Flask-Login==0.6.3
Flask-WTF==1.2.2 Flask-WTF==1.2.2

0
tests/__init__.py Normal file
View file

99
tests/conftest.py Normal file
View file

@ -0,0 +1,99 @@
"""Тесты на настоящем PostgreSQL (JSON-поля): адрес базы — в TEST_DATABASE_URL."""
import os
import pytest
from sqlalchemy import text
from app import create_app, db
from app.models import Schedule, Share, ShareEditor, User
DB_URL = os.getenv('TEST_DATABASE_URL', 'postgresql://tablo:tablo@localhost:5432/tablo_test')
SAMPLE_DATA = [
{
'subject': 'Базы данных',
'types': {
'Лекция': {'color': 'blue', 'dates': {'01.09-31.12': [
['понедельник', '10:40-12:10', 'АВ-301', 'Иванов И.И.'],
['понедельник', '9:00-10:30', 'АВ-301', 'Иванов И.И.'],
]}},
},
},
]
@pytest.fixture(scope='session')
def _app():
app = create_app({
'TESTING': True,
'SECRET_KEY': 'test-secret',
'WTF_CSRF_ENABLED': False,
'SQLALCHEMY_DATABASE_URI': DB_URL,
})
with app.app_context():
db.drop_all()
db.create_all()
yield app
with app.app_context():
db.drop_all()
@pytest.fixture(autouse=True)
def app(_app):
"""Свой контекст приложения на каждый тест, после теста таблицы очищаются."""
ctx = _app.app_context()
ctx.push()
yield _app
db.session.remove()
tables = ', '.join(t.name for t in db.metadata.sorted_tables)
with db.engine.begin() as conn:
conn.execute(text(f'TRUNCATE {tables} RESTART IDENTITY CASCADE'))
ctx.pop()
def make_user(login, password='password123'):
user = User(login=login)
user.set_password(password)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def owner():
return make_user('owner')
@pytest.fixture
def stranger():
return make_user('stranger')
@pytest.fixture
def schedule(owner):
s = Schedule(user_id=owner.id, name='Весенний семестр', data=SAMPLE_DATA)
db.session.add(s)
db.session.commit()
return s
def login(client, user_login, password='password123'):
return client.post('/login', data={'login': user_login, 'password': password})
@pytest.fixture
def share_edit(schedule, owner):
share = Share(schedule_id=schedule.id, owner_id=owner.id, share_type='edit')
db.session.add(share)
db.session.commit()
return share
def add_editor(share, user):
db.session.add(ShareEditor(share_id=share.id, user_id=user.id))
db.session.commit()
@pytest.fixture
def client(app):
return app.test_client()

76
tests/test_access.py Normal file
View file

@ -0,0 +1,76 @@
import pytest
from app import db
from app.models import Share
from tests.conftest import add_editor, login, make_user
EXPORTS = ['json', 'csv']
def test_owner_sees_schedule(client, owner, schedule):
login(client, 'owner')
assert client.get(f'/schedules/{schedule.id}').status_code == 200
def test_anonymous_is_redirected_to_login(client, schedule):
response = client.get(f'/schedules/{schedule.id}')
assert response.status_code == 302
assert '/login' in response.headers['Location']
def test_stranger_cannot_open_schedule(client, schedule, stranger):
login(client, 'stranger')
response = client.get(f'/schedules/{schedule.id}', follow_redirects=False)
assert response.status_code in (302, 403)
@pytest.mark.parametrize('fmt', EXPORTS)
def test_owner_can_export(client, schedule, fmt):
login(client, 'owner')
response = client.get(f'/schedules/{schedule.id}/export/{fmt}')
assert response.status_code == 200
assert 'Базы данных' in response.get_data(as_text=True)
@pytest.mark.parametrize('fmt', EXPORTS)
def test_view_share_does_not_open_export_by_id(client, schedule, owner, stranger, fmt):
"""Ссылка на просмотр открывает расписание по токену, но не экспорт по id."""
db.session.add(Share(schedule_id=schedule.id, owner_id=owner.id, share_type='view'))
db.session.commit()
login(client, 'stranger')
response = client.get(f'/schedules/{schedule.id}/export/{fmt}')
assert response.status_code == 302
assert 'Базы данных' not in response.get_data(as_text=True)
def test_editor_can_view_and_export(client, schedule, share_edit):
editor = make_user('editor')
add_editor(share_edit, editor)
login(client, 'editor')
assert client.get(f'/schedules/{schedule.id}').status_code == 200
assert client.get(f'/schedules/{schedule.id}/export/json').status_code == 200
def test_view_share_link_works_without_login(client, schedule, owner):
share = Share(schedule_id=schedule.id, owner_id=owner.id, share_type='view')
db.session.add(share)
db.session.commit()
response = client.get(f'/shared/{share.token}')
assert response.status_code == 200
assert 'Базы данных' in response.get_data(as_text=True)
def test_stranger_cannot_rename(client, schedule, stranger):
login(client, 'stranger')
client.post(f'/schedules/{schedule.id}/rename', data={'name': 'Взлом'})
db.session.refresh(schedule)
assert schedule.name == 'Весенний семестр'
def test_app_refuses_to_start_without_secret_key(monkeypatch):
from app import create_app
monkeypatch.delenv('SECRET_KEY', raising=False)
monkeypatch.delenv('FLASK_DEBUG', raising=False)
with pytest.raises(RuntimeError):
create_app({'SECRET_KEY': None})

36
tests/test_auth.py Normal file
View file

@ -0,0 +1,36 @@
from app.models import User
def test_register_and_login(client, app):
response = client.post('/register', data={'login': 'student', 'password': 'secret123', 'confirm': 'secret123'})
assert response.status_code in (200, 302)
assert User.query.filter_by(login='student').one().check_password('secret123')
client.get('/logout')
response = client.post('/login', data={'login': 'student', 'password': 'secret123'})
assert response.status_code == 302
def test_wrong_password_is_rejected(client, owner):
response = client.post('/login', data={'login': 'owner', 'password': 'wrong'})
assert response.status_code == 200
assert client.get('/').status_code == 302
def test_password_is_hashed(owner):
assert owner.password_hash != 'password123'
def test_login_next_redirect_stays_on_site(client, owner):
response = client.post('/login?next=https://evil.example/phish', data={'login': 'owner', 'password': 'password123'})
assert response.status_code == 302
assert 'evil.example' not in response.headers['Location']
def test_login_next_relative_path_is_kept(client, owner):
response = client.post('/login?next=/schedules/upload', data={'login': 'owner', 'password': 'password123'})
assert response.headers['Location'].endswith('/schedules/upload')
def test_login_next_backslash_trick_is_rejected(client, owner):
response = client.post('/login?next=/%5Cevil.example', data={'login': 'owner', 'password': 'password123'})
assert 'evil.example' not in response.headers['Location']

50
tests/test_helpers.py Normal file
View file

@ -0,0 +1,50 @@
from datetime import date
from app.services.merge import merge_schedules_data
from app.services.schedule_helpers import _parse_date_range, _time_sort_key, build_day_view, get_all_subjects
from tests.conftest import SAMPLE_DATA
def test_period_across_new_year_ends_next_year():
start, end = _parse_date_range('01.09-03.01')
assert start < end
assert end.year == start.year + 1
def test_period_within_year():
start, end = _parse_date_range('01.02-31.05')
assert (start.month, end.month) == (2, 5)
assert start.year == end.year
def test_whole_period_and_garbage():
assert _parse_date_range('Весь период') == (None, None)
assert _parse_date_range('ерунда') == (None, None)
def test_time_sort_key_handles_missing_leading_zero():
times = ['10:40-12:10', '9:00-10:30', None, '08:00-09:30']
assert sorted(times, key=_time_sort_key) == ['08:00-09:30', '9:00-10:30', '10:40-12:10', None]
def test_day_view_is_sorted_by_time():
monday = date(date.today().year, 9, 1)
while monday.weekday() != 0:
monday = monday.replace(day=monday.day + 1)
groups = build_day_view(SAMPLE_DATA, monday)
times = [g['time'] if isinstance(g, dict) else g[0]['time'] for g in groups]
assert times == ['9:00-10:30', '10:40-12:10']
def test_merge_combines_slots_without_duplicates():
other = [{'subject': 'Базы данных', 'types': {'Практика': {'color': 'green', 'dates': {
'01.09-31.12': [['среда', '12:20-13:50', 'АВ-210', 'Петров П.П.']]}}}}]
merged = merge_schedules_data([SAMPLE_DATA, SAMPLE_DATA, other])
assert len(merged) == 1
types = merged[0]['types']
assert set(types) == {'Лекция', 'Практика'}
assert len(types['Лекция']['dates']['01.09-31.12']) == 2
def test_get_all_subjects():
assert 'Базы данных' in get_all_subjects(SAMPLE_DATA)