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
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:
+22
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user