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:
parent
3a21ef916a
commit
ac8859d9d3
7 changed files with 264 additions and 1 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
0
tests/__init__.py
Normal file
99
tests/conftest.py
Normal file
99
tests/conftest.py
Normal 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
76
tests/test_access.py
Normal 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
36
tests/test_auth.py
Normal 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
50
tests/test_helpers.py
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue