init
This commit is contained in:
4
backend/config.py
Normal file
4
backend/config.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DATEN_DIR = PROJECT_ROOT / "daten"
|
||||
74
backend/features.py
Normal file
74
backend/features.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Feature-Dateien: eine .md je Feature, Rohtext wird verbatim gespeichert."""
|
||||
|
||||
import re
|
||||
|
||||
import parser
|
||||
import projekte
|
||||
|
||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def _slug(titel: str) -> str:
|
||||
s = titel.lower().translate(_UMLAUTE)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "feature"
|
||||
|
||||
|
||||
def _eintrag(datei) -> dict:
|
||||
roh = datei.read_text()
|
||||
return {"id": datei.stem, "roh": roh, "sektionen": parser.zerlege(roh)}
|
||||
|
||||
|
||||
def _datei(projekt: str, id: str):
|
||||
datei = projekte.wurzel(projekt) / f"{id}.md"
|
||||
if not datei.is_file():
|
||||
raise FileNotFoundError(f"Feature „{id}“ nicht gefunden")
|
||||
return datei
|
||||
|
||||
|
||||
def liste(projekt: str) -> list[dict]:
|
||||
dateien = projekte.wurzel(projekt).glob("*.md")
|
||||
eintraege = [_eintrag(d) for d in sorted(dateien)]
|
||||
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
|
||||
|
||||
|
||||
def anlegen(projekt: str, roh: str) -> dict:
|
||||
ordner = projekte.wurzel(projekt)
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
if not titel:
|
||||
raise ValueError("Titel fehlt")
|
||||
slug = _slug(titel)
|
||||
datei = ordner / f"{slug}.md"
|
||||
n = 2
|
||||
while datei.exists():
|
||||
datei = ordner / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
datei.write_text(roh)
|
||||
return _eintrag(datei)
|
||||
|
||||
|
||||
def _folge_titel(datei, titel: str):
|
||||
"""Benennt die Datei um, wenn der Slug nicht mehr zum Titel passt."""
|
||||
slug = _slug(titel)
|
||||
if re.fullmatch(re.escape(slug) + r"(-\d+)?", datei.stem):
|
||||
return datei # passt schon (inkl. Kollisions-Suffix)
|
||||
ziel = datei.parent / f"{slug}.md"
|
||||
n = 2
|
||||
while ziel.exists():
|
||||
ziel = datei.parent / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
datei.rename(ziel)
|
||||
return ziel
|
||||
|
||||
|
||||
def speichern(projekt: str, id: str, roh: str) -> dict:
|
||||
datei = _datei(projekt, id)
|
||||
datei.write_text(roh)
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
if titel:
|
||||
datei = _folge_titel(datei, titel)
|
||||
return _eintrag(datei)
|
||||
|
||||
|
||||
def loeschen(projekt: str, id: str):
|
||||
_datei(projekt, id).unlink()
|
||||
72
backend/parser.py
Normal file
72
backend/parser.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Zerlegt den rohen Feature-Text in Sektionen — nur für die Anzeige.
|
||||
|
||||
Format: titel (eine Zeile), ziel, dann wiederholbare <!-- funktion -->-Blöcke,
|
||||
denen <!-- regel -->-Blöcke zugeordnet werden, zuletzt sonstiges.
|
||||
Präambel, unbekannte Marker und Regeln ohne Funktion landen in `sonstiges`.
|
||||
Trennzeilen aus Bindestrichen (---) sind reine Optik und werden ignoriert.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
MARKER = re.compile(r"^<!--\s*([a-zä-ü]+)\s*-->\s*$")
|
||||
TRENNER = re.compile(r"^-{3,}\s*$")
|
||||
|
||||
|
||||
def zerlege(roh: str) -> dict:
|
||||
# Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden
|
||||
bloecke = []
|
||||
aktuell = None # None = Präambel vor dem ersten Marker
|
||||
puffer = []
|
||||
|
||||
def ablegen():
|
||||
text = "\n".join(z for z in puffer if not TRENNER.match(z)).strip()
|
||||
if text:
|
||||
bloecke.append((aktuell, text))
|
||||
puffer.clear()
|
||||
|
||||
for zeile in roh.splitlines():
|
||||
m = MARKER.match(zeile)
|
||||
if m:
|
||||
ablegen()
|
||||
aktuell = m.group(1)
|
||||
else:
|
||||
puffer.append(zeile)
|
||||
ablegen()
|
||||
|
||||
titel = []
|
||||
ziel = []
|
||||
funktionen = []
|
||||
sonstiges = []
|
||||
art = []
|
||||
for schluessel, text in bloecke:
|
||||
if schluessel == "titel":
|
||||
titel.append(text)
|
||||
elif schluessel == "art":
|
||||
art.append(text)
|
||||
elif schluessel == "ziel":
|
||||
ziel.append(text)
|
||||
elif schluessel == "funktion":
|
||||
funktionen.append({"text": text, "regeln": []})
|
||||
elif schluessel == "regel" and funktionen:
|
||||
funktionen[-1]["regeln"].append(text)
|
||||
else: # Präambel, unbekannter Marker, regel ohne funktion
|
||||
sonstiges.append(text)
|
||||
|
||||
# Titel ist eine Zeile: erste nicht-leere
|
||||
titel_zeilen = [z.strip() for t in titel for z in t.splitlines() if z.strip()]
|
||||
|
||||
# Funktionen-Bereich mit Markern rekonstruiert — für das Editor-Feld
|
||||
teile = []
|
||||
for f in funktionen:
|
||||
teile.append(f"<!-- funktion -->\n{f['text']}")
|
||||
for r in f["regeln"]:
|
||||
teile.append(f"<!-- regel -->\n{r}")
|
||||
|
||||
return {
|
||||
"titel": titel_zeilen[0] if titel_zeilen else "",
|
||||
"art": "optional" if art and art[0].strip().lower() == "optional" else "kern",
|
||||
"ziel": "\n\n".join(ziel),
|
||||
"funktionen": funktionen,
|
||||
"funktionen_roh": "\n".join(teile),
|
||||
"sonstiges": "\n\n".join(sonstiges),
|
||||
}
|
||||
62
backend/projekte.py
Normal file
62
backend/projekte.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Projekte = Ordner unter daten/. Kein Registry-File, das Listing ist die Liste."""
|
||||
|
||||
import shutil
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def _pruefe_name(name: str):
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Projektname darf nicht leer sein")
|
||||
if "/" in name or "\\" in name or name.startswith("."):
|
||||
raise ValueError("Ungültiger Projektname")
|
||||
|
||||
|
||||
def _ordner(name: str):
|
||||
_pruefe_name(name)
|
||||
return config.DATEN_DIR / name
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
if not config.DATEN_DIR.exists():
|
||||
return []
|
||||
ordner = sorted(
|
||||
(p for p in config.DATEN_DIR.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.name.lower(),
|
||||
)
|
||||
return [
|
||||
{"name": p.name, "anzahl": len(list(p.glob("*.md")))}
|
||||
for p in ordner
|
||||
]
|
||||
|
||||
|
||||
def anlegen(name: str):
|
||||
ziel = _ordner(name)
|
||||
if ziel.exists():
|
||||
raise ValueError(f"Projekt „{name}“ existiert bereits")
|
||||
ziel.mkdir(parents=True)
|
||||
|
||||
|
||||
def umbenennen(alt: str, neu: str):
|
||||
quelle = _ordner(alt)
|
||||
ziel = _ordner(neu)
|
||||
if not quelle.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{alt}“ nicht gefunden")
|
||||
if ziel.exists():
|
||||
raise ValueError(f"Projekt „{neu}“ existiert bereits")
|
||||
quelle.rename(ziel)
|
||||
|
||||
|
||||
def loeschen(name: str):
|
||||
ordner = _ordner(name)
|
||||
if not ordner.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
|
||||
shutil.rmtree(ordner)
|
||||
|
||||
|
||||
def wurzel(name: str):
|
||||
"""Projektordner für Feature-Zugriffe; FileNotFoundError bei unbekanntem Projekt."""
|
||||
ordner = _ordner(name)
|
||||
if not ordner.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
|
||||
return ordner
|
||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
87
backend/routes.py
Normal file
87
backend/routes.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Alle API-Routen. Fachlogik wirft ValueError/FileNotFoundError,
|
||||
hier wird auf 400/404/409 gemappt."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import features
|
||||
import projekte
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/projekte")
|
||||
async def projekte_liste():
|
||||
return projekte.liste()
|
||||
|
||||
|
||||
@router.post("/projekte")
|
||||
async def projekt_anlegen(body: dict):
|
||||
name = (body.get("name") or "").strip()
|
||||
try:
|
||||
projekte.anlegen(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(409 if "existiert" in str(e) else 400, str(e))
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@router.put("/projekte/{name}")
|
||||
async def projekt_umbenennen(name: str, body: dict):
|
||||
neu = (body.get("name") or "").strip()
|
||||
try:
|
||||
projekte.umbenennen(name, neu)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(409 if "existiert" in str(e) else 400, str(e))
|
||||
return {"name": neu}
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}")
|
||||
async def projekt_loeschen(name: str):
|
||||
try:
|
||||
projekte.loeschen(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/features")
|
||||
async def features_liste(name: str):
|
||||
try:
|
||||
return features.liste(name)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/features")
|
||||
async def feature_anlegen(name: str, body: dict):
|
||||
try:
|
||||
return features.anlegen(name, body.get("roh") or "")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.put("/projekte/{name}/features/{id}")
|
||||
async def feature_speichern(name: str, id: str, body: dict):
|
||||
try:
|
||||
return features.speichern(name, id, body.get("roh") or "")
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}/features/{id}")
|
||||
async def feature_loeschen(name: str, id: str):
|
||||
try:
|
||||
features.loeschen(name, id)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
return {"ok": True}
|
||||
24
backend/server.py
Normal file
24
backend/server.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Planer2-Server: API + gebautes Frontend.
|
||||
|
||||
Start: uvicorn server:app --port 8200
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
|
||||
import config # noqa: E402
|
||||
from routes import router # noqa: E402
|
||||
|
||||
app = FastAPI(title="Planer2")
|
||||
app.include_router(router)
|
||||
|
||||
config.DATEN_DIR.mkdir(exist_ok=True)
|
||||
|
||||
FRONTEND_DIST = config.PROJECT_ROOT / "frontend" / "dist"
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||
27
backend/tests/conftest.py
Normal file
27
backend/tests/conftest.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datenordner(tmp_path, monkeypatch):
|
||||
"""Leitet DATEN_DIR auf einen frischen Ordner um."""
|
||||
ordner = tmp_path / "daten"
|
||||
ordner.mkdir()
|
||||
monkeypatch.setattr(config, "DATEN_DIR", ordner)
|
||||
return ordner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(datenordner):
|
||||
from server import app
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
80
backend/tests/test_features.py
Normal file
80
backend/tests/test_features.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
|
||||
import features
|
||||
import projekte
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def projekt(datenordner):
|
||||
projekte.anlegen("Test")
|
||||
return "Test"
|
||||
|
||||
|
||||
def test_anlegen_erzeugt_slug(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nÜber Ätzend!\n")
|
||||
assert f["id"] == "ueber-aetzend"
|
||||
assert (datenordner / "Test" / "ueber-aetzend.md").is_file()
|
||||
|
||||
|
||||
def test_slug_kollision(projekt):
|
||||
a = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
b = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
assert a["id"] == "gleich"
|
||||
assert b["id"] == "gleich-2"
|
||||
|
||||
|
||||
def test_titel_pflicht(projekt):
|
||||
with pytest.raises(ValueError):
|
||||
features.anlegen(projekt, "nur Text ohne Titel-Marker")
|
||||
|
||||
|
||||
def test_titelaenderung_benennt_datei_um(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nAlt\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nNeu\n")
|
||||
assert g["id"] == "neu"
|
||||
assert not (datenordner / "Test" / "alt.md").exists()
|
||||
assert (datenordner / "Test" / "neu.md").is_file()
|
||||
|
||||
|
||||
def test_umbenennen_mit_kollision(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nZiel\n")
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nAnders\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nZiel\n")
|
||||
assert g["id"] == "ziel-2"
|
||||
|
||||
|
||||
def test_gleicher_titel_behaelt_kollisions_suffix(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nGleich\n<!-- ziel -->\nx\n")
|
||||
assert g["id"] == "gleich-2"
|
||||
|
||||
|
||||
def test_speichern_verbatim(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nX\n")
|
||||
roh = "Vorspann\n<!-- titel -->\nX\n<!-- fremd -->\n unangetastet \n"
|
||||
features.speichern(projekt, f["id"], roh)
|
||||
assert (datenordner / "Test" / "x.md").read_text() == roh
|
||||
|
||||
|
||||
def test_liste_sortiert_nach_titel(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nZebra\n")
|
||||
features.anlegen(projekt, "<!-- titel -->\nanton\n")
|
||||
titel = [f["sektionen"]["titel"] for f in features.liste(projekt)]
|
||||
assert titel == ["anton", "Zebra"]
|
||||
|
||||
|
||||
def test_loeschen(projekt):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nWeg\n")
|
||||
features.loeschen(projekt, f["id"])
|
||||
assert features.liste(projekt) == []
|
||||
|
||||
|
||||
def test_unbekannte_id(projekt):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
features.speichern(projekt, "gibts-nicht", "x")
|
||||
|
||||
|
||||
def test_unbekanntes_projekt(datenordner):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
features.liste("Fremd")
|
||||
92
backend/tests/test_parser.py
Normal file
92
backend/tests/test_parser.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from parser import zerlege
|
||||
|
||||
VOLL = """<!-- titel -->
|
||||
Login
|
||||
----------------------------------------
|
||||
<!-- ziel -->
|
||||
Nutzer können sich anmelden.
|
||||
----------------------------------------
|
||||
<!-- funktion -->
|
||||
Anmeldung mit E-Mail
|
||||
<!-- regel -->
|
||||
Passwort nie loggen
|
||||
<!-- regel -->
|
||||
Sperre nach 5 Fehlversuchen
|
||||
<!-- funktion -->
|
||||
Abmeldung
|
||||
----------------------------------------
|
||||
<!-- sonstiges -->
|
||||
Offen: 2FA.
|
||||
"""
|
||||
|
||||
|
||||
def test_alle_sektionen():
|
||||
s = zerlege(VOLL)
|
||||
assert s["titel"] == "Login"
|
||||
assert s["ziel"] == "Nutzer können sich anmelden."
|
||||
assert s["funktionen"] == [
|
||||
{"text": "Anmeldung mit E-Mail", "regeln": ["Passwort nie loggen", "Sperre nach 5 Fehlversuchen"]},
|
||||
{"text": "Abmeldung", "regeln": []},
|
||||
]
|
||||
assert s["sonstiges"] == "Offen: 2FA."
|
||||
|
||||
|
||||
def test_funktionen_roh_rekonstruiert_marker():
|
||||
s = zerlege(VOLL)
|
||||
assert s["funktionen_roh"] == (
|
||||
"<!-- funktion -->\nAnmeldung mit E-Mail\n"
|
||||
"<!-- regel -->\nPasswort nie loggen\n"
|
||||
"<!-- regel -->\nSperre nach 5 Fehlversuchen\n"
|
||||
"<!-- funktion -->\nAbmeldung"
|
||||
)
|
||||
|
||||
|
||||
def test_trenner_zeilen_werden_ignoriert():
|
||||
s = zerlege("<!-- ziel -->\nText\n-----\n")
|
||||
assert s["ziel"] == "Text"
|
||||
|
||||
|
||||
def test_praeambel_landet_in_sonstiges():
|
||||
s = zerlege("Vorspann ohne Marker\n<!-- titel -->\nX\n")
|
||||
assert s["titel"] == "X"
|
||||
assert s["sonstiges"] == "Vorspann ohne Marker"
|
||||
|
||||
|
||||
def test_unbekannter_marker_landet_in_sonstiges():
|
||||
s = zerlege("<!-- titel -->\nX\n<!-- notizen -->\nGeheim\n")
|
||||
assert s["sonstiges"] == "Geheim"
|
||||
|
||||
|
||||
def test_regel_ohne_funktion_landet_in_sonstiges():
|
||||
s = zerlege("<!-- regel -->\nverwaist\n")
|
||||
assert s["funktionen"] == []
|
||||
assert s["sonstiges"] == "verwaist"
|
||||
|
||||
|
||||
def test_doppelter_marker_wird_konkateniert():
|
||||
s = zerlege("<!-- ziel -->\na\n<!-- titel -->\nX\n<!-- ziel -->\nb\n")
|
||||
assert s["ziel"] == "a\n\nb"
|
||||
|
||||
|
||||
def test_leerer_text():
|
||||
s = zerlege("")
|
||||
assert s == {"titel": "", "art": "kern", "ziel": "", "funktionen": [], "funktionen_roh": "", "sonstiges": ""}
|
||||
|
||||
|
||||
def test_art_optional():
|
||||
assert zerlege("<!-- art -->\nOptional\n")["art"] == "optional"
|
||||
|
||||
|
||||
def test_art_default_und_unbekannter_wert():
|
||||
assert zerlege("<!-- titel -->\nX\n")["art"] == "kern"
|
||||
assert zerlege("<!-- art -->\nquatsch\n")["art"] == "kern"
|
||||
|
||||
|
||||
def test_marker_mit_leerraum():
|
||||
s = zerlege("<!-- titel -->\nX\n")
|
||||
assert s["titel"] == "X"
|
||||
|
||||
|
||||
def test_titel_erste_nicht_leere_zeile():
|
||||
s = zerlege("<!-- titel -->\n\nErste\nZweite\n")
|
||||
assert s["titel"] == "Erste"
|
||||
59
backend/tests/test_routes.py
Normal file
59
backend/tests/test_routes.py
Normal file
@@ -0,0 +1,59 @@
|
||||
ROH = "<!-- titel -->\nLogin\n<!-- funktion -->\nAnmeldung\n<!-- regel -->\nPasswort nie loggen\n"
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
res = await client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True}
|
||||
|
||||
|
||||
async def test_projekt_crud(client):
|
||||
assert (await client.get("/api/projekte")).json() == []
|
||||
|
||||
res = await client.post("/api/projekte", json={"name": "Alpha"})
|
||||
assert res.status_code == 200
|
||||
|
||||
# Duplikat → 409, ungültig → 400
|
||||
assert (await client.post("/api/projekte", json={"name": "Alpha"})).status_code == 409
|
||||
assert (await client.post("/api/projekte", json={"name": ""})).status_code == 400
|
||||
assert (await client.post("/api/projekte", json={"name": "../boese"})).status_code == 400
|
||||
|
||||
res = await client.put("/api/projekte/Alpha", json={"name": "Beta"})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"name": "Beta"}
|
||||
assert (await client.put("/api/projekte/Alpha", json={"name": "X"})).status_code == 404
|
||||
|
||||
assert [p["name"] for p in (await client.get("/api/projekte")).json()] == ["Beta"]
|
||||
|
||||
assert (await client.delete("/api/projekte/Beta")).status_code == 200
|
||||
assert (await client.delete("/api/projekte/Beta")).status_code == 404
|
||||
|
||||
|
||||
async def test_feature_crud(client):
|
||||
await client.post("/api/projekte", json={"name": "Alpha"})
|
||||
|
||||
res = await client.post("/api/projekte/Alpha/features", json={"roh": ROH})
|
||||
assert res.status_code == 200
|
||||
f = res.json()
|
||||
assert f["id"] == "login"
|
||||
assert f["roh"] == ROH
|
||||
assert f["sektionen"]["titel"] == "Login"
|
||||
|
||||
# Titel fehlt → 400
|
||||
res = await client.post("/api/projekte/Alpha/features", json={"roh": "kein Titel"})
|
||||
assert res.status_code == 400
|
||||
|
||||
res = await client.put("/api/projekte/Alpha/features/login", json={"roh": "mehr\n" + ROH})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["sektionen"]["sonstiges"] == "mehr"
|
||||
|
||||
liste = (await client.get("/api/projekte/Alpha/features")).json()
|
||||
assert len(liste) == 1
|
||||
|
||||
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 200
|
||||
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 404
|
||||
|
||||
|
||||
async def test_unbekanntes_projekt(client):
|
||||
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
|
||||
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
|
||||
Reference in New Issue
Block a user