test: suite pytest (36 tests) et CI Gitea Actions
Modèles (chemin DB, migration 0.1.0, ordre, réconciliation), classification, client Gitea (pagination, en-tête, écritures), API (CSRF, write-back, codes d'erreur, 502 propre), auth Basic et fail-closed. Tous verts; pyflakes propre.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install
|
||||
run: pip install -e ".[dev]"
|
||||
- name: Compile check
|
||||
run: python -m py_compile app/*.py
|
||||
- name: Tests
|
||||
run: pytest -q
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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}",
|
||||
}
|
||||
|
||||
|
||||
def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> dict:
|
||||
issue = {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"state": state,
|
||||
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"assignee": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||
}
|
||||
if pr:
|
||||
issue["pull_request"] = {"url": "http://gitea.test/pr"}
|
||||
return issue
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self.repos = [
|
||||
_repo("demo", now - timedelta(days=1)),
|
||||
_repo("vieux", now - timedelta(days=800)),
|
||||
_repo("archive", now - timedelta(days=1), archived=True),
|
||||
]
|
||||
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.state_changes: list[tuple] = []
|
||||
self.created: 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 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 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_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")
|
||||
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 ""
|
||||
@@ -0,0 +1,198 @@
|
||||
"""HTTP API behaviour with a fake Gitea (audit C2/C3/I5/M6/M7)."""
|
||||
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_health_is_public_liveness(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
assert r.json()["version"] == "0.2.0"
|
||||
|
||||
|
||||
def test_readiness_reflects_gitea(client, fake_gitea):
|
||||
assert client.get("/api/readiness").status_code == 200
|
||||
fake_gitea.ping_result = False
|
||||
r = client.get("/api/readiness")
|
||||
assert r.status_code == 503
|
||||
assert r.json()["checks"]["gitea"] is False
|
||||
assert r.json()["checks"]["database"] is True
|
||||
|
||||
|
||||
def test_dashboard_renders_three_categories(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "demo" in r.text and "vieux" in r.text and "archive" in r.text
|
||||
assert "Archivés" in r.text and "Inactifs" in r.text
|
||||
|
||||
|
||||
def test_board_hides_pull_requests_and_reconciles(client):
|
||||
models.move_issue("Projets/demo", 3, "todo") # closed in Gitea
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche un" in r.text
|
||||
assert "PR déguisée" not in r.text # M6: PRs are not issues
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["done"]] == [3] # reconciled
|
||||
|
||||
|
||||
def test_focus_lists_in_progress_across_projects(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/focus")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" in r.text
|
||||
|
||||
|
||||
def test_gitea_outage_is_a_clean_502_not_500(client, fake_gitea):
|
||||
fake_gitea.raise_on_search = True
|
||||
r = client.get("/")
|
||||
assert r.status_code == 502
|
||||
assert "joindre Gitea" in r.text
|
||||
r = client.get("/api/projects")
|
||||
assert r.status_code == 502
|
||||
assert r.json()["ok"] is False
|
||||
|
||||
|
||||
# ── CSRF + moves + write-back ──────────────────────────────
|
||||
|
||||
def test_move_requires_csrf_token(client):
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={"repo_full_name": "Projets/demo", "issue_number": 1, "to_column": "done"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_move_to_done_closes_issue_in_gitea(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"issue_number": 1,
|
||||
"to_column": "done",
|
||||
"from_column": "backlog",
|
||||
"to_order": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["gitea_sync"] == "closed"
|
||||
assert fake_gitea.state_changes == [("Projets", "demo", 1, "closed")]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["done"]] == [1]
|
||||
|
||||
|
||||
def test_move_out_of_done_reopens_issue(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "done", "from_column": "backlog", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "todo", "from_column": "done", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.json()["gitea_sync"] == "reopened"
|
||||
assert fake_gitea.state_changes[-1] == ("Projets", "demo", 1, "open")
|
||||
|
||||
|
||||
def test_move_writeback_failure_is_reported_not_fatal(client, fake_gitea):
|
||||
fake_gitea.fail_state_change = True
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "done", "from_column": "backlog", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
assert r.json()["gitea_sync"] == "failed"
|
||||
|
||||
|
||||
def test_move_invalid_column_is_400(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "nimporte_quoi", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["ok"] is False
|
||||
|
||||
|
||||
def test_move_persists_card_order(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 5,
|
||||
"to_column": "backlog", "from_column": "backlog",
|
||||
"to_order": "5,1", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["backlog"]][:2] == [5, 1]
|
||||
|
||||
|
||||
# ── Notes + creation ───────────────────────────────────────
|
||||
|
||||
def test_notes_roundtrip_via_api(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/notes",
|
||||
data={"repo_full_name": "Projets/demo", "note": "à retenir", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert models.get_notes("Projets/demo") == "à retenir"
|
||||
|
||||
|
||||
def test_notes_size_limit_is_413(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_MAX_NOTE_CHARS", "10")
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/notes",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"note": "x" * 50,
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_create_issue_goes_to_gitea_and_lands_on_board(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "title": "Bug à corriger",
|
||||
"column": "backlog", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
number = r.json()["issue"]["number"]
|
||||
assert fake_gitea.created == [("Projets", "demo", "Bug à corriger")]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert number in [c["issue_number"] for c in state["backlog"]]
|
||||
|
||||
|
||||
def test_create_issue_validates_title(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues",
|
||||
data={"repo_full_name": "Projets/demo", "title": " ", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Basic auth + fail-closed behaviour (audit C2)."""
|
||||
|
||||
import base64
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
|
||||
|
||||
def test_no_credentials_means_open_but_health_works(client):
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_auth_required_when_configured(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
assert client.get("/").status_code == 401
|
||||
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
|
||||
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
||||
# Liveness stays public for Docker HEALTHCHECK.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_require_auth_fails_closed_without_credentials(monkeypatch, fake_gitea):
|
||||
monkeypatch.delenv("KANBANTE_AUTH_USER", raising=False)
|
||||
monkeypatch.delenv("KANBANTE_AUTH_PASSWORD", raising=False)
|
||||
monkeypatch.setenv("KANBANTE_REQUIRE_AUTH", "1")
|
||||
with TestClient(main.app) as client:
|
||||
assert client.get("/").status_code == 503
|
||||
assert client.get("/api/health").status_code == 200
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Repo classification: rolling window + real archived flag (audit I1)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.main import classify_repos
|
||||
|
||||
NOW = datetime(2026, 10, 11, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _repo(name, updated, archived=False):
|
||||
return {
|
||||
"full_name": f"Projets/{name}",
|
||||
"updated_at": updated.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"archived": archived,
|
||||
}
|
||||
|
||||
|
||||
def test_recent_repo_is_active():
|
||||
active, inactive, archived = classify_repos(
|
||||
[_repo("a", NOW - timedelta(days=2))], now=NOW
|
||||
)
|
||||
assert [r["full_name"] for r in active] == ["Projets/a"]
|
||||
assert inactive == [] and archived == []
|
||||
|
||||
|
||||
def test_old_repo_is_inactive_not_archived():
|
||||
active, inactive, archived = classify_repos(
|
||||
[_repo("a", NOW - timedelta(days=900))], now=NOW
|
||||
)
|
||||
assert inactive and not active and not archived
|
||||
|
||||
|
||||
def test_archived_flag_wins_even_if_recent():
|
||||
# 0.1.0 showed recently-touched archived repos as "active".
|
||||
active, inactive, archived = classify_repos(
|
||||
[_repo("a", NOW - timedelta(hours=1), archived=True)], now=NOW
|
||||
)
|
||||
assert archived and not active and not inactive
|
||||
|
||||
|
||||
def test_no_hardcoded_year_2027_still_works():
|
||||
future = datetime(2027, 1, 5, tzinfo=timezone.utc)
|
||||
active, _, _ = classify_repos([_repo("a", future - timedelta(days=3))], now=future)
|
||||
assert len(active) == 1
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Gitea client: pagination, auth header, write operations (audit I2/M9)."""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import PAGE_SIZE, GiteaClient
|
||||
|
||||
|
||||
def _client(handler, token=""):
|
||||
return GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token=token,
|
||||
cache_ttl=0,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
async def test_pagination_follows_until_short_page():
|
||||
seen_pages = []
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
page = int(request.url.params.get("page", "1"))
|
||||
seen_pages.append(page)
|
||||
count = PAGE_SIZE if page == 1 else 25
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
data = [{"id": start + i} for i in range(count)]
|
||||
return httpx.Response(200, json={"data": data}, headers={"X-Total-Count": "75"})
|
||||
|
||||
client = _client(handler)
|
||||
repos = await client.search_repos()
|
||||
assert len(repos) == 75
|
||||
assert seen_pages.count(1) >= 1 and 2 in seen_pages
|
||||
|
||||
|
||||
async def test_pagination_without_total_header():
|
||||
def handler(request):
|
||||
page = int(request.url.params.get("page", "1"))
|
||||
count = PAGE_SIZE if page == 1 else 10
|
||||
return httpx.Response(200, json=[{"number": i} for i in range(count)])
|
||||
|
||||
client = _client(handler)
|
||||
issues = await client.get_repo_issues("o", "r", state="open")
|
||||
assert len(issues) == PAGE_SIZE + 10
|
||||
|
||||
|
||||
async def test_search_uses_authenticated_user_id_not_hardcoded():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 42})
|
||||
seen["uid"] = request.url.params.get("uid")
|
||||
return httpx.Response(200, json={"data": []})
|
||||
|
||||
client = _client(handler)
|
||||
await client.search_repos()
|
||||
assert seen["uid"] == "42"
|
||||
|
||||
|
||||
async def test_authorization_header_only_with_token():
|
||||
captured = {}
|
||||
|
||||
def handler(request):
|
||||
captured["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(200, json={"version": "1.27.3"})
|
||||
|
||||
await _client(handler, token="").ping()
|
||||
assert captured["auth"] is None
|
||||
|
||||
await _client(handler, token="sekret").ping()
|
||||
assert captured["auth"] == "token sekret"
|
||||
|
||||
|
||||
async def test_set_issue_state_patches_gitea():
|
||||
captured = {}
|
||||
|
||||
def handler(request):
|
||||
captured["method"] = request.method
|
||||
captured["path"] = request.url.path
|
||||
captured["json"] = request.content.decode()
|
||||
return httpx.Response(200, json={"state": "closed"})
|
||||
|
||||
client = _client(handler, token="x")
|
||||
await client.set_issue_state("Projets", "demo", 3, "closed")
|
||||
assert captured["method"] == "PATCH"
|
||||
assert captured["path"].endswith("/repos/Projets/demo/issues/3")
|
||||
assert '"closed"' in captured["json"]
|
||||
|
||||
|
||||
async def test_create_issue_posts_title():
|
||||
def handler(request):
|
||||
assert request.method == "POST"
|
||||
return httpx.Response(201, json={"number": 9, "title": "Nouvelle"})
|
||||
|
||||
client = _client(handler, token="x")
|
||||
issue = await client.create_issue("Projets", "demo", "Nouvelle")
|
||||
assert issue["number"] == 9
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Model layer: env-driven DB path, migrations, ordering, reconciliation."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from app import models
|
||||
|
||||
|
||||
def test_db_path_honours_env_var():
|
||||
# Regression for audit point C1: the path the Dockerfile sets must win.
|
||||
assert models.db_path() == os.environ["KANBANTE_DB_PATH"]
|
||||
|
||||
|
||||
def test_board_state_ignores_unknown_column_rows():
|
||||
# Regression for audit point I4: a legacy row must not KeyError the board.
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO board_state (repo_full_name, issue_number, column_name) "
|
||||
"VALUES ('a/b', 99, 'ancienne_colonne')"
|
||||
)
|
||||
state = models.get_board_state("a/b")
|
||||
assert all(cards == [] for cards in state.values())
|
||||
|
||||
|
||||
def test_migration_from_v01_schema(tmp_path, monkeypatch):
|
||||
legacy = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(legacy)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE board_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE project_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name)
|
||||
);
|
||||
INSERT INTO board_state (repo_full_name, issue_number, column_name)
|
||||
VALUES ('a/b', 1, 'todo'), ('a/b', 2, 'colonne_disparue');
|
||||
INSERT INTO project_notes (repo_full_name, note) VALUES ('a/b', 'salut');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(legacy))
|
||||
models.init_db()
|
||||
|
||||
with models.get_db() as conn:
|
||||
board_cols = [r[1] for r in conn.execute("PRAGMA table_info(board_state)")]
|
||||
notes_cols = [r[1] for r in conn.execute("PRAGMA table_info(project_notes)")]
|
||||
remaining = conn.execute("SELECT COUNT(*) FROM board_state").fetchone()[0]
|
||||
assert "position" in board_cols
|
||||
assert "updated_at" in notes_cols
|
||||
assert remaining == 1 # valid row kept, unknown-column row dropped
|
||||
assert models.get_notes("a/b") == "salut"
|
||||
|
||||
|
||||
def test_move_and_column_order_persisted():
|
||||
models.move_issue("a/b", 1, "todo")
|
||||
models.move_issue("a/b", 2, "todo")
|
||||
models.set_column_order("a/b", "todo", [2, 1])
|
||||
state = models.get_board_state("a/b")
|
||||
assert [c["issue_number"] for c in state["todo"]] == [2, 1]
|
||||
|
||||
|
||||
def test_reconcile_closed_to_done_and_purge_deleted():
|
||||
models.move_issue("a/b", 1, "in_progress")
|
||||
models.move_issue("a/b", 2, "todo")
|
||||
models.move_issue("a/b", 3, "review")
|
||||
changed = models.reconcile_board_state("a/b", open_numbers={1}, closed_numbers={2})
|
||||
assert changed == {"closed_to_done": 1, "purged": 1}
|
||||
state = models.get_board_state("a/b")
|
||||
assert [c["issue_number"] for c in state["in_progress"]] == [1]
|
||||
assert [c["issue_number"] for c in state["done"]] == [2]
|
||||
assert all(
|
||||
c["issue_number"] != 3 for cards in state.values() for c in cards
|
||||
)
|
||||
|
||||
|
||||
def test_notes_roundtrip():
|
||||
assert models.get_notes("a/b") is None
|
||||
models.set_notes("a/b", "première version")
|
||||
models.set_notes("a/b", "deuxième version")
|
||||
assert models.get_notes("a/b") == "deuxième version"
|
||||
|
||||
|
||||
def test_move_issue_rejects_invalid_column():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
models.move_issue("a/b", 1, "pas_une_colonne")
|
||||
Reference in New Issue
Block a user