"""Тесты 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"]