2026-06-09 20:02:50 +02:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
|
|
|
|
import asyncio
|
|
|
|
|
import threading
|
2026-05-10 09:08:12 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots")
|
|
|
|
|
|
2026-05-13 23:26:18 +02:00
|
|
|
|
2026-07-04 22:24:59 +02:00
|
|
|
PORT = int(os.environ.get("DEVPLACE_TEST_PORT", "10501"))
|
2026-05-13 23:26:18 +02:00
|
|
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
|
|
|
|
|
|
|
|
|
_TEST_DB = tempfile.NamedTemporaryFile(suffix=f"_{PORT}.db", delete=False)
|
2026-05-11 20:49:45 +02:00
|
|
|
_TEST_DB.close()
|
2026-06-09 23:11:37 +02:00
|
|
|
_TEST_DATA_DIR = Path(tempfile.mkdtemp(suffix=f"_data_{PORT}"))
|
2026-05-11 20:49:45 +02:00
|
|
|
os.environ["DEVPLACE_DATABASE_URL"] = f"sqlite:///{_TEST_DB.name}"
|
2026-06-09 23:11:37 +02:00
|
|
|
os.environ["DEVPLACE_DATA_DIR"] = str(_TEST_DATA_DIR)
|
2026-05-11 20:49:45 +02:00
|
|
|
os.environ["SECRET_KEY"] = "test-secret-key"
|
2026-05-11 22:12:43 +02:00
|
|
|
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
|
2026-05-23 03:21:55 +02:00
|
|
|
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
|
2026-06-13 10:17:45 +02:00
|
|
|
# Pin a single web worker so the per-worker rate-limit divisor is 1 regardless of
|
2026-06-13 11:19:46 +02:00
|
|
|
# any DEVPLACE_WEB_WORKERS the host (or a server .env) exports.
|
2026-06-13 10:17:45 +02:00
|
|
|
os.environ["DEVPLACE_WEB_WORKERS"] = "1"
|
2026-06-13 11:19:46 +02:00
|
|
|
# Disable per-IP rate limiting outright for the suite: the middleware short-circuits
|
|
|
|
|
# when this is set, so request-dense tests (e.g. test_auth_matrix) are never throttled
|
|
|
|
|
# regardless of host environment. test_ratelimit.py re-enables it per test to exercise
|
|
|
|
|
# the limiter directly.
|
|
|
|
|
os.environ["DEVPLACE_DISABLE_RATE_LIMIT"] = "1"
|
2026-06-05 21:51:36 +02:00
|
|
|
os.environ["DEVPLACE_SITEMAP_TTL"] = "0"
|
2026-06-22 18:41:53 +02:00
|
|
|
os.environ["DEVPLACE_HOME_CACHE_TTL"] = "0"
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
os.environ["DEVPLACE_RANKING_TTL"] = "0"
|
|
|
|
|
os.environ["DEVPLACE_MARKET_SATURATION_TTL"] = "0"
|
2026-06-05 21:51:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_async(coro):
|
2026-06-09 22:52:51 +02:00
|
|
|
"""Run a coroutine from sync test code on a dedicated thread and fresh loop."""
|
2026-06-09 21:16:47 +02:00
|
|
|
result_box = []
|
|
|
|
|
exc_box = []
|
2026-06-05 21:51:36 +02:00
|
|
|
|
2026-06-09 21:16:47 +02:00
|
|
|
def _run():
|
|
|
|
|
try:
|
2026-06-09 22:52:51 +02:00
|
|
|
result_box.append(asyncio.run(coro))
|
2026-06-09 21:16:47 +02:00
|
|
|
except BaseException as e:
|
|
|
|
|
exc_box.append(e)
|
feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes:
- `tables.py`: list all tables and inspect table schemas
- `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge
- `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService`
- `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes
Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
2026-06-15 01:00:30 +02:00
|
|
|
finally:
|
|
|
|
|
from devplacepy.database import refresh_snapshot
|
|
|
|
|
|
|
|
|
|
refresh_snapshot()
|
2026-06-09 21:16:47 +02:00
|
|
|
|
|
|
|
|
t = threading.Thread(target=_run, daemon=True)
|
|
|
|
|
t.start()
|
|
|
|
|
t.join(timeout=30)
|
2026-06-05 21:51:36 +02:00
|
|
|
from devplacepy.database import refresh_snapshot
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-05 21:51:36 +02:00
|
|
|
refresh_snapshot()
|
2026-06-09 21:16:47 +02:00
|
|
|
if exc_box:
|
|
|
|
|
raise exc_box[0]
|
|
|
|
|
if result_box:
|
|
|
|
|
return result_box[0]
|
|
|
|
|
raise TimeoutError("run_async timed out waiting for coroutine to complete")
|
2026-06-05 21:51:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _fresh_db_snapshot():
|
|
|
|
|
from devplacepy.database import refresh_snapshot
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-05 21:51:36 +02:00
|
|
|
refresh_snapshot()
|
|
|
|
|
yield
|
2026-05-11 20:49:45 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
def save_failure_screenshot(page, test_name):
|
|
|
|
|
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
try:
|
|
|
|
|
if page and not page.is_closed():
|
|
|
|
|
path = str(SCREENSHOT_DIR / f"{test_name}.png")
|
|
|
|
|
page.screenshot(path=path, full_page=True)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
|
|
|
def pytest_runtest_makereport(item, call):
|
|
|
|
|
outcome = yield
|
|
|
|
|
report = outcome.get_result()
|
|
|
|
|
if report.when == "call" and report.failed:
|
|
|
|
|
for name in ("page", "alice", "bob"):
|
|
|
|
|
if name in item.funcargs:
|
|
|
|
|
try:
|
|
|
|
|
obj = item.funcargs[name]
|
|
|
|
|
if name in ("alice", "bob"):
|
|
|
|
|
obj = obj[0]
|
2026-06-09 18:48:08 +02:00
|
|
|
save_failure_screenshot(
|
|
|
|
|
obj, item.nodeid.replace("::", "_").replace("/", "_")
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-05-11 20:49:45 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def test_db_path():
|
2026-05-11 20:49:45 +02:00
|
|
|
yield _TEST_DB.name
|
2026-05-10 09:08:12 +02:00
|
|
|
try:
|
2026-05-11 20:49:45 +02:00
|
|
|
os.unlink(_TEST_DB.name)
|
2026-05-10 09:08:12 +02:00
|
|
|
except OSError:
|
|
|
|
|
pass
|
2026-06-09 23:11:37 +02:00
|
|
|
import shutil
|
|
|
|
|
|
|
|
|
|
shutil.rmtree(_TEST_DATA_DIR, ignore_errors=True)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
2026-05-11 20:49:45 +02:00
|
|
|
|
2026-06-10 05:22:44 +02:00
|
|
|
def _port_in_use(port):
|
|
|
|
|
import socket
|
|
|
|
|
|
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
|
|
|
sock.settimeout(0.5)
|
|
|
|
|
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def app_server(test_db_path):
|
2026-06-10 05:22:44 +02:00
|
|
|
if _port_in_use(PORT):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Port {PORT} is already in use before app_server starts. A stale test "
|
|
|
|
|
f"server is running with a different database; kill it (the readiness "
|
|
|
|
|
f"check would otherwise bind tests to it and produce phantom failures)."
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
env = os.environ.copy()
|
2026-05-11 08:15:41 +02:00
|
|
|
env["PYTHONUNBUFFERED"] = "1"
|
2026-05-23 04:12:41 +02:00
|
|
|
log_file = tempfile.NamedTemporaryFile(suffix=f"_server_{PORT}.log", delete=False)
|
2026-05-11 03:14:43 +02:00
|
|
|
proc = subprocess.Popen(
|
2026-06-09 18:48:08 +02:00
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-m",
|
|
|
|
|
"uvicorn",
|
|
|
|
|
"devplacepy.main:app",
|
|
|
|
|
"--host",
|
|
|
|
|
"127.0.0.1",
|
|
|
|
|
"--port",
|
|
|
|
|
str(PORT),
|
|
|
|
|
"--log-level",
|
|
|
|
|
"warning",
|
|
|
|
|
],
|
2026-05-11 03:14:43 +02:00
|
|
|
cwd=str(PROJECT_ROOT),
|
|
|
|
|
env=env,
|
2026-05-23 04:12:41 +02:00
|
|
|
stdout=log_file,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
2026-05-11 03:14:43 +02:00
|
|
|
)
|
2026-05-23 04:12:41 +02:00
|
|
|
|
|
|
|
|
def server_log():
|
|
|
|
|
try:
|
|
|
|
|
with open(log_file.name, "r", errors="replace") as f:
|
|
|
|
|
return f.read()[-5000:]
|
|
|
|
|
except OSError:
|
|
|
|
|
return ""
|
|
|
|
|
|
2026-05-11 08:15:41 +02:00
|
|
|
deadline = time.time() + 30
|
2026-05-10 09:08:12 +02:00
|
|
|
while time.time() < deadline:
|
|
|
|
|
try:
|
|
|
|
|
import urllib.request
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
urllib.request.urlopen(f"{BASE_URL}/", timeout=2)
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
2026-05-11 08:15:41 +02:00
|
|
|
poll = proc.poll()
|
|
|
|
|
if poll is not None:
|
|
|
|
|
proc.wait()
|
|
|
|
|
raise RuntimeError(
|
2026-06-09 18:48:08 +02:00
|
|
|
f"Server process died (exit code {poll}). Log:\n{server_log()}"
|
2026-05-11 08:15:41 +02:00
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
time.sleep(0.5)
|
|
|
|
|
else:
|
2026-05-11 08:15:41 +02:00
|
|
|
proc.terminate()
|
|
|
|
|
proc.wait(timeout=5)
|
2026-06-09 18:48:08 +02:00
|
|
|
raise RuntimeError(f"Server did not start after 30s. Log:\n{server_log()}")
|
2026-06-06 16:31:42 +02:00
|
|
|
|
2026-06-13 10:17:45 +02:00
|
|
|
from devplacepy.database import set_setting as _set_setting
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-13 10:17:45 +02:00
|
|
|
# Use set_setting (upsert + cache-version bump) rather than a raw insert: the
|
|
|
|
|
# server process caches settings and only reloads when the version bumps, so a
|
|
|
|
|
# bare insert can leave the worker on a stale/default rate limit and throttle
|
|
|
|
|
# request-dense tests (e.g. test_auth_matrix) with spurious 429s. Pin the limit
|
|
|
|
|
# high regardless of any pre-existing row or the host's DEVPLACE_* environment.
|
|
|
|
|
_set_setting("rate_limit_per_minute", "1000000")
|
|
|
|
|
_set_setting("rate_limit_window_seconds", "60")
|
2026-06-06 16:31:42 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
yield proc
|
2026-05-11 03:14:43 +02:00
|
|
|
try:
|
|
|
|
|
proc.terminate()
|
|
|
|
|
proc.wait(timeout=10)
|
|
|
|
|
except Exception:
|
|
|
|
|
proc.kill()
|
|
|
|
|
proc.wait(timeout=5)
|
2026-05-23 04:12:41 +02:00
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
log_file.close()
|
|
|
|
|
os.unlink(log_file.name)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
2026-05-11 03:14:43 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def playwright_instance():
|
|
|
|
|
from playwright.sync_api import sync_playwright
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
with sync_playwright() as p:
|
|
|
|
|
yield p
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def browser(playwright_instance):
|
|
|
|
|
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
|
|
|
|
|
b = playwright_instance.chromium.launch(
|
2026-06-09 18:48:08 +02:00
|
|
|
headless=headless,
|
|
|
|
|
slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")),
|
|
|
|
|
args=["--window-size=1400,900"],
|
2026-05-10 09:08:12 +02:00
|
|
|
)
|
|
|
|
|
yield b
|
|
|
|
|
b.close()
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-11 00:41:41 +02:00
|
|
|
@pytest.fixture(scope="module")
|
2026-05-10 09:08:12 +02:00
|
|
|
def browser_context(browser):
|
2026-06-09 18:48:08 +02:00
|
|
|
ctx = browser.new_context(
|
|
|
|
|
viewport={"width": 1400, "height": 900},
|
|
|
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
yield ctx
|
|
|
|
|
ctx.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def page(browser_context):
|
2026-05-11 00:41:41 +02:00
|
|
|
browser_context.clear_cookies()
|
2026-05-10 09:08:12 +02:00
|
|
|
p = browser_context.new_page()
|
2026-05-11 05:30:51 +02:00
|
|
|
p.set_default_timeout(15000)
|
2026-05-10 21:33:53 +02:00
|
|
|
p.bring_to_front()
|
2026-05-10 09:08:12 +02:00
|
|
|
yield p
|
|
|
|
|
p.close()
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def signup_user(page, user):
|
2026-05-10 21:33:53 +02:00
|
|
|
page.bring_to_front()
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.fill("#username", user["username"])
|
|
|
|
|
page.fill("#email", user["email"])
|
|
|
|
|
page.fill("#password", user["password"])
|
|
|
|
|
page.fill("#confirm_password", user["password"])
|
|
|
|
|
page.click("button:has-text('Create account')")
|
2026-06-12 06:30:08 +02:00
|
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_user(page, user):
|
2026-05-10 21:33:53 +02:00
|
|
|
page.bring_to_front()
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.fill("#email", user["email"])
|
|
|
|
|
page.fill("#password", user["password"])
|
|
|
|
|
page.click("button:has-text('Sign in')")
|
2026-05-11 03:14:43 +02:00
|
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
def assert_share_copies(page, expected_fragment):
|
|
|
|
|
from playwright.sync_api import expect
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
share = page.locator("button[data-share]").first
|
|
|
|
|
share.scroll_into_view_if_needed()
|
2026-06-10 05:22:44 +02:00
|
|
|
assert expected_fragment in (share.get_attribute("data-share") or ""), (
|
|
|
|
|
f"data-share={share.get_attribute('data-share')!r}"
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
share.click()
|
|
|
|
|
expect(share).to_have_text("Copied!", timeout=3000)
|
|
|
|
|
expect(share).not_to_have_text("Copied!", timeout=3000)
|
|
|
|
|
try:
|
|
|
|
|
clip = page.evaluate("navigator.clipboard.readText()")
|
|
|
|
|
except Exception:
|
|
|
|
|
clip = None
|
|
|
|
|
if clip:
|
2026-06-09 18:48:08 +02:00
|
|
|
assert clip.startswith("http") and expected_fragment in clip, (
|
|
|
|
|
f"clipboard={clip!r}"
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def seeded_db(app_server):
|
|
|
|
|
"""Create test users once at session level using requests directly."""
|
|
|
|
|
import urllib.request
|
|
|
|
|
import urllib.parse
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
users = [
|
|
|
|
|
("alice_test", "alice@test.devplace", "secret123"),
|
|
|
|
|
("bob_test", "bob@test.devplace", "secret456"),
|
|
|
|
|
]
|
|
|
|
|
for username, email, pw in users:
|
2026-06-09 18:48:08 +02:00
|
|
|
data = urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"username": username,
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": pw,
|
|
|
|
|
"confirm_password": pw,
|
|
|
|
|
}
|
|
|
|
|
).encode()
|
2026-05-10 09:08:12 +02:00
|
|
|
req = urllib.request.Request(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data=data,
|
|
|
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
|
|
|
)
|
|
|
|
|
urllib.request.urlopen(req)
|
2026-06-10 05:22:44 +02:00
|
|
|
|
|
|
|
|
from devplacepy.database import get_table as _get_table
|
|
|
|
|
|
|
|
|
|
users_table = _get_table("users")
|
|
|
|
|
for username, role in (("alice_test", "Admin"), ("bob_test", "Member")):
|
|
|
|
|
row = users_table.find_one(username=username)
|
|
|
|
|
if row and row.get("role") != role:
|
|
|
|
|
users_table.update({"uid": row["uid"], "role": role}, ["uid"])
|
2026-05-10 09:08:12 +02:00
|
|
|
return {
|
2026-06-09 18:48:08 +02:00
|
|
|
"alice": {
|
|
|
|
|
"username": "alice_test",
|
|
|
|
|
"email": "alice@test.devplace",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
},
|
|
|
|
|
"bob": {
|
|
|
|
|
"username": "bob_test",
|
|
|
|
|
"email": "bob@test.devplace",
|
|
|
|
|
"password": "secret456",
|
|
|
|
|
},
|
2026-05-10 09:08:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def alice(page, seeded_db):
|
|
|
|
|
login_user(page, seeded_db["alice"])
|
|
|
|
|
return page, seeded_db["alice"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def bob(browser, seeded_db):
|
2026-06-09 18:48:08 +02:00
|
|
|
ctx = browser.new_context(
|
|
|
|
|
viewport={"width": 1400, "height": 900},
|
|
|
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
p = ctx.new_page()
|
2026-05-11 05:30:51 +02:00
|
|
|
p.set_default_timeout(15000)
|
2026-05-10 21:33:53 +02:00
|
|
|
p.bring_to_front()
|
2026-05-10 09:08:12 +02:00
|
|
|
login_user(p, seeded_db["bob"])
|
|
|
|
|
yield p, seeded_db["bob"]
|
|
|
|
|
p.close()
|
|
|
|
|
ctx.close()
|
2026-06-05 20:33:35 +02:00
|
|
|
|
|
|
|
|
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
@pytest.fixture
|
|
|
|
|
def mobile_page(browser, seeded_db):
|
2026-06-09 18:48:08 +02:00
|
|
|
ctx = browser.new_context(
|
|
|
|
|
viewport={"width": 412, "height": 840},
|
|
|
|
|
has_touch=True,
|
|
|
|
|
permissions=["clipboard-read", "clipboard-write"],
|
|
|
|
|
)
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
p = ctx.new_page()
|
|
|
|
|
p.set_default_timeout(15000)
|
|
|
|
|
p.bring_to_front()
|
|
|
|
|
login_user(p, seeded_db["alice"])
|
|
|
|
|
yield p, seeded_db["alice"]
|
|
|
|
|
p.close()
|
|
|
|
|
ctx.close()
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 20:33:35 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def local_db():
|
|
|
|
|
from devplacepy.database import init_db, db
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-05 20:33:35 +02:00
|
|
|
init_db()
|
|
|
|
|
return db
|