Files
schedule/tests/test_api.py
Vladislav Kopnin 34b975da69 Перенос в master всего, кроме админки: ТОП-группы, порядок групп, запуск без Docker
Админка осталась отдельной ветвью admin-panel; в master её нет (ни кода, ни
страниц, ни переменных окружения, ни тома настроек).

Что перенесено:
- parsing: имена групп вида «ИВТ-ТОП-11БО» распознаются, порядок групп = порядок
  столбцов таблицы (похожие заголовки с пробелами группой не считаются);
- main: GET /groups отдаёт группы в порядке столбцов; интервал обновления из
  SCHEDULE_INTERVAL_MINUTES (со старым SCHEDULE_CACHE_TTL как fallback);
- cache: по интервалу сверяем modified — если файл не менялся, не скачиваем;
  при загрузке считаем sha256 и пишем в лог и /status, изменился ли файл;
- source: запросы к Яндекс.Диску с no-cache и меткой времени;
- frontend: ответы API с Cache-Control: no-store, общий .env из корня проекта
  (и backend, и frontend), страницы/прокси админки нет;
- Dockerfile/compose/.env.example: без ADMIN_* и без тома настроек; в api-образ
  попадают все модули backend (проверяется тестом test_deploy);
- Makefile: make help/venv/run/run-api/run-front/test + docker-цели — «make run»
  поднимает всё локально без Docker;
- README: быстрый старт, полный список зависимостей, запуск локально и в Docker;
- deploy/: пример nginx и инструкция «второй стенд рядом с основным».

Тесты: 114 (удалены админские), все зелёные.
2026-10-06 15:15:44 +03:00

265 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты API через TestClient: маршруты, ошибки, кэш (TTL/modified/stale-while-error)."""
import time
from urllib.parse import quote
import pytest
from fastapi.testclient import TestClient
import main
from fixtures import fixture_xlsx, make_cache
from schedule import ScheduleCache, ScheduleError
from schedule import parsing
@pytest.fixture()
def cache(monkeypatch):
c, calls, fail = make_cache(monkeypatch, fixture_xlsx())
c.calls = calls
c.fail = fail
return c
@pytest.fixture()
def client(cache):
app = main.create_app(cache)
with TestClient(app) as c:
yield c
# --- обслуживание ----------------------------------------------------------------
def test_healthz(client):
assert client.get("/healthz").json() == {"ok": True}
def test_root_lists_endpoints(client):
body = client.get("/").json()
assert "/groups" in body["endpoints"]
# --- справочники ------------------------------------------------------------------
def test_groups(client):
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
def test_groups_keep_sheet_order(monkeypatch):
"""GET /groups отдаёт группы в порядке столбцов таблицы, а не по алфавиту."""
from fixtures import fixture_sheets
from xlsx_builder import build_workbook
sheets = fixture_sheets()
header = sheets["Бакалавры и магистры"].rows[0]
header[6] = "ЯЯ-ТОП-11БО\nсеместр до 11.01.27"
header[7] = "АА-11БО\nсеместр до 29.12.26"
cache, _, _ = make_cache(monkeypatch, build_workbook(sheets))
with TestClient(main.create_app(cache)) as client:
assert client.get("/groups").json() == ["ЯЯ-ТОП-11БО", "АА-11БО"]
def test_teachers(client):
assert client.get("/teachers").json() == [
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
]
def test_rooms(client):
assert client.get("/rooms").json() == [
"ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301",
]
def test_group_schedule(client):
body = client.get("/schedule/ИВТ-11БО").json()
assert body["semester_until"] == "11.01.27"
p1 = body["days"]["понедельник"]["1"]
assert p1["time"] == "9:00-10:35"
assert p1["ч"]["lessons"][0]["room"] == "ауд. 215"
assert p1["ч"]["lessons"][0]["note"] == "пара 14.09 не будет"
assert p1["з"]["lessons"][0]["room"] == "ауд. 216"
assert body["days"]["понедельник"]["2"]["ч"]["lessons"][0]["marker"] == "к.в. - 2"
def test_group_schedule_day(client):
body = client.get("/schedule/ИВТ-11БО/понедельник").json()
assert body["day"] == "понедельник"
assert "1" in body["periods"]
empty = client.get("/schedule/ИВТ-11БО/суббота").json()
assert empty["periods"] == {}
def test_group_not_found(client):
resp = client.get("/schedule/НЕТ-11БО")
assert resp.status_code == 404
def test_bad_day(client):
resp = client.get("/schedule/ИВТ-11БО/бука")
assert resp.status_code == 400
def test_today(client):
resp = client.get("/today", params={"group": "ИВТ-11БО"})
assert resp.status_code == 200
body = resp.json()
assert body["day"] in parsing.DAYS
assert body["group"] == "ИВТ-11БО"
assert client.get("/today", params={"group": "НЕТ"}).status_code == 404
def test_teacher(client):
body = client.get("/teacher/Смирнов А.В.").json()
assert body["full_name"] == "Смирнов Александр Викторович"
assert body["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Математика"
assert client.get("/teacher/Нет А.Н.").status_code == 404
def test_room(client):
body = client.get("/room/" + quote("ауд. 215")).json()
assert body["info"] == "комп.класс, 12 мест"
assert body["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Математика"
assert client.get("/room/" + quote("ауд. 999")).status_code == 404
def test_rooms_free(client):
free = client.get("/rooms-free", params={"day": "суббота", "period": 1}).json()
assert [r["room"] for r in free] == ["ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301"]
busy_day = client.get("/rooms-free", params={"day": "понедельник", "period": 1}).json()
assert [r["room"] for r in busy_day] == ["ауд. 201", "ауд. 220"]
assert client.get("/rooms-free", params={"day": "суббота", "period": 9}).status_code == 400
assert client.get("/rooms-free", params={"day": "бука", "period": 1}).status_code == 400
def test_rooms_day(client):
body = client.get("/rooms-day", params={"day": "понедельник"}).json()
assert body["day"] == "понедельник"
rooms = {r["room"]: r for r in body["rooms"]}
assert set(rooms) == {"ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301"}
room_301 = rooms["ауд. 301"]
assert room_301["periods"]["1"]["time"] == "9:00-10:35"
assert room_301["periods"]["1"]["ч"][0]["subject"] == "Физика"
assert rooms["ауд. 201"]["periods"]["2"]["з"][0]["subject"] == "Химия"
assert client.get("/rooms-day", params={"day": "бука"}).status_code == 400
def test_week(client):
body = client.get("/week").json()
assert len(body["weeks"]) == 8
assert body["weeks"][0] == {"parity": "ч", "start": "2026-08-31", "end": "2026-09-06"}
def test_status(client):
body = client.get("/status").json()
assert body["error"] is None
assert body["table_modified"] == "fixed-modified"
assert body["last_updated"]
assert body["cache_ttl_seconds"] == 3600
# --- кэш ---------------------------------------------------------------------------
def test_cache_no_repeated_download(client, cache):
client.get("/groups")
client.get("/teachers")
client.get("/rooms")
assert cache.calls["download"] == 1
def test_modified_unchanged_no_download(cache):
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем."""
cache._ttl = 0
cache.get()
cache.get()
assert cache.calls["download"] == 1
assert cache.calls["modified"] >= 2
def test_status_reports_file_changes(monkeypatch):
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
import schedule.cache as cache_mod
from fixtures import fixture_sheets
from xlsx_builder import build_workbook
def xlsx_with_room(room: str) -> bytes:
sheets = fixture_sheets()
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
return build_workbook(sheets)
files = {"data": xlsx_with_room("215"), "modified": "m1"}
monkeypatch.setattr(
cache_mod, "fetch_table_modified", lambda url, timeout=20.0: files["modified"]
)
monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"])
cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60)
cache.get()
first = cache.status()
assert first["content_hash"]
assert first["changed"] is False # первая загрузка — не «изменение»
assert first["changed_at"] is not None
files["data"] = xlsx_with_room("216")
files["modified"] = "m2"
cache.get() # ttl=0 и новый modified — перечитываем таблицу
changed = cache.status()
assert changed["changed"] is True
assert changed["content_hash"] != first["content_hash"]
# новая ревизия файла с тем же содержимым → скачали, но изменений нет
files["modified"] = "m3"
cache.get()
assert cache.status()["changed"] is False
def test_cache_stale_while_error(cache):
cache._ttl = 0 # форсим повторную попытку
cache.get()
data_before, _ = cache.get()
# полный обрыв связи: и modified, и download
cache.fail["download"] = True
cache.fail["modified"] = True
try:
cache.get() # ошибка -> старые данные, без эксепсов
except ScheduleError:
pytest.fail("ожидается stale-ответ, а не ошибка")
status = cache.status()
assert status["error"] is not None
assert len(data_before) == 2
# повторная попытка в пределах retry_after не скачивает повторно
downloads = cache.calls["download"]
cache.get()
assert cache.calls["download"] == downloads
# после retry_after и исправления сети — успешное обновление
cache._retry_after = 0
cache.fail["download"] = False
cache.fail["modified"] = False
cache.get()
assert cache.status()["error"] is None
def test_cache_failure_without_data_raises(cache):
cache.fail["download"] = True
with pytest.raises(ScheduleError):
cache.get()
assert cache.status()["last_updated"] is None
def test_cache_without_link():
"""Без ссылки приложение живёт: /healthz есть, данные отвечают понятной ошибкой."""
from fastapi.testclient import TestClient as TC
from schedule import ScheduleCache
cache = ScheduleCache(link="", retry_after=60)
app = main.create_app(cache)
with TC(app) as c:
assert c.get("/healthz").status_code == 200
assert c.get("/groups").status_code == 502
body = c.get("/status").json()
assert "SCHEDULE_LINK" in body["error"]