# retoor import time import pytest from fastapi.testclient import TestClient from devplacepy.database import get_setting, get_table, set_setting from devplacepy.main import app from devplacepy.routers.admin import communities as router_module from devplacepy.services.community.runner import CommunityRunner from devplacepy.services.community.store import store from devplacepy.utils import register_account from tests.unit.services.community.fakes import FakeFetcher, json_community, no_sleep _counter = [0] def unique(prefix: str) -> str: _counter[0] += 1 return f"{prefix}{int(time.time() * 1000)}{_counter[0]}" def make_account(role: str = "Member") -> dict: name = unique("cmty") uid, _, _ = register_account(name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True) if role != "Member": get_table("users").update({"uid": uid, "role": role}, ["uid"]) row = get_table("users").find_one(uid=uid) return {"username": name, "headers": {"X-API-KEY": row["api_key"], "Accept": "application/json"}} @pytest.fixture(scope="module") def client(): previous = {key: get_setting(key, "") for key in ("sync_enabled", "sync_origins")} set_setting("sync_enabled", "1") set_setting("sync_origins", "") with TestClient(app) as test_client: yield test_client for key, value in previous.items(): set_setting(key, value) @pytest.fixture def fake_world(monkeypatch): routes = json_community() monkeypatch.setattr( router_module, "CommunityRunner", lambda: CommunityRunner(fetcher_factory=lambda: FakeFetcher(routes), sleep=no_sleep), ) get_table("communities").delete() get_table("community_runs").delete() get_table("sync_links").delete(origin="forum.test") return routes @pytest.fixture def admin(client): return make_account("Admin") def wait_for(predicate, seconds: float = 10.0): deadline = time.time() + seconds while time.time() < deadline: value = predicate() if value: return value time.sleep(0.1) raise AssertionError("condition never became true") def add_ready(client, admin) -> dict: response = client.post("/admin/communities", data={"query": "https://forum.test/api/posts"}, headers=admin["headers"]) assert response.status_code == 200, response.text return wait_for(lambda: (row := store.get("forum-test")) and row["status"] == "ready" and row) def test_member_is_refused(client, admin, fake_world): member = make_account() assert client.get("/admin/communities", headers=member["headers"], follow_redirects=False).status_code in (303, 403) assert client.post("/admin/communities", data={"query": "x.test"}, headers=member["headers"], follow_redirects=False).status_code in (303, 403) def test_add_learns_and_shows_detail(client, admin, fake_world): row = add_ready(client, admin) assert row["origin"] == "forum.test" assert row["schema"]["feeds"][0]["type"] == "json_api" assert row["description"] == "A friendly forum about code." assert row["user_count"] == 1234 listing = client.get("/admin/communities", headers=admin["headers"]) assert listing.status_code == 200 assert listing.json()["items"][0]["slug"] == "forum-test" detail = client.get("/admin/communities/forum-test", headers=admin["headers"]) assert detail.status_code == 200 body = detail.json() assert body["feeds"][0]["pagination"] == "page" assert body["collectors_lines"] > 100 html = client.get("/admin/communities/forum-test", headers={"X-API-KEY": admin["headers"]["X-API-KEY"]}) assert html.status_code == 200 and "Learned feeds" in html.text def test_add_is_idempotent(client, admin, fake_world): add_ready(client, admin) again = client.post("/admin/communities", data={"query": "https://forum.test/api/posts"}, headers=admin["headers"]) assert again.status_code == 200 assert len(store.list()) == 1 def test_dry_run_writes_nothing_and_keeps_watermark(client, admin, fake_world): row = add_ready(client, admin) users_before = get_table("users").count() response = client.post("/admin/communities/forum-test/run", data={"dry_run": "true"}, headers=admin["headers"]) assert response.status_code == 200 run = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] == "dry_run"), None)) assert run["dry_run"] == 1 assert run["pushed"]["posts"]["created"] == 10 assert "DRY RUN" in run["log"] assert get_table("users").count() == users_before after = store.get("forum-test") assert not after["watermark"] assert after["run_count"] == 0 def test_live_run_creates_then_is_unchanged(client, admin, fake_world): row = add_ready(client, admin) client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"]) first = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] in ("ok", "partial", "failed")), None)) assert first["status"] == "ok", first["errors"] assert first["pushed"]["posts"]["created"] == 10 assert get_table("sync_links").count(origin="forum.test", kind="posts") == 10 done = store.get("forum-test") assert done["run_count"] == 1 and done["watermark"]["feeds"]["feed1"]["since"] assert done["next_run_at"] client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"]) second = wait_for(lambda: len([item for item in store.runs(row["uid"]) if item["status"] != "running"]) >= 2 and store.runs(row["uid"])[0]) assert second["pushed"].get("posts", {}).get("created", 0) == 0 def test_live_run_refused_when_sync_disabled(client, admin, fake_world): row = add_ready(client, admin) set_setting("sync_enabled", "0") try: client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"]) run = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] != "running"), None)) assert run["status"] == "failed" assert any("disabled" in error for error in run["errors"]) finally: set_setting("sync_enabled", "1") def test_update_and_delete(client, admin, fake_world): add_ready(client, admin) client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "enabled": "on"}, headers=admin["headers"]) assert store.get("forum-test")["interval_seconds"] == 7200 bad = client.post("/admin/communities/forum-test/update", data={"interval_seconds": "10"}, headers=admin["headers"]) assert bad.status_code in (400, 422) client.post("/admin/communities/forum-test/delete", headers=admin["headers"]) assert store.get("forum-test") is None assert get_table("communities").find_one(slug="forum-test")["deleted_at"] def test_unknown_community_is_404(client, admin, fake_world): assert client.get("/admin/communities/nope", headers=admin["headers"]).status_code == 404 GATED = [ ("get", "/admin/communities", {}), ("post", "/admin/communities", {"query": "https://forum.test/api/posts"}), ("get", "/admin/communities/forum-test", {}), ("post", "/admin/communities/forum-test/run", {}), ("post", "/admin/communities/forum-test/relearn", {}), ("post", "/admin/communities/forum-test/update", {"interval_seconds": "7200"}), ("post", "/admin/communities/forum-test/delete", {}), ] @pytest.mark.parametrize("method,path,data", GATED) def test_every_endpoint_refuses_guests_and_members_and_changes_nothing(client, admin, fake_world, method, path, data): add_ready(client, admin) before = store.get("forum-test") member = make_account() for headers in ({"Accept": "application/json"}, member["headers"]): response = client.request(method.upper(), path, data=data or None, headers=headers, follow_redirects=False) assert response.status_code in (303, 401, 403), (headers, response.status_code) after = store.get("forum-test") assert after is not None assert after["interval_seconds"] == before["interval_seconds"] and len(store.list()) == 1 def test_password_is_stored_but_never_returned(client, admin, fake_world): client.post( "/admin/communities", data={"query": "https://forum.test/api/posts", "login_username": "bot", "login_password": "s3cret-pw"}, headers=admin["headers"], ) row = wait_for(lambda: (found := store.get("forum-test")) and found["status"] == "ready" and found) assert row["login_password"] == "s3cret-pw" for url in ("/admin/communities", "/admin/communities/forum-test"): body = client.get(url, headers=admin["headers"]) assert "s3cret-pw" not in body.text and body.json() page = client.get("/admin/communities/forum-test", headers={"X-API-KEY": admin["headers"]["X-API-KEY"]}) assert "s3cret-pw" not in page.text and "(stored)" in page.text client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "login_username": "other"}, headers=admin["headers"]) assert store.get("forum-test")["login_password"] == "s3cret-pw" client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "clear_login": "on"}, headers=admin["headers"]) assert store.get("forum-test")["login_password"] == "" def test_devii_tools_are_admin_only_and_delete_needs_confirmation(): from devplacepy.services.devii.actions.catalog.community import COMMUNITY_ACTIONS from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED from devplacepy.docs_api.groups import community assert {action.name for action in COMMUNITY_ACTIONS} >= {"community_add", "community_run", "community_delete"} assert all(action.requires_admin for action in COMMUNITY_ACTIONS) assert "community_delete" in CONFIRM_REQUIRED assert next(action for action in COMMUNITY_ACTIONS if action.name == "community_delete").params[-1].name == "confirm" assert community.GROUP["admin"] is True def test_update_saves_sync_options_and_keeps_unsent_ones(client, admin, fake_world): add_ready(client, admin) body = {"interval_seconds": 86400, "enabled": "true", "alias": "true", "sample_percent": 40, "topics": "devlog, rant"} assert client.post("/admin/communities/forum-test/update", data=body, headers=admin["headers"]).status_code == 200 options = store.get("forum-test")["sync_options"] assert options["alias"] is True and options["sample_percent"] == 40 and options["topics"] == ["devlog", "rant"] client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "jitter_percent": 10}, headers=admin["headers"]) kept = store.get("forum-test")["sync_options"] assert kept["alias"] is True and kept["jitter_percent"] == 10 def test_update_rejects_bad_options_and_late_aliasing(client, admin, fake_world): add_ready(client, admin) bad = client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "topics": "nonsense"}, headers=admin["headers"]) assert bad.status_code == 400 get_table("sync_links").insert({"uid": "late1", "origin": "forum.test", "kind": "users", "origin_uid": "1", "local_uid": "x", "fingerprint": "f", "origin_updated_at": "", "first_synced_at": "", "synced_at": ""}) late = client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "alias": "true"}, headers=admin["headers"]) assert late.status_code == 400 assert not store.get("forum-test")["sync_options"].get("alias") def test_sync_options_are_admin_only(client, fake_world): member = make_account("Member") add_ready(client, make_account("Admin")) assert client.get("/admin/communities/forum-test", headers=member["headers"]).status_code in (401, 403) assert client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "alias": "true"}, headers=member["headers"]).status_code in (401, 403)