Тесты и инфраструктура

- tests/: 104 теста — lessons (все форматы ячеек), xlsx (мержи/скрытые
  столбцы/красный текст/даты), parsing (группы/недели/индексы), API (TestClient,
  TTL/modified/stale-while-error, 404/400/502)
- xlsx_builder.py: генерация xlsx в памяти для тестов без openpyxl
- docker-compose: healthz-хелскчеки (не блокируют старт), SCHEDULE_LINK
- Makefile: target test
- requirements: закреплённые версии
- .gitignore: __pycache__/, .venv/ и т.д.
This commit is contained in:
2026-09-09 11:33:58 +03:00
parent 2febf9b4ff
commit 9c9875579c
14 changed files with 1122 additions and 18 deletions

207
tests/test_api.py Normal file
View File

@@ -0,0 +1,207 @@
"""Тесты 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_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_cache_modified_unchanged_no_download(cache):
cache._ttl = 0 # даже с истёкшим TTL
cache.get()
cache.get()
assert cache.calls["download"] == 1
assert cache.calls["modified"] >= 2
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_broken_cache_http(client=None):
from fastapi.testclient import TestClient as TC
app = main.create_app(main._BrokenCache())
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"]