- Tables forge_connections/users/workspaces (+ membres, projets, états, audit) ; migration des anciennes bases avec portées (workspace_id, connection_id) par défaut (1, 1). - Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en base), jamais réaffichés. - ForgeRegistry : connexion héritée créée depuis l'environnement, espace Principal, instantanés préfixés par connexion. - GitHubClient complet (Bearer, pagination Link, étiquettes par nom, PATCH archived, branches via git/refs). - Paramètres surchargeables en base (résolveur config, P3).
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""Shared fixtures: isolated SQLite file + a fake async Gitea client.
|
|
|
|
Environment is set BEFORE any app module is imported so the app picks up
|
|
the test database path and never talks to a real Gitea instance.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
_TMP = tempfile.mkdtemp(prefix="kanbante-tests-")
|
|
os.environ["KANBANTE_DB_PATH"] = os.path.join(_TMP, "test.db")
|
|
os.environ["GITEA_URL"] = "http://gitea.test"
|
|
os.environ.pop("GITEA_TOKEN", None)
|
|
os.environ.pop("KANBANTE_AUTH_USER", None)
|
|
os.environ.pop("KANBANTE_AUTH_PASSWORD", None)
|
|
os.environ.pop("KANBANTE_REQUIRE_AUTH", None)
|
|
|
|
import httpx # noqa: E402
|
|
import pytest # noqa: E402
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
import app.main as main # noqa: E402
|
|
from app import models # noqa: E402
|
|
|
|
|
|
def _iso(dt: datetime) -> str:
|
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
|
|
return {
|
|
"full_name": f"Projets/{name}",
|
|
"name": name,
|
|
"description": f"Repo {name}",
|
|
"updated_at": _iso(updated),
|
|
"open_issues_count": 1,
|
|
"owner": {"login": "Projets"},
|
|
"archived": archived,
|
|
"html_url": f"http://gitea.test/Projets/{name}",
|
|
"default_branch": "main",
|
|
}
|
|
|
|
|
|
def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> dict:
|
|
issue = {
|
|
"number": number,
|
|
"title": title,
|
|
"body": f"Corps de l'issue {number} en **markdown**.",
|
|
"state": state,
|
|
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
|
|
"assignee": {"login": "bruno", "avatar_url": ""} if number == 1 else None,
|
|
"assignees": [{"login": "bruno", "avatar_url": ""}] if number == 1 else [],
|
|
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
|
|
"due_date": None,
|
|
"created_at": "2026-09-01T09:00:00Z",
|
|
"updated_at": "2026-09-02T09:00:00Z",
|
|
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
|
# Faithful to real Gitea 1.27 payloads: the key is ALWAYS
|
|
# present — null for a plain issue, an object for a PR.
|
|
"pull_request": {"url": "http://gitea.test/pr"} if pr else None,
|
|
}
|
|
return issue
|
|
|
|
|
|
class FakeGitea:
|
|
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
|
|
|
kind = "gitea"
|
|
|
|
def __init__(self) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
self.connection_id = 1
|
|
self.connection_slug = "gitea"
|
|
self.key_prefix = ""
|
|
self.starred = set()
|
|
self.repos = [
|
|
_repo("demo", now - timedelta(days=1)),
|
|
_repo("vieux", now - timedelta(days=800)),
|
|
_repo("archive", now - timedelta(days=1), archived=True),
|
|
]
|
|
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
|
|
self.issues = {
|
|
"Projets/demo": [
|
|
_issue(1, "Tâche un"),
|
|
_issue(2, "PR déguisée", pr=True),
|
|
_issue(3, "Ancienne tâche", state="closed"),
|
|
_issue(5, "Tâche focus"),
|
|
],
|
|
}
|
|
self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}}
|
|
self.label_ops: list[tuple] = []
|
|
self._label_seq = 100
|
|
self.cache_invalidations = 0
|
|
self.state_changes: list[tuple] = []
|
|
self.assignee_changes: list[tuple] = []
|
|
self.created: list[tuple] = []
|
|
self.comments: dict[tuple, list[dict]] = {
|
|
("Projets/demo", 1): [
|
|
{
|
|
"id": 1,
|
|
"body": "Premier commentaire",
|
|
"created_at": "2026-09-03T10:00:00Z",
|
|
"updated_at": "2026-09-03T10:00:00Z",
|
|
"html_url": "http://gitea.test/comment/1",
|
|
"user": {"login": "bruno", "avatar_url": ""},
|
|
}
|
|
],
|
|
}
|
|
self.posted_comments: list[tuple] = []
|
|
self.ping_result = True
|
|
self.raise_on_search = False
|
|
self.fail_state_change = False
|
|
self._next_number = 100
|
|
|
|
async def aclose(self) -> None:
|
|
pass
|
|
|
|
async def ping(self) -> bool:
|
|
return self.ping_result
|
|
|
|
async def get_user(self):
|
|
return {"id": 1, "login": "bruno"}
|
|
|
|
async def search_issues(self, q="", state="open", assigned_by=None):
|
|
results = []
|
|
for full_name, issues in self.issues.items():
|
|
for issue in issues:
|
|
if issue.get("pull_request"):
|
|
continue
|
|
if state != "all" and issue["state"] != state:
|
|
continue
|
|
if q and q.lower() not in issue["title"].lower():
|
|
continue
|
|
if assigned_by:
|
|
logins = [a["login"] for a in issue.get("assignees") or []]
|
|
if issue.get("assignee"):
|
|
logins.append(issue["assignee"]["login"])
|
|
if assigned_by not in logins:
|
|
continue
|
|
results.append({**issue, "repository": {"full_name": full_name}})
|
|
return results
|
|
|
|
async def set_archived(self, owner, repo, archived):
|
|
for r in self.repos:
|
|
if r["full_name"] == f"{owner}/{repo}":
|
|
r["archived"] = archived
|
|
return {"archived": archived}
|
|
|
|
async def delete_label(self, owner, repo, label_id):
|
|
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
|
for name in [
|
|
n for n, l in labels.items() if str(l["id"]) == str(label_id)
|
|
]:
|
|
del labels[name]
|
|
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
|
|
|
def forget_label_cache(self, owner, repo):
|
|
pass
|
|
|
|
async def search_repos(self):
|
|
if self.raise_on_search:
|
|
raise httpx.HTTPError("simulated Gitea outage")
|
|
return list(self.repos)
|
|
|
|
async def get_repo(self, owner, repo):
|
|
full = f"{owner}/{repo}"
|
|
for r in self.repos:
|
|
if r["full_name"] == full:
|
|
return r
|
|
return {"full_name": full, "html_url": "", "updated_at": ""}
|
|
|
|
async def get_repo_issues(self, owner, repo, state="all"):
|
|
items = list(self.issues.get(f"{owner}/{repo}", []))
|
|
if state != "all":
|
|
items = [i for i in items if i["state"] == state]
|
|
return items
|
|
|
|
async def get_issue(self, owner, repo, number):
|
|
import httpx as _httpx
|
|
|
|
for issue in self.issues.get(f"{owner}/{repo}", []):
|
|
if issue["number"] == number:
|
|
return issue
|
|
request = _httpx.Request("GET", "http://gitea.test")
|
|
raise _httpx.HTTPStatusError(
|
|
"404", request=request, response=_httpx.Response(404, request=request)
|
|
)
|
|
|
|
async def get_issue_comments(self, owner, repo, number):
|
|
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
|
|
|
async def create_comment(self, owner, repo, number, body):
|
|
comment = {
|
|
"id": 500 + len(self.posted_comments),
|
|
"body": body,
|
|
"created_at": "2026-10-11T10:00:00Z",
|
|
"updated_at": "2026-10-11T10:00:00Z",
|
|
"html_url": "http://gitea.test/comment/new",
|
|
"user": {"login": "bruno", "avatar_url": ""},
|
|
}
|
|
self.comments.setdefault((f"{owner}/{repo}", number), []).append(comment)
|
|
self.posted_comments.append((owner, repo, number, body))
|
|
return comment
|
|
|
|
def invalidate_cache(self):
|
|
self.cache_invalidations += 1
|
|
|
|
async def get_labels(self, owner, repo):
|
|
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
|
|
|
|
async def create_label(self, owner, repo, name, color):
|
|
self._label_seq += 1
|
|
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
|
|
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
|
|
self.label_ops.append(("create", f"{owner}/{repo}", name))
|
|
return label
|
|
|
|
async def ensure_label(self, owner, repo, name, color):
|
|
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
|
if name in existing:
|
|
return existing[name]
|
|
return await self.create_label(owner, repo, name, color)
|
|
|
|
async def add_issue_label(self, owner, repo, number, label_id):
|
|
issue = await self.get_issue(owner, repo, number)
|
|
label = next(
|
|
l for l in self.repo_labels[f"{owner}/{repo}"].values()
|
|
if l["id"] == label_id
|
|
)
|
|
if label not in issue["labels"]:
|
|
issue["labels"].append(label)
|
|
self.label_ops.append(("add", number, label["name"]))
|
|
|
|
async def remove_issue_label(self, owner, repo, number, label_id):
|
|
issue = await self.get_issue(owner, repo, number)
|
|
removed = [l for l in issue["labels"] if l["id"] == label_id]
|
|
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
|
|
for label in removed:
|
|
self.label_ops.append(("remove", number, label["name"]))
|
|
|
|
async def list_branches(self, owner, repo):
|
|
return [{"name": n} for n in self.branches]
|
|
|
|
async def create_branch(self, owner, repo, new_branch, old_branch):
|
|
if new_branch in self.branches:
|
|
raise RuntimeError("branch exists")
|
|
self.branches.append(new_branch)
|
|
return {"name": new_branch}
|
|
|
|
async def list_pulls(self, owner, repo, state="all"):
|
|
return [
|
|
{
|
|
"number": 16, "title": "Corrige la tâche un",
|
|
"body": "Fixes #1", "state": "open", "merged": False,
|
|
"html_url": "http://gitea.test/pr/16",
|
|
"head": {"ref": "feature/1-tache-un"},
|
|
},
|
|
{
|
|
"number": 17, "title": "Focus", "body": "",
|
|
"state": "closed", "merged": True,
|
|
"html_url": "http://gitea.test/pr/17",
|
|
"head": {"ref": "issue-5-focus"},
|
|
},
|
|
]
|
|
|
|
async def list_commits(self, owner, repo, limit=75):
|
|
return [
|
|
{
|
|
"sha": "abcdef1234567890",
|
|
"html_url": "http://gitea.test/c/abcdef1",
|
|
"commit": {
|
|
"message": "Début de la tâche #1\n\nDétails",
|
|
"author": {"date": "2026-10-10T10:00:00Z"},
|
|
},
|
|
},
|
|
{
|
|
"sha": "1234567abcdef",
|
|
"html_url": "http://gitea.test/c/1234567",
|
|
"commit": {
|
|
"message": "Commit sans rapport",
|
|
"author": {"date": "2026-10-09T10:00:00Z"},
|
|
},
|
|
},
|
|
]
|
|
|
|
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
|
|
# l'interface du vrai client.
|
|
def hydrate_from_snapshots(self):
|
|
return 0
|
|
|
|
def latest_snapshot_at(self):
|
|
return None
|
|
|
|
async def refresh_search_repos(self):
|
|
return await self.search_repos()
|
|
|
|
async def refresh_starred(self):
|
|
return await self.list_starred()
|
|
|
|
async def refresh_repo_issues(self, owner, repo, state="all"):
|
|
return await self.get_repo_issues(owner, repo, state=state)
|
|
|
|
async def refresh_milestones(self, owner, repo):
|
|
return await self.get_milestones(owner, repo)
|
|
|
|
async def list_starred(self):
|
|
return [r for r in self.repos if r["full_name"] in self.starred]
|
|
|
|
async def set_starred(self, owner, repo, starred):
|
|
if starred:
|
|
self.starred.add(f"{owner}/{repo}")
|
|
else:
|
|
self.starred.discard(f"{owner}/{repo}")
|
|
|
|
async def get_milestones(self, owner, repo, state="open"):
|
|
return [
|
|
{
|
|
"id": 1, "title": "Sprint 1",
|
|
"due_on": "2026-10-20T00:00:00Z",
|
|
"open_issues": 1, "closed_issues": 1,
|
|
}
|
|
]
|
|
|
|
async def create_issue(self, owner, repo, title, body=""):
|
|
self._next_number += 1
|
|
issue = _issue(self._next_number, title)
|
|
issue["html_url"] = f"http://gitea.test/{owner}/{repo}/issues/{issue['number']}"
|
|
self.issues.setdefault(f"{owner}/{repo}", []).append(issue)
|
|
self.created.append((owner, repo, title))
|
|
return issue
|
|
|
|
async def set_issue_assignees(self, owner, repo, issue_number, logins):
|
|
self.assignee_changes.append((owner, repo, issue_number, list(logins)))
|
|
for issue in self.issues.get(f"{owner}/{repo}", []):
|
|
if issue["number"] == issue_number:
|
|
issue["assignees"] = [{"login": name} for name in logins]
|
|
return issue
|
|
return {}
|
|
|
|
async def set_issue_state(self, owner, repo, issue_number, state):
|
|
if self.fail_state_change:
|
|
raise httpx.HTTPError("simulated write-back failure")
|
|
self.state_changes.append((owner, repo, issue_number, state))
|
|
for issue in self.issues.get(f"{owner}/{repo}", []):
|
|
if issue["number"] == issue_number:
|
|
issue["state"] = "closed" if state == "closed" else "open"
|
|
return {}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_db():
|
|
models.init_db()
|
|
with models.get_db() as conn:
|
|
conn.execute("DELETE FROM board_state")
|
|
conn.execute("DELETE FROM project_notes")
|
|
conn.execute("DELETE FROM board_events")
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_gitea(monkeypatch):
|
|
fake = FakeGitea()
|
|
monkeypatch.setattr(main, "gitea", fake)
|
|
return fake
|
|
|
|
|
|
@pytest.fixture
|
|
def client(fake_gitea):
|
|
with TestClient(main.app) as c:
|
|
yield c
|
|
|
|
|
|
def csrf_from(client: TestClient) -> str:
|
|
"""Load a page so the app sets the CSRF cookie, then return its value."""
|
|
client.get("/")
|
|
return client.cookies.get("kanbante_csrf") or ""
|