Админка осталась отдельной ветвью 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 (удалены админские), все зелёные.
265 lines
9.8 KiB
Python
265 lines
9.8 KiB
Python
"""Тесты 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"]
|