- 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/ и т.д.
208 lines
7.2 KiB
Python
208 lines
7.2 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_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"]
|