"""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) ) supports_attachments = True async def update_issue(self, owner, repo, number, title=None, body=None): issue = await self.get_issue(owner, repo, number) if title is not None: issue["title"] = title if body is not None: issue["body"] = body return issue # Pièces jointes en mémoire : {(repo, numéro): [assets]} @property def _assets(self): return self.__dict__.setdefault("assets_store", {}) async def list_issue_assets(self, owner, repo, number): return list(self._assets.get((f"{owner}/{repo}", number), [])) async def upload_issue_asset( self, owner, repo, number, filename, content, content_type="application/octet-stream" ): store = self._assets.setdefault((f"{owner}/{repo}", number), []) asset = { "id": 700 + len(store) + number, "name": filename, "size": len(content), "download_count": 0, "created_at": "2026-10-23T10:00:00Z", "_content": content, "_type": content_type, } store.append(asset) return {k: v for k, v in asset.items() if not k.startswith("_")} async def delete_issue_asset(self, owner, repo, number, asset_id): store = self._assets.get((f"{owner}/{repo}", number), []) self._assets[(f"{owner}/{repo}", number)] = [ a for a in store if a["id"] != asset_id ] async def download_issue_asset(self, owner, repo, number, asset_id): for asset in self._assets.get((f"{owner}/{repo}", number), []): if asset["id"] == asset_id: return asset["_content"], asset["_type"] raise AssertionError("asset introuvable dans le faux") 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 ""