Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+451
View File
@@ -0,0 +1,451 @@
import os
import pytest
import tempfile
from datetime import datetime, date, time
# TEST_DATABASE_URL позволяет прогнать те же тесты на PostgreSQL (по умолчанию — временный файл SQLite)
os.environ["DATABASE_URL"] = os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "t.db").replace("\\", "/")
os.environ["SCHEDULER_INTERVAL"] = "3600"
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef")
_DATA = tempfile.mkdtemp()
os.environ["DATA_DIR"] = _DATA
from fastapi.testclient import TestClient # noqa: E402
from app import logic # noqa: E402
from app.main import app # noqa: E402
from app.models import ShiftSchedule, OrderTemplate # noqa: E402
def test_shifts_and_recurrence():
cyc = ShiftSchedule(kind="cycle", work_days=2, off_days=2, cycle_start=date(2026, 1, 1),
start_time=time(20, 0), end_time=time(8, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 1, 22, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 2, 3, 0)) # ночная смена с 1-го
assert logic.shift_active(cyc, datetime(2026, 1, 2, 23, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 3, 3, 0)) # смена со 2-го идёт до 8:00 3-го
assert not logic.shift_active(cyc, datetime(2026, 1, 3, 22, 0)) # 3-е — выходной
wk = ShiftSchedule(kind="weekly", weekdays=[0], start_time=time(8, 0), end_time=time(17, 0))
assert logic.shift_active(wk, datetime(2026, 1, 5, 9, 0)) # понедельник
assert not logic.shift_active(wk, datetime(2026, 1, 6, 9, 0))
t = OrderTemplate(recurrence="monthly", month_day=31, run_time=time(8, 0), start_date=date(2026, 1, 1))
assert logic.next_run_after(t, datetime(2026, 2, 1)) == datetime(2026, 2, 28, 8, 0)
t = OrderTemplate(recurrence="interval", interval_days=3, run_time=time(6, 0), start_date=date(2026, 1, 1))
assert logic.next_run_after(t, datetime(2026, 1, 2)) == datetime(2026, 1, 4, 6, 0)
def test_full_flow():
with TestClient(app) as c:
def login(u, p):
r = c.post("/api/auth/login", json={"username": u, "password": p})
assert r.status_code == 200, r.text
return {"Authorization": "Bearer " + r.json()["access_token"]}
adm = login("admin", "admin")
sch = c.post("/api/schedules", headers=adm, json={"name": "Всегда", "kind": "weekly",
"weekdays": [0, 1, 2, 3, 4, 5, 6], "start_time": "00:00:00", "end_time": "23:59:00"}).json()
night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly",
"weekdays": []}).json()
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician",
"schedule_id": sch["id"]}).json()
tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician",
"schedule_id": night["id"]}).json()
eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).json()
for name, pos in (("ivan", tech), ("petr", tech2), ("eng", eng)):
r = c.post("/api/users", headers=adm, json={"username": name, "password": "Pass1234567",
"position_id": pos["id"]})
assert r.status_code == 201, r.text
ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567")
# технику нельзя создавать справочники
assert c.post("/api/locations", headers=ivan, json={"name": "x"}).status_code == 403
loc = c.post("/api/locations", headers=engh, json={"name": "Цех 1"}).json()
eq = c.post("/api/equipment", headers=engh, json={"name": "Насос Н-1", "location_id": loc["id"]}).json()
cl = c.post("/api/checklists", headers=engh, json={"name": "ТО насоса", "items": [
{"text": "Осмотр", "answer_type": "status"},
{"text": "Давление", "answer_type": "number", "unit": "бар", "min_value": 2, "max_value": 6},
{"text": "Состояние", "answer_type": "choice", "options": ["норма", "износ"]},
{"text": "Заметки", "answer_type": "text", "required": False}]})
assert cl.status_code == 201, cl.text
cl = cl.json()
assert c.post("/api/checklists", headers=engh, json={"name": "bad", "items": [
{"text": "a", "answer_type": "choice"}]}).status_code == 422
# шаблон по расписанию на должность
tpl = c.post("/api/order-templates", headers=engh, json={
"title": "Ежедневное ТО Н-1", "checklist_id": cl["id"], "equipment_id": eq["id"],
"location_id": loc["id"], "assignee_position_id": tech["id"], "reviewer_position_id": eng["id"],
"recurrence": "daily"}).json()
assert tpl["next_run"]
order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"]
# видимость: Иван (на смене) видит, Пётр (выходной) — нет
assert [o["id"] for o in c.get("/api/orders", headers=ivan).json()] == [order_id]
assert c.get("/api/orders", headers=petr).json() == []
assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404
o = c.get(f"/api/orders/{order_id}", headers=ivan).json()
assert o["can_execute"] and o["status"] == "new"
ids = [i["id"] for i in o["items"]]
# неверные ответы
assert c.put(f"/api/orders/{order_id}/items/{ids[2]}", headers=ivan, json={"value": "??"}).status_code == 422
assert c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "abc"}).status_code == 422
# сдать рано нельзя
assert c.post(f"/api/orders/{order_id}/submit", headers=ivan).status_code == 422
c.put(f"/api/orders/{order_id}/items/{ids[0]}", headers=ivan, json={"value": "done"})
o = c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "7,5"}).json()
assert o["status"] == "in_progress" and o["items"][1]["out_of_range"]
c.put(f"/api/orders/{order_id}/items/{ids[2]}", headers=ivan, json={"value": "норма"})
o = c.post(f"/api/orders/{order_id}/submit", headers=ivan).json()
assert o["status"] == "review" and o["done_count"] == 3
# техник не может проверять; инженер возвращает, затем принимает
assert c.post(f"/api/orders/{order_id}/review", headers=ivan, json={"approve": True}).status_code == 403
assert c.post(f"/api/orders/{order_id}/review", headers=engh, json={"approve": False}).status_code == 422
o = c.post(f"/api/orders/{order_id}/review", headers=engh,
json={"approve": False, "comment": "Давление вне нормы"}).json()
assert o["status"] == "in_progress" and o["review_comment"]
c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "4"})
c.post(f"/api/orders/{order_id}/submit", headers=ivan)
o = c.post(f"/api/orders/{order_id}/review", headers=engh, json={"approve": True}).json()
assert o["status"] == "completed"
# ручной наряд сразу на человека, без проверяющего
r = c.post("/api/orders", headers=engh, json={"title": "Разовый", "checklist_id": cl["id"],
"assignee_user_id": 2}).json()
assert c.get("/api/orders/stats", headers=engh).json()["by_status"]["completed"] == 1
assert r["status"] == "new"
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 100
def test_comments_photos_roles_push(monkeypatch):
from app import push
sent = []
monkeypatch.setattr(push, "notify", lambda ids, title, body, url="/": sent.append((sorted(i for i in ids if i), title)))
with TestClient(app) as c:
def login(u, p):
return {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
mod = c.post("/api/users", headers=adm, json={"username": "mod", "password": "Pass1234567", "is_moderator": True}).json()
assert mod["is_moderator"]
modh = login("mod", "Pass1234567")
# модератор: управляет пользователями/должностями, но не админами и не сервером
assert c.post("/api/positions", headers=modh, json={"name": "Электрик"}).status_code == 201
assert c.post("/api/users", headers=modh, json={"username": "u1", "password": "Pass1234567"}).status_code == 201
assert c.post("/api/users", headers=modh, json={"username": "u2", "password": "Pass1234567", "is_admin": True}).status_code == 403
assert c.patch("/api/users/1", headers=modh, json={"full_name": "x"}).status_code == 403
assert c.delete("/api/users/1", headers=modh).status_code == 403
assert c.get("/api/settings", headers=modh).status_code == 403
assert c.get("/api/settings", headers=adm).status_code == 200
tech = c.post("/api/positions", headers=modh, json={"name": "Т"}).json()
uid = c.post("/api/users", headers=modh, json={"username": "t1", "password": "Pass1234567", "position_id": tech["id"]}).json()["id"]
t1, other = login("t1", "Pass1234567"), login("u1", "Pass1234567")
cl = c.post("/api/checklists", headers=modh, json={"name": "Ф", "items": [
{"text": "Снимок", "answer_type": "status", "photo": "required"},
{"text": "Без фото", "answer_type": "text", "photo": "none", "required": False}]}).json()
o = c.post("/api/orders", headers=modh, json={"title": "Фото-наряд", "checklist_id": cl["id"], "assignee_user_id": uid}).json()
assert (sorted([uid]), "Новый наряд") in sent
oid, i1, i2 = o["id"], o["items"][0]["id"], o["items"][1]["id"]
assert not o["can_execute"] # модератор не исполнитель
# настройка каталога фото
import tempfile
d = tempfile.mkdtemp()
assert c.put("/api/settings", headers=adm, json={"photo_dir": d, "max_photo_mb": 1}).json()["photo_dir"] == d
c.put(f"/api/orders/{oid}/items/{i1}", headers=t1, json={"value": "done"})
assert c.post(f"/api/orders/{oid}/submit", headers=t1).status_code == 422 # нет обязательного фото
up = lambda h, item, body, ct="image/png": c.post(f"/api/orders/{oid}/items/{item}/photos", headers={**h, "Content-Type": ct}, content=body)
assert up(t1, i2, PNG).status_code == 400 # фото не предусмотрено
assert up(t1, i1, b"not an image").status_code == 415
assert up(t1, i1, PNG + b"0" * 1_100_000).status_code == 413
assert up(other, i1, PNG).status_code == 404 # чужой наряд не виден
r = up(t1, i1, PNG)
assert r.status_code == 201, r.text
pid = r.json()["items"][0]["photos"][0]["id"]
assert c.get(f"/api/photos/{pid}", headers=t1).content == PNG
assert c.get(f"/api/photos/{pid}", headers=other).status_code == 404
assert c.get(f"/api/photos/{pid}").status_code == 401
assert c.post(f"/api/orders/{oid}/submit", headers=t1).status_code == 200
# комментарии
sent.clear()
assert c.post(f"/api/orders/{oid}/comments", headers=t1, json={"text": "Готово, замечаний нет"}).status_code == 201
assert c.post(f"/api/orders/{oid}/comments", headers=other, json={"text": "x"}).status_code == 404
cm = c.post(f"/api/orders/{oid}/comments", headers=modh, json={"text": "Принято"}).json()
assert [x["text"] for x in c.get(f"/api/orders/{oid}/comments", headers=t1).json()] == ["Готово, замечаний нет", "Принято"]
assert c.get(f"/api/orders/{oid}", headers=t1).json()["comments_count"] == 2
assert c.delete(f"/api/comments/{cm['id']}", headers=t1).status_code == 403
assert c.delete(f"/api/comments/{cm['id']}", headers=modh).status_code == 204
assert any(t.startswith("Комментарий") for _, t in sent)
# push-подписка
key = c.get("/api/push/key", headers=t1).json()["key"]
assert len(key) > 60
sub = {"endpoint": "https://fcm.googleapis.com/fcm/send/abc", "keys": {"p256dh": "k", "auth": "a"}}
assert c.post("/api/push/subscribe", headers=t1, json=sub).status_code == 204
assert c.post("/api/push/subscribe", headers=t1, json=sub).status_code == 204 # идемпотентно
assert c.post("/api/push/unsubscribe", headers=t1, json=sub).status_code == 204
def test_api_v1_tokens_sync(monkeypatch):
from app import push
monkeypatch.setattr(push, "notify", lambda *a, **k: None)
with TestClient(app) as c:
# версия и формат времени
h = c.get("/api/v1/health").json()
assert h["api_version"] == 1 and h["time"][-6] in "+-" # смещение часового пояса
assert c.get("/api/health").status_code == 200 # старый путь работает
assert "/api/v1/orders" in c.get("/openapi.json").json()["paths"]
tok = c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin", "device": "pytest"}).json()
assert tok["expires_in"] == 3600 and tok["refresh_token"]
adm = {"Authorization": "Bearer " + tok["access_token"]}
# refresh с ротацией, повтор старого токена отзывает сессию
t2 = c.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}).json()
assert t2["refresh_token"] != tok["refresh_token"]
assert c.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/refresh", json={"refresh_token": t2["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/refresh", json={"refresh_token": "junk"}).status_code == 401
t3 = c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()
adm = {"Authorization": "Bearer " + t3["access_token"]}
c.post("/api/v1/auth/logout", json={"refresh_token": t3["refresh_token"]})
assert c.post("/api/v1/auth/refresh", json={"refresh_token": t3["refresh_token"]}).status_code == 401
# ограничение попыток входа
for _ in range(5):
assert c.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}).status_code == 401
assert c.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}).status_code == 429
_fails_reset()
# смена пароля завершает остальные сессии
c.post("/api/v1/users", headers=adm, json={"username": "sync1", "password": "Pass1234567"})
lg = lambda p: c.post("/api/v1/auth/login", json={"username": "sync1", "password": p}).json()
a, b = lg("Pass1234567"), lg("Pass1234567")
ha = {"Authorization": "Bearer " + a["access_token"]}
assert c.post("/api/v1/auth/change-password", headers=ha, json={"old_password": "bad", "new_password": "NewPass12345"}).status_code == 403
n = c.post("/api/v1/auth/change-password", headers=ha, json={"old_password": "Pass1234567", "new_password": "NewPass12345"}).json()
assert c.post("/api/v1/auth/refresh", json={"refresh_token": n["refresh_token"]}).status_code == 200
assert c.post("/api/v1/auth/refresh", json={"refresh_token": b["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/login", json={"username": "sync1", "password": "Pass1234567"}).status_code == 401
uid = c.get("/api/v1/users", headers=adm).json()[-1]["id"]
# офлайн-синхронизация
cl = c.post("/api/v1/checklists", headers=adm, json={"name": "S", "items": [
{"text": "a", "answer_type": "status"}, {"text": "n", "answer_type": "number", "min_value": 1, "max_value": 5},
{"text": "ph", "answer_type": "text", "required": False, "photo": "optional"}]}).json()
o = c.post("/api/v1/orders", headers=adm, json={"title": "Офлайн", "checklist_id": cl["id"], "assignee_user_id": uid,
"due_at": "2026-12-01T10:00:00+03:00"}).json()
assert o["due_at"].startswith("2026-12-01T10:00:00")
assert c.post("/api/v1/orders", headers=adm, json={"title": "UTC", "checklist_id": cl["id"],
"due_at": "2026-12-01T07:00:00Z"}).json()["due_at"].startswith("2026-12-01T10:00:00+03:00")
sh = {"Authorization": "Bearer " + lg("NewPass12345")["access_token"]}
ids = [i["id"] for i in o["items"]]
r = c.post(f"/api/v1/orders/{o['id']}/sync", headers=sh, json={"items": [
{"item_id": ids[0], "value": "done", "answered_at": "2026-10-07T12:00:00+03:00"},
{"item_id": ids[1], "value": "99", "answered_at": "2026-10-07T12:01:00+03:00"},
{"item_id": ids[1], "value": "zzz"}, {"item_id": 99999, "value": "x"}], "submit": True}).json()
assert [x["status"] for x in r["results"]] == ["ok", "ok", "error", "error"]
assert r["submitted"] and r["order"]["status"] == "completed" and r["order"]["items"][1]["out_of_range"]
# повторная отправка — безопасна и не ломается (наряд уже сдан)
assert c.post(f"/api/v1/orders/{o['id']}/sync", headers=sh, json={"items": []}).status_code == 403
# устаревший ответ не затирает более свежий
o2 = c.post("/api/v1/orders", headers=adm, json={"title": "Конфликт", "checklist_id": cl["id"], "assignee_user_id": uid}).json()
i2 = o2["items"][1]["id"]
c.put(f"/api/v1/orders/{o2['id']}/items/{i2}", headers=sh, json={"value": "3"})
r = c.post(f"/api/v1/orders/{o2['id']}/sync", headers=sh, json={"items": [
{"item_id": i2, "value": "1", "answered_at": "2020-01-01T00:00:00Z"}]}).json()
assert r["results"][0]["status"] == "stale" and r["order"]["items"][1]["value"] == "3"
# updated_since + include_items
full = c.get("/api/v1/orders?include_items=true", headers=sh).json()
assert all(x["items"] is not None for x in full)
cursor = max(x["updated_at"] for x in full)
assert c.get("/api/v1/orders", headers=sh, params={"updated_since": cursor}).json() == []
c.post(f"/api/v1/orders/{o2['id']}/comments", headers=adm, json={"text": "ping"})
ch = c.get("/api/v1/orders", headers=sh, params={"updated_since": cursor}).json()
assert [x["id"] for x in ch] == [o2["id"]]
# идемпотентная загрузка фото
up = lambda: c.post(f"/api/v1/orders/{o2['id']}/items/{o2['items'][2]['id']}/photos",
headers={**sh, "Content-Type": "image/png", "Idempotency-Key": "abc-1"}, content=PNG)
assert up().status_code == 201
assert len(up().json()["items"][2]["photos"]) == 1
# FCM: регистрация устройства и отправка
tk = "x" * 40
assert c.post("/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
assert c.post("/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
import httpx
from app.db import SessionLocal
from app import settings_store as ss
calls = []
class Creds:
project_id, token, valid = "proj", "tok", True
monkeypatch.setattr(push, "_fcm_auth", lambda p: Creds)
monkeypatch.setattr(httpx, "post", lambda url, **kw: (calls.append((url, kw["json"])), type("R", (), {"status_code": 200, "text": ""})())[1])
with SessionLocal() as db:
push._send_fcm(db, {"title": "T", "body": "B", "url": "/x"}, [uid])
assert calls == [] # FCM не настроен
ss.set_(db, "fcm_credentials_path", "dummy.json")
db.commit()
push._send_fcm(db, {"title": "T", "body": "B", "url": "/x"}, [uid])
assert calls[0][0].endswith("/projects/proj/messages:send") and calls[0][1]["message"]["token"] == tk
assert c.request("DELETE", "/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
def _fails_reset():
from app.routers import catalog
catalog._fails.clear()
def test_pattern_schedule():
"""3 дня / отсыпной / 3 ночи / отсыпной / 3 выходных (11 дней), и «сутки через трое»."""
from app.models import ShiftSchedule
from datetime import date, datetime
W = lambda d, a, b, label="": {"type": "work", "days": d, "start_time": a, "end_time": b, "label": label}
O = lambda d: {"type": "off", "days": d}
s = ShiftSchedule(kind="pattern", cycle_start=date(2026, 1, 1),
pattern=[W(3, "08:00", "20:00", "день"), O(1), W(3, "20:00", "08:00", "ночь"), O(1), O(3)])
at = lambda m, d, h, mi=0: logic.shift_active(s, datetime(2026, m, d, h, mi))
assert at(1, 1, 9) and at(1, 3, 19) and not at(1, 3, 21) and not at(1, 4, 12) # дни, потом отсыпной
assert not at(1, 5, 12) and at(1, 5, 21) and at(1, 6, 3) # ночь 5-го уходит на 6-е
assert at(1, 7, 7) and not at(1, 7, 9) # ночь 7-го закончилась 8:00 8-го? нет — 7-го в 7:00 идёт смена 6-го
assert not at(1, 8, 9) and not at(1, 8, 21) # 8-е — отсыпной после ночей
assert not at(1, 9, 12) and not at(1, 10, 12) and not at(1, 11, 12) # 3 выходных
assert at(1, 12, 9) # цикл 11 дней -> 12 янв снова день
sut = ShiftSchedule(kind="pattern", cycle_start=date(2026, 1, 1), pattern=[W(1, "08:00", "08:00"), O(3)])
assert logic.shift_active(sut, datetime(2026, 1, 1, 8, 0)) and logic.shift_active(sut, datetime(2026, 1, 2, 7, 59))
assert not logic.shift_active(sut, datetime(2026, 1, 2, 8, 0)) and logic.shift_active(sut, datetime(2026, 1, 5, 12))
assert logic.shift_starting(ShiftSchedule(kind="pattern", pattern=[]), date(2026, 1, 1)) is None
def test_schedule_api_pattern():
with TestClient(app) as c:
h = {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
body = {"name": "Сутки/трое", "kind": "pattern", "cycle_start": "2026-01-01",
"pattern": [{"type": "work", "days": 1, "start_time": "08:00", "end_time": "08:00"}, {"type": "off", "days": 3}]}
r = c.post("/api/v1/schedules", headers=h, json=body)
assert r.status_code == 201, r.text
assert r.json()["pattern"][0]["start_time"] == "08:00:00"
assert c.post("/api/v1/schedules", headers=h, json={**body, "name": "bad", "pattern": [{"type": "off", "days": 2}]}).status_code == 422
pv = c.post("/api/v1/schedules/preview", headers=h, json={"schedule": body, "from_date": "2026-01-01", "days": 5}).json()
assert [d["work"] for d in pv] == [True, False, False, False, True]
assert pv[0]["end"].startswith("2026-01-02T08:00:00")
sid = r.json()["id"]
up = c.patch(f"/api/v1/schedules/{sid}", headers=h, json={"pattern": [{"type": "work", "days": 2}, {"type": "off", "days": 1}]})
assert up.status_code == 200 and up.json()["pattern"][0]["days"] == 2
assert c.get("/api/v1/schedules", headers=h).status_code == 200
def test_report_completed_orders():
from datetime import datetime, timedelta
with TestClient(app) as c:
H = lambda u, p: {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = H("admin", "admin")
pos = c.post("/api/v1/positions", headers=adm, json={"name": "Слесарь РП"}).json()
eng = c.post("/api/v1/positions", headers=adm, json={"name": "Инженер РП", "kind": "engineer"}).json()
c.post("/api/v1/users", headers=adm, json={"username": "rep_t", "full_name": "Пётр Сидоров", "password": "Pass1234567", "position_id": pos["id"]})
c.post("/api/v1/users", headers=adm, json={"username": "rep_e", "password": "Pass1234567", "position_id": eng["id"]})
uid = [u for u in c.get("/api/v1/users", headers=adm).json() if u["username"] == "rep_t"][0]["id"]
t, e = H("rep_t", "Pass1234567"), H("rep_e", "Pass1234567")
eq = c.post("/api/v1/equipment", headers=adm, json={"name": "Компрессор К-2", "inventory_no": "K-002"}).json()
cl = c.post("/api/v1/checklists", headers=adm, json={"name": "ТО компрессора", "items": [{"text": "осмотр", "answer_type": "status"}]}).json()
o = c.post("/api/v1/orders", headers=adm, json={"title": "ТО К-2", "checklist_id": cl["id"], "equipment_id": eq["id"], "assignee_user_id": uid}).json()
c.put(f"/api/v1/orders/{o['id']}/items/{o['items'][0]['id']}", headers=t, json={"value": "done"})
assert c.post(f"/api/v1/orders/{o['id']}/submit", headers=t).json()["status"] == "completed"
# смена должности после выполнения не меняет отчёт
other = c.post("/api/v1/positions", headers=adm, json={"name": "Другая"}).json()
c.patch(f"/api/v1/users/{uid}", headers=adm, json={"position_id": other["id"]})
now = datetime.now()
q = {"from": (now - timedelta(hours=2)).isoformat(), "to": (now + timedelta(hours=2)).isoformat()}
j = c.get("/api/v1/reports/completed-orders", headers=e, params=q).json()
r = [x for x in j["rows"] if x["order_id"] == o["id"]][0]
assert (r["equipment"], r["checklist"], r["executor"], r["position"]) == ("Компрессор К-2", "ТО компрессора", "Пётр Сидоров", "Слесарь РП")
assert r["finished_at"][-6] in "+-"
# фильтры
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "equipment_id": 99999}).json()["total"] == 0
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "position_id": pos["id"]}).json()["total"] == 1
far = {"from": (now + timedelta(days=1)).isoformat(), "to": (now + timedelta(days=2)).isoformat()}
assert c.get("/api/v1/reports/completed-orders", headers=e, params=far).json()["total"] == 0
# обязательность и корректность периода, права
assert c.get("/api/v1/reports/completed-orders", headers=e).status_code == 422
assert c.get("/api/v1/reports/completed-orders", headers=e, params={"from": q["to"], "to": q["from"]}).status_code == 422
assert c.get("/api/v1/reports/completed-orders", headers=t, params=q).status_code == 403
# экспорт
pdf = c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "format": "pdf"})
assert pdf.status_code == 200 and pdf.content[:5] == b"%PDF-" and "attachment" in pdf.headers["content-disposition"]
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**far, "format": "pdf"}).content[:5] == b"%PDF-" # пустой отчёт
csv = c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "format": "csv"}).content.decode("utf-8-sig")
assert "Компрессор К-2" in csv and "Слесарь РП" in csv
assert [x["id"] for x in c.get("/api/v1/reports", headers=e).json()] == ["completed-orders", "equipment-state", "execution", "people"]
@pytest.mark.skipif(not os.environ["DATABASE_URL"].startswith("sqlite"), reason="проверка неизменности файла БД — только для SQLite")
def test_order_search():
with TestClient(app) as c:
h = {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
cl = c.post("/api/v1/checklists", headers=h, json={"name": "П", "items": []}).json()
eq = c.post("/api/v1/equipment", headers=h, json={"name": "Гидронасос ЖЕЛТЫЙ"}).json()
mk = lambda t, **k: c.post("/api/v1/orders", headers=h, json={"title": t, "checklist_id": cl["id"], **k}).json()["id"]
a = mk("Обход Котельной"); b = mk("Смазка 100%_готово"); d = mk("Прочее", equipment_id=eq["id"])
s = lambda q: sorted(o["id"] for o in c.get("/api/v1/orders", headers=h, params={"q": q}).json())
assert s("котел") == [a] and s("КОТЕЛ") == [a] # регистр кириллицы не важен
assert s("желтый") == [d] # по названию оборудования
assert s("100%_") == [b] and s("%") == [b] # спецсимволы LIKE — буквально
assert s("#%d" % d) == [d] and s(str(a)) == [a] # по номеру
assert s("нет такого") == [] and s(" ") != []
def test_apk_download(monkeypatch, tmp_path):
from pathlib import Path
import os, time
from app import config
bundled = tmp_path / "release" # APK, поставляемый в образе
bundled.mkdir()
monkeypatch.setattr(config, "RELEASE_DIR", bundled)
with TestClient(app) as c:
assert c.get("/app.apk").status_code == 404 # нигде нет файла
(bundled / "image.apk").write_bytes(b"PK-image")
r = c.get("/app.apk") # есть только APK из образа -> раздаётся он
assert r.status_code == 200 and r.content == b"PK-image"
assert r.headers["content-type"] == "application/vnd.android.package-archive"
assert c.head("/app.apk").status_code == 200
d = Path(_DATA) / "apk" # загруженный вручную важнее и берётся самый свежий
d.mkdir(exist_ok=True)
(d / "old.apk").write_bytes(b"PK-old")
os.utime(d / "old.apk", (time.time() - 100, time.time() - 100))
(d / "new.apk").write_bytes(b"PK-new")
assert c.get("/app.apk").content == b"PK-new"
for f in d.glob("*.apk"):
f.unlink()
assert c.get("/app.apk").content == b"PK-image" # после удаления снова APK из образа
def test_mobile_pwa_assets():
import json
with TestClient(app) as c:
r = c.get("/m")
assert r.status_code == 200 and "text/html" in r.headers["content-type"] and "/static/m/m.js" in r.text
assert c.get("/m/").status_code == 200
man = json.loads(c.get("/static/m/manifest.webmanifest").text)
assert man["start_url"] == "/m" and man["display"] == "standalone"
for icon in man["icons"]:
png = c.get(icon["src"])
assert png.status_code == 200 and png.content[:8] == b"\x89PNG\r\n\x1a\n", icon["src"]
sw = c.get("/sw.js")
assert sw.status_code == 200 and "shell-m" in sw.text and sw.headers["service-worker-allowed"] == "/"
# все файлы оболочки из service worker реально существуют
import re
shell = re.search(r"const SHELL = \[(.*?)\];", sw.text, re.S).group(1)
for path in re.findall(r'"([^"]+)"', shell):
assert c.get(path).status_code == 200, path