Files
astrololo/services/presentation/tests/test_house_compare.py
T
gitea a833965909
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s
feat(bezpieczeństwo): konta z uprawnieniami do zakładek i funkcji (PRE-27)
Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień.
Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY —
tylko mniejszy.

PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką —
to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów:
porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie
tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania
domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście.

NIC NIE ZDRADZA, ŻE JEST WIĘCEJ:
- brak pozycji w menu zamiast pozycji wyszarzonej,
- 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest,
- rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle,
- automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują
  komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by
  nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po
  trasach aplikacji, nie przeglądem kodu.

KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku
kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku
o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest
odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.
Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je
w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie.

GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem,
nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie
serwera i test wysyła spreparowane żądanie, żeby to potwierdzić.

MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga
administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach
APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie.

Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie
całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym
samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS
obcięłoby plik, czyli skasowało wszystkie konta naraz.

Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu
powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html
niczego już nie sprawdzało.

Wymaga wolumenu na konta — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:18:14 +00:00

232 lines
10 KiB
Python

"""Porównanie systemów domów naraz (PRE-05).
JS-a tu nie ma — sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka
systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
MAIN = (APP / "main.py").read_text(encoding="utf-8")
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_both_forms_use_shared_option_partial():
"""Opcje w jednym pliku — oba formularze go dołączają (koniec duplikacji)."""
assert '{% include "_form_options.html" %}' in CHART
assert '{% include "_form_options.html" %}' in COMPILE
def _render(name: str, **ctx) -> str:
"""Renderuje szablon z tymi samymi globalsami, co produkcja.
Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
surowego tekstu nic już nie mówi — trzeba je wykonać. Przy okazji test
łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p,
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
# mają własne testy w test_kontrola_dostepu.py.
can=lambda request, key: ctx.pop("_denied", None) != key,
nav_screens=lambda request: [])
return env.get_template(name).render(**ctx)
def test_form_offers_every_system_the_engine_advertises():
"""Każdy system z katalogu ma checkbox. Katalog jest jednym źródłem nazw,
więc dopisanie systemu bez odświeżenia UI zapali się tutaj."""
html = _render("_form_options.html", form={})
for value, name in HOUSE_SYSTEMS:
assert f'name="house_systems" value="{value}"' in html, f"brak checkboxa {value}"
assert name in html, f"brak nazwy {name}"
def test_form_marks_systems_with_a_domain_limit():
"""Placidus i Koch nie istnieją za kołem podbiegunowym — UI ma uprzedzać
ZANIM ktoś policzy horoskop dla Tromsø, nie dopiero w ostrzeżeniu wyniku."""
html = _render("_form_options.html", form={})
assert LIMITED, "katalog nie oznacza żadnego systemu jako ograniczonego"
for value, name in HOUSE_SYSTEMS:
starred = f"{name} *" in html
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
def test_handler_accepts_and_passes_house_systems(monkeypatch):
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
seen = {}
def spy(**kw):
seen.update(kw)
return _minimal_chart()
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
from app.main import logic
monkeypatch.setattr(logic, "positions", spy)
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "campanus"]
assert seen["house_system"] == "placidus"
def test_client_forwards_house_systems_to_logic():
assert '"house_systems": house_systems' in CLIENT
def test_comparison_table_renders_only_for_multiple_systems():
"""Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania."""
assert "result.house_systems | length > 1" in TABLES
assert "Porównanie systemów domów" in TABLES
def test_comparison_table_shows_cusp_per_system():
"""Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce."""
assert "for hs in result.house_systems" in TABLES
assert "hs.cusps[i].in_sign" in TABLES
def test_checkbox_state_survives_submit():
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
for value, _ in HOUSE_SYSTEMS:
marker = f'value="{value}" checked'
assert (marker in html) == (value in ("koch", "campanus")), value
def test_fallback_notice_is_shown_prominently():
"""Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym
sygnałem, że podział domów jest z innego systemu."""
html = _render("_result_tables.html",
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
assert "house-warning" in html
assert "UWAGA: system domów nie ma definicji" in html
def test_no_notice_when_nothing_was_substituted():
html = _render("_result_tables.html", result={"house_warnings": []})
assert "house-warning" not in html
# ── ścieżka PRZEZ handlery, nie obok nich ───────────────────────────────
# Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał
# POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz
# użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na
# żywo i oba łapie dopiero prawdziwe żądanie.
def _client_with_stub_logic(monkeypatch, chart):
import os
os.environ.pop("APP_PASSWORD", None)
from starlette.testclient import TestClient
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: chart)
return TestClient(app)
def _minimal_chart():
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''",
"decimal": float(i * 30), "sign_glyph": "♈"} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "decimal": 0.0,
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}],
"house_system": "porphyry", "house_system_requested": "placidus",
"house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]}
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "placidus"}
def test_chart_page_survives_a_real_post(monkeypatch):
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/", data=FORM)
assert r.status_code == 200, r.text[:400]
assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę"
def test_compile_page_survives_a_real_post(monkeypatch):
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/compile", data=FORM)
assert r.status_code == 200, r.text[:400]
@pytest.mark.parametrize("orientation", ("asc", "aries"))
def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
"""Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić
zaznaczony, żeby nie gubił się przy przeliczeniu."""
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
assert r.status_code == 200, r.text[:400]
assert f'value="{orientation}" checked' in r.text
# ── układ strony przy komplecie systemów ────────────────────────────────
# Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela
# porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
# poziomy pasek przewijania na body i rozjechany układ.
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
def test_comparison_table_scrolls_instead_of_widening_the_page():
"""Tabela ma własny kontener przewijany — przewija się ona, nie strona."""
html = _render("_result_tables.html", result={
"house_systems": [{"system": v, "cusps": [
{"sign_glyph": "♈", "in_sign": "0°00'00''"} for _ in range(12)]}
for v, _ in HOUSE_SYSTEMS],
"house_warnings": []})
assert '<div class="table-scroll">' in html
before = html.index('class="table-scroll"')
assert html.index("<table", before) < html.index("</div>", before), \
"tabela musi być WEWNĄTRZ kontenera przewijanego"
def test_scroll_container_actually_clips_and_pins_the_house_number():
"""Sam znacznik nie wystarczy — reguła musi istnieć w arkuszu.
Geometrii nie da się sprawdzić bez przeglądarki, więc sprawdzamy kontrakt:
kontener przewija się w poziomie, a pierwsza kolumna zostaje przyklejona
(bez tego po przewinięciu w prawo nie wiadomo, który to dom)."""
assert ".table-scroll { overflow-x: auto;" in CSS
assert ".table-scroll th:first-child, .table-scroll td:first-child {" in CSS
assert "position: sticky" in CSS
def test_system_checkboxes_use_a_grid_not_a_ragged_flex_wrap():
"""13 pozycji na flexie zawijało się w poszarpane wiersze o różnej długości."""
html = _render("_form_options.html", form={})
assert 'class="opts systems"' in html
assert ".opts.systems { display: grid;" in CSS
def test_polar_circle_note_is_explained_once_next_to_the_asterisks():
"""Przypis siedział WEWNĄTRZ <label> selektora i łamał się na dwie linie,
przez co wiersz siatki rósł z 66 do 108 px i rozjeżdżał się z sąsiednim
polem. Wyjaśnienie stoi teraz raz, przy checkboxach z gwiazdkami."""
opts = _render("_form_options.html", form={})
assert "kołem podbiegunowym" in opts
for name in ("chart.html", "compile.html"):
tpl = (APP / "templates" / name).read_text(encoding="utf-8")
select = tpl[tpl.index("<select name=\"house_system\""):]
assert "podbiegunow" not in select[:select.index("</label>")], \
f"{name}: przypis wrócił do etykiety selektora"