"""Цвет-метка, сайт (0.63), булавка (0.66), репозиторий (0.67): валидация, очистка."""
from fastapi.testclient import TestClient
def test_color_and_site_roundtrip(client: TestClient) -> None:
resp = client.post(
"/api/projects",
json={
"name": "Метка",
"color": "#7AA2F7",
"site_url": "https://example.com",
"repository_url": "https://git.example.com/owner/repo",
},
)
assert resp.status_code == 200
project = resp.json()
# цвет нормализуется в нижний регистр — с ним сравнивается палитра фронта
assert project["color"] == "#7aa2f7"
assert project["site_url"] == "https://example.com"
assert project["repository_url"] == "https://git.example.com/owner/repo"
# поля видны и в одиночном чтении
single = client.get(f"/api/projects/{project['id']}").json()
assert single["color"] == "#7aa2f7"
assert single["site_url"] == "https://example.com"
assert single["repository_url"] == "https://git.example.com/owner/repo"
def test_project_without_color_and_site(client: TestClient) -> None:
project = client.post("/api/projects", json={"name": "Пусто"}).json()
# поля необязательные: по умолчанию метки, сайта и репозитория нет
assert project["color"] is None
assert project["site_url"] is None
assert project["repository_url"] is None
def test_patch_sets_and_clears(client: TestClient) -> None:
project_id = client.post("/api/projects", json={"name": "Правки"}).json()["id"]
patched = client.patch(
f"/api/projects/{project_id}",
json={
"color": "#9ECE6A",
"site_url": "https://example.org/page",
"repository_url": "https://git.example.org/team/repo.git",
},
).json()
assert patched["color"] == "#9ece6a"
assert patched["site_url"] == "https://example.org/page"
assert patched["repository_url"] == "https://git.example.org/team/repo.git"
# пустая строка — «убрать» (и через PATCH, и через MCP)
cleared = client.patch(
f"/api/projects/{project_id}", json={"color": "", "site_url": "", "repository_url": ""}
).json()
assert cleared["color"] is None
assert cleared["site_url"] is None
assert cleared["repository_url"] is None
# явный null тоже очищает
again = client.patch(f"/api/projects/{project_id}", json={"color": "#ff9e64"}).json()
assert again["color"] == "#ff9e64"
assert client.patch(f"/api/projects/{project_id}", json={"color": None}).json()["color"] is None
# PATCH без репозитория его не трогает
repo = client.patch(
f"/api/projects/{project_id}", json={"repository_url": "https://git.example.org/a/b"}
).json()
assert repo["repository_url"] == "https://git.example.org/a/b"
renamed = client.patch(f"/api/projects/{project_id}", json={"name": "Правки-2"}).json()
assert renamed["repository_url"] == "https://git.example.org/a/b"
def test_invalid_color_rejected(client: TestClient) -> None:
project_id = client.post("/api/projects", json={"name": "Цвет"}).json()["id"]
for bad in ("#GGGGGG", "#7aa2f", "7aa2f7", "red"):
resp = client.patch(f"/api/projects/{project_id}", json={"color": bad})
assert resp.status_code == 422, bad
def test_invalid_site_url_rejected(client: TestClient) -> None:
project_id = client.post("/api/projects", json={"name": "Сайт"}).json()["id"]
for bad in ("ftp://example.com", "javascript:alert(1)", "example.com", "https://"):
resp = client.patch(f"/api/projects/{project_id}", json={"site_url": bad})
assert resp.status_code == 422, bad
def test_invalid_repository_url_rejected(client: TestClient) -> None:
"""Репозиторий (0.67) валидируется как сайт: только http(s) с хостом."""
project_id = client.post("/api/projects", json={"name": "Репозиторий"}).json()["id"]
for bad in ("git@github.com:owner/repo.git", "ftp://git.example.com/a/b", "/owner/repo", ""):
resp = client.patch(f"/api/projects/{project_id}", json={"repository_url": bad})
# пустая строка — не ошибка, а очистка: проверяем остальные
assert resp.status_code == (200 if bad == "" else 422), bad
def test_task_carries_project_color(client: TestClient) -> None:
project_id = client.post(
"/api/projects", json={"name": "С меткой", "color": "#bb9af7"}
).json()["id"]
task_id = client.post("/api/tasks", json={"title": "Задача"}).json()["id"]
client.patch(f"/api/tasks/{task_id}", json={"project_id": project_id})
# ProjectOut вложен в TaskOut — метка проекта доезжает до задач без правок схемы
task = next(t for t in client.get("/api/tasks").json() if t["project"])
assert task["project"]["color"] == "#bb9af7"
def test_pinned_default_and_toggle(client: TestClient) -> None:
"""Булавка проекта (0.66): по умолчанию снята, PATCH ставит и снимает."""
project = client.post("/api/projects", json={"name": "Булавка"}).json()
assert project["pinned"] is False
project_id = project["id"]
pinned = client.patch(f"/api/projects/{project_id}", json={"pinned": True}).json()
assert pinned["pinned"] is True
# в одиночном чтении и в списке — тоже закреплён
assert client.get(f"/api/projects/{project_id}").json()["pinned"] is True
listed = next(p for p in client.get("/api/projects").json() if p["id"] == project_id)
assert listed["pinned"] is True
# PATCH без pinned (частичное обновление) булавку не трогает
renamed = client.patch(f"/api/projects/{project_id}", json={"name": "Булавка-2"}).json()
assert renamed["pinned"] is True
unpinned = client.patch(f"/api/projects/{project_id}", json={"pinned": False}).json()
assert unpinned["pinned"] is False