Files
schedule/tests/test_api.py
Vladislav Kopnin cfa6a99639 Админка: секретный путь, смена ссылки, интервал и принудительное обновление
Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль —
ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты
/api/admin/* не регистрируются, прод можно не трогать).

Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку
(проверяется скачиванием и разбором до сохранения, при ошибке откат),
принудительно обновить таблицу вне интервала и без проверки modified,
задать интервал в минутах (1..1440).

Реализация:
- backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов,
  HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время;
  compare_digest по байтам — кириллица в логине не роняет сервер;
- schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная
  запись, терпимость к битому/недоступному файлу (тогда живёт в памяти);
- schedule/cache.py — интервал вместо TTL, apply_link с откатом,
  force_reload, state() для панели; кэш без ссылки больше не роняет старт;
- frontend: страница admin.html (вне static, прямой /admin.html закрыт) и
  прокси /api/admin/* с телом и Set-Cookie;
- docker-compose: том schedule_data для настроек, ADMIN_* в окружении;
- deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru.

Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
2026-09-28 12:06:25 +03:00

213 lines
7.5 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_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_cache_without_link():
"""Без ссылки приложение живёт: /healthz есть, данные отвечают понятной ошибкой."""
from fastapi.testclient import TestClient as TC
from schedule import ScheduleCache
from schedule.settings import SettingsStore
cache = ScheduleCache(settings=SettingsStore(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 "Ссылка" in body["error"]