From ac8859d9d351969927ec038e6baf8a5698439fab Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Fri, 2 Oct 2026 13:24:18 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0?= =?UTF-8?q?=20PostgreSQL:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=B0,=20=D1=8D=D0=BA=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82,=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4,=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 теста pytest на настоящей базе (адрес — TEST_DATABASE_URL): владелец, посторонний, редактор и ссылка на просмотр; экспорт по id только для владельца и редакторов; регистрация и вход, защита next; периоды через Новый год, сортировка времени, сборка дня и слияние расписаний. SQLAlchemy и alembic закреплены по продакшену: SQLAlchemy 2.1 по умолчанию требует другой драйвер PostgreSQL (psycopg 3). --- pyproject.toml | 2 +- requirements.txt | 2 + tests/__init__.py | 0 tests/conftest.py | 99 +++++++++++++++++++++++++++++++++++++++++++ tests/test_access.py | 76 +++++++++++++++++++++++++++++++++ tests/test_auth.py | 36 ++++++++++++++++ tests/test_helpers.py | 50 ++++++++++++++++++++++ 7 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_access.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_helpers.py diff --git a/pyproject.toml b/pyproject.toml index d761be6..546d797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101", "S105", "S106"] +"tests/*" = ["S101", "S105", "S106", "S107"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index 7ee3a73..7ece408 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Flask==3.1.0 Flask-SQLAlchemy==3.1.1 +SQLAlchemy==2.0.49 +alembic==1.18.4 Flask-Migrate==4.0.7 Flask-Login==0.6.3 Flask-WTF==1.2.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e5914c6 --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_access.py b/tests/test_access.py new file mode 100644 index 0000000..d607c50 --- /dev/null +++ b/tests/test_access.py @@ -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}) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..285c69c --- /dev/null +++ b/tests/test_auth.py @@ -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'] diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..f03cc5f --- /dev/null +++ b/tests/test_helpers.py @@ -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)