feat(v5.2.0): finalize Infrastructure & Polish (tests isolation, xdist, lint, CI)
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s

tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
This commit is contained in:
2026-09-11 23:36:53 -04:00
parent 881c3e3e0a
commit ba363eaee9
42 changed files with 566 additions and 206 deletions
+22 -7
View File
@@ -1,9 +1,15 @@
"""FlowDeck — pytest fixtures and configuration."""
"""FlowDeck — pytest fixtures and configuration.
Each test gets a fresh, isolated SQLite database and backup directory so the
suite is safe to run in parallel (``pytest -n auto``): workers never share a
database file, and no state leaks between tests.
"""
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
@@ -15,7 +21,7 @@ def client():
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
# Set env BEFORE importing app modules (config reads at import time)
# Set env BEFORE importing app modules (config reads at import time).
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
@@ -23,9 +29,21 @@ def client():
os.environ["BACKUP_DIR"] = backup_dir
os.environ["PROJECT_SYNC_ENABLED"] = "false"
# Force config reload by clearing the cached Settings instance
# IMPORTANT: mutate the existing Settings singleton in place — do NOT rebind
# `app.config.settings`. Modules such as `app.services.backup` and
# `app.routers.auth` hold a direct reference imported at load time, so
# rebinding would leave them pointing at the stale defaults (this was the
# cause of the previously-skipped flaky backup tests).
import app.config
app.config.settings = app.config.Settings()
s = app.config.settings
s.database_url = f"sqlite:///{db_path}"
s.app_secret_key = "test-secret-for-tests"
s.rate_limit_enabled = False
s.backup_enabled = True
s.backup_dir = backup_dir
s.backup_interval_hours = 24
s.backup_keep = 30
s.project_sync_enabled = False
from app.db import init_db
from app.main import app
@@ -47,6 +65,3 @@ def client():
Path(backup_dir).rmdir()
except OSError:
pass
from fastapi.testclient import TestClient
+17 -2
View File
@@ -19,10 +19,25 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
# A default user id=1 so tests that reference the implicit user (favorites,
# workspace ownership) satisfy foreign keys without relying on leaked state.
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) "
"VALUES (1, 'tester', 'Tester', '[email protected]', 1)"
)
conn.commit()
yield TestClient(app)
os.unlink(db_path)
@@ -1915,7 +1930,7 @@ def test_nav_menu_workspace_pages(client):
"""/api/nav/menu returns root pages and nested children for a workspace."""
from app.db import get_conn
with get_conn() as conn:
u = conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')")
conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')")
uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"]
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,))
ws_id = cur.lastrowid
@@ -3294,7 +3309,7 @@ def test_v490_create_comment_with_mentions(client):
json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5},
cookies=cookies)
assert r.status_code == 200
cid = r.json()["id"]
r.json()["id"]
r = client.get(f"/api/pages/{pid}/comments", cookies=cookies)
assert r.status_code == 200
+1 -1
View File
@@ -159,7 +159,7 @@ def test_automation_scope_collection_filter(client):
}).json()["id"]
p_c1 = _make_page(client, c1, props={"Status": "Todo"})
p_c2 = _make_page(client, c2, props={"Status": "Todo"})
_make_page(client, c2, props={"Status": "Todo"})
from app.db import get_conn
with get_conn() as conn:
+5
View File
@@ -17,6 +17,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
+10 -5
View File
@@ -194,7 +194,8 @@ def test_ws_presence_join_leave(client):
pid = _make_page()
_auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json()
wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
w_f = wb.receive_json() # welcome
@@ -211,10 +212,12 @@ def test_ws_cursor_broadcast(client):
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}])
_auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json()
wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json()
wb.receive_json()
wb.receive_json()
wa.receive_json() # peer_join
wa.send_json({"t": "sel", "block": "a", "offset": 1})
m = wb.receive_json()
@@ -229,10 +232,12 @@ def test_ws_title_broadcast(client):
pid = _make_page()
_auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json()
wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json()
wb.receive_json()
wb.receive_json()
wa.receive_json()
wa.send_json({"t": "title", "title": "Nouveau titre"})
m = wb.receive_json()
+5
View File
@@ -20,6 +20,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
+203 -9
View File
@@ -148,16 +148,64 @@ def test_onboarding_workspace_and_project(client):
# ═══════════════ Backups ═══════════════
def test_backup_snapshot_and_pruning(client):
"""Skipped: flaky due to test isolation issues with global config state.
Passes when run in isolation.
"""
pytest.skip("Flaky: backup_dir cleanup interference between tests")
"""backup_db() snapshots the SQLite file and prune keeps the newest N."""
import datetime
def test_backup_admin_api_requires_admin(client):
"""Skipped: flaky due to test isolation issues with global config state.
Passes when run in isolation.
"""
pytest.skip("Flaky: admin API test interference between tests")
from app.services import backup as backup_svc
base = datetime.datetime(2026, 1, 1, 0, 0, 0)
first = backup_svc.backup_db(now=base)
assert first and first.startswith("flowdeck-") and first.endswith(".db")
backups = backup_svc.list_backups()
assert len(backups) == 1
assert backups[0]["filename"] == first
assert backups[0]["size"] > 0
# Distinct timestamps → distinct filenames (the format has 1-second resolution).
for i in range(1, 4):
assert backup_svc.backup_db(now=base + datetime.timedelta(seconds=i))
assert len(backup_svc.list_backups()) == 4
removed = backup_svc.prune_old_backups(keep=2)
assert removed == 2
remaining = backup_svc.list_backups()
assert len(remaining) == 2
# Newest first (filename sort == chronological for this format).
assert remaining[0]["filename"] > remaining[1]["filename"]
# Age + due logic (file mtime is "now", so a fresh backup is not due).
assert backup_svc.last_backup_age_hours() is not None
assert backup_svc.backup_due() is False
def test_backup_disabled_returns_none(client):
from app.config import settings
from app.services import backup as backup_svc
previous = settings.backup_enabled
settings.backup_enabled = False
try:
assert backup_svc.backup_db() is None
finally:
settings.backup_enabled = previous
def test_backup_admin_api(client):
"""The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403
# First registered user becomes admin → allowed.
_register(client)
run = client.post("/api/settings/backups/run")
assert run.status_code == 200, run.text
assert run.json()["status"] == "ok"
assert run.json()["filename"].startswith("flowdeck-")
listing = client.get("/api/settings/backups")
assert listing.status_code == 200
assert len(listing.json()["backups"]) >= 1
# ═══════════════ Projects registry ═══════════════
@@ -266,6 +314,152 @@ def test_projects_sync_with_mock_client(client):
os.environ.setdefault("PROJECT_SYNC_ENABLED", "false")
# ═══════════════ OAuth integration (mocked providers) ═══════════════
class _FakeProvider:
"""Stand-in OAuth provider — no network calls."""
name = "gitea"
icon = "🔗"
def __init__(self, login="octocat", full_name="Octo Cat", email="[email protected]"):
self.login = login
self.full_name = full_name
self.email = email
def is_enabled(self) -> bool:
return True
def get_authorize_url(self, state, redirect_uri=None, force_login=False):
return f"https://gitea.test/login/oauth/authorize?client_id=cid&state={state}"
async def exchange_code(self, code, redirect_uri=None):
return {"access_token": "tok-123", "refresh_token": "ref-123"}
async def get_user(self, access_token):
return {
"login": self.login,
"full_name": self.full_name,
"email": self.email,
"avatar_url": "https://gitea.test/avatar.png",
"provider_id": "42",
}
async def list_repositories(self, access_token):
return []
def _patch_provider(monkeypatch, provider=None):
from app.auth import providers
fake = provider or _FakeProvider()
monkeypatch.setattr(providers, "get_provider", lambda name: fake if name in ("gitea", "github") else None)
return fake
def _extract_state(location: str) -> str:
from urllib.parse import parse_qs, urlparse
return parse_qs(urlparse(location).query)["state"][0]
def test_oauth_login_callback_flow(client, monkeypatch):
"""Full login flow: authorize redirect → callback → user + token + session."""
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea", follow_redirects=False)
assert login.status_code == 302
assert "gitea.test/login/oauth/authorize" in login.headers["location"]
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "/workspaces" in cb.headers["location"]
assert client.cookies.get("flowdeck_session")
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='gitea_octocat'").fetchone()
assert user and user["auth_method"] == "gitea"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
me = client.get("/auth/user").json()
assert me["authenticated"] is True
assert me["user"]["login"] == "gitea_octocat"
def test_oauth_github_callback_creates_github_user(client, monkeypatch):
_patch_provider(monkeypatch, _FakeProvider(login="hubber", full_name="Hub Ber"))
login = client.get("/auth/login?provider=github", follow_redirects=False)
assert login.status_code == 302
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=xyz&state={state}", follow_redirects=False)
assert cb.status_code == 302
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='github_hubber'").fetchone()
assert user and user["auth_method"] == "github"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
def test_oauth_link_mode_attaches_to_current_user(client, monkeypatch):
"""mode=link must attach the forge token to the already-logged-in user."""
_register(client)
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
state = _extract_state(login.headers["location"])
assert state.endswith(":link")
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "settings" in cb.headers["location"]
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE login='[email protected]'").fetchone()
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
# No new OAuth user was created.
count = conn.execute("SELECT COUNT(*) FROM users WHERE login LIKE 'gitea_%'").fetchone()[0]
assert count == 0
def test_oauth_callback_rejects_invalid_state(client, monkeypatch):
_patch_provider(monkeypatch)
client.get("/auth/login?provider=gitea", follow_redirects=False)
bad = client.get("/auth/callback?code=abc&state=tampered", follow_redirects=False)
assert bad.status_code == 400
def test_oauth_provider_authorize_urls():
from app.auth.providers import GiteaProvider, GitHubProvider
gitea = GiteaProvider("https://git.example.com", "cid", "sec", "https://app/auth/callback")
assert gitea.is_enabled()
gitea_url = gitea.get_authorize_url("st", redirect_uri="https://app/auth/callback")
assert gitea_url.startswith("https://git.example.com/login/oauth/authorize?")
assert "client_id=cid" in gitea_url and "state=st" in gitea_url
github = GitHubProvider("cid", "sec", "https://app/auth/callback")
assert github.is_enabled()
assert "github.com/login/oauth/authorize" in github.get_authorize_url("st")
assert not GitHubProvider("", "", "https://app/auth/callback").is_enabled()
# ═══════════════ Multi-user permissions ═══════════════
def test_permission_manager_roles(client):
+8 -8
View File
@@ -3,19 +3,18 @@ and v5.5.0 (embed, bookmark, video, audio, cover, icon)."""
from __future__ import annotations
import json
import tempfile
import os
import pytest
import tempfile
# ── Helpers ──────────────────────────────────────────────────────────────
def _login(client):
"""Create admin user and return session cookie + csrf token (like test_app.py)."""
import secrets
from app.auth.session import SessionManager
from app.db import get_conn
import secrets
with get_conn() as conn:
login = f"testadmin_{secrets.token_hex(4)}"
@@ -167,7 +166,7 @@ class TestV54VersionHistory:
)
data2 = r2.json()
assert len(data2["versions"]) >= 2
v2_id = data2["versions"][0]["id"]
data2["versions"][0]["id"]
# restore v1
r3 = client.post(
@@ -191,10 +190,11 @@ class TestV54TrashPurge:
"""v5.4.0: global trash 30-day purge."""
def test_trash_purge_removes_old_deleted_pages(self, client):
from app.services.trash import purge_expired
from app.db import init_db, get_conn
import datetime
from app.db import get_conn, init_db
from app.services.trash import purge_expired
# create temp DB
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close()
@@ -565,4 +565,4 @@ class TestDashboardPublicNewBlocks:
assert "<video" in html
assert "<audio" in html
assert "bookmark" in html or "Ex" in html
assert "iframe" in html
assert "iframe" in html