diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index b892fe0..3c7532f 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -21,9 +21,26 @@ jobs: pip install -e ".[dev]" python -m playwright install chromium --with-deps - - name: Run integration tests + - name: Run integration tests with coverage + env: + COVERAGE_PROCESS_START: ${{ github.workspace }}/.coveragerc + PLAYWRIGHT_HEADLESS: "1" run: | - python -m pytest tests/ -v --tb=line -x + python -m coverage run -m pytest tests/ -p no:xdist --tb=line + + - name: Build coverage report + if: always() + run: | + python -m coverage combine + python -m coverage report + python -m coverage html + + - name: Publish coverage HTML + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: htmlcov/ - name: Upload test screenshots if: failure() diff --git a/.gitignore b/.gitignore index 0a445cb..cb5aa35 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ devplacepy/static/uploads/*.jpg devplacepy/static/uploads/*.jpeg devplacepy/static/uploads/*.gif devplacepy/static/uploads/*.webp + +# coverage +.coverage +.coverage.* +htmlcov/ diff --git a/Makefile b/Makefile index ab4b1e7..bbbe51b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LOCUST_SPAWN_RATE ?= 5 LOCUST_RUN_TIME ?= 120s DEVPLACE_RATE_LIMIT ?= 1000000 -.PHONY: install dev clean test test-headed locust locust-headless +.PHONY: install dev clean test test-headed coverage coverage-headed coverage-html locust locust-headless install: pip install -e . @@ -24,6 +24,24 @@ test: test-headed: PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x +coverage: + rm -f .coverage .coverage.* + COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc PLAYWRIGHT_HEADLESS=1 \ + python -m coverage run -m pytest tests/ -p no:xdist --tb=line + python -m coverage combine + python -m coverage report + +coverage-headed: + rm -f .coverage .coverage.* + COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc PLAYWRIGHT_HEADLESS=0 \ + python -m coverage run -m pytest tests/ -p no:xdist --tb=line + python -m coverage combine + python -m coverage report + +coverage-html: coverage + python -m coverage html + @echo "Report written to htmlcov/index.html" + locust: export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \ diff --git a/pyproject.toml b/pyproject.toml index 180f403..b7928ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ devplace = "devplacepy.cli:main" [project.optional-dependencies] -dev = ["pytest", "playwright", "pytest-xdist", "requests"] +dev = ["pytest", "playwright", "pytest-xdist", "requests", "coverage"] [build-system] requires = ["hatchling"] diff --git a/tests/conftest.py b/tests/conftest.py index ffb0295..705c196 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -229,3 +229,10 @@ def bob(browser, seeded_db): yield p, seeded_db["bob"] p.close() ctx.close() + + +@pytest.fixture(scope="session") +def local_db(): + from devplacepy.database import init_db, db + init_db() + return db diff --git a/tests/test_push.py b/tests/test_push.py index 8324d10..cb5032e 100644 --- a/tests/test_push.py +++ b/tests/test_push.py @@ -62,3 +62,30 @@ def test_service_worker_served(app_server): def test_manifest_served(app_server): r = requests.get(f"{BASE_URL}/manifest.json") assert r.status_code == 200 + + +def test_browser_base64_is_url_safe_unpadded(): + import base64 + from devplacepy import push + encoded = push.browser_base64(b"\xff\xfe\x00 hello") + assert "=" not in encoded + assert "+" not in encoded and "/" not in encoded + assert base64.urlsafe_b64decode(encoded + "==") == b"\xff\xfe\x00 hello" + + +def test_hkdf_returns_requested_length(): + from devplacepy import push + derived = push.hkdf(b"input-key-material", b"salt", b"info", 16) + assert isinstance(derived, bytes) + assert len(derived) == 16 + + +def test_public_key_standard_b64_non_empty(): + from devplacepy import push + assert push.public_key_standard_b64() + + +def test_create_notification_authorization_is_jwt(): + from devplacepy import push + token = push.create_notification_authorization("https://push.example.com/endpoint") + assert token.count(".") == 2 diff --git a/tests/test_utils.py b/tests/test_utils.py index 2e0bdbe..7cc6b0e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -104,3 +104,78 @@ def test_badge_info_unknown_fallback(): meta = badge_info("Nonexistent Badge") assert meta["icon"] assert meta["description"] == "Nonexistent Badge" + + +from devplacepy.utils import ( + safe_next, strip_html, extract_mentions, award_badge, award_xp, + check_milestone_badges, create_mention_notifications, +) +from devplacepy.database import get_table + + +def _seed_user(role="Member", xp=0, level=1): + uid = generate_uid() + username = f"ut_{uid[:8]}" + get_table("users").insert({ + "uid": uid, "username": username, "email": f"{username}@t.dev", + "role": role, "xp": xp, "level": level, + "created_at": datetime.now(timezone.utc).isoformat(), + }) + return uid, username + + +def test_safe_next_allows_internal_rejects_external(): + assert safe_next("/gists") == "/gists" + assert safe_next("//evil.com") == "/feed" + assert safe_next("http://evil.com") == "/feed" + assert safe_next(None) == "/feed" + + +def test_strip_html_removes_tags_and_unescapes(): + assert strip_html("Hi & bye") == "Hi & bye" + assert strip_html("") == "" + + +def test_extract_mentions_finds_usernames(): + mentions = extract_mentions("hi @alice and (@bob_1), mail a@b.com") + assert mentions == ["alice", "bob_1"] + + +def test_award_badge_is_idempotent(local_db): + uid, _ = _seed_user() + assert award_badge(uid, "First Post") is True + assert award_badge(uid, "First Post") is False + + +def test_award_xp_levels_up_and_notifies(local_db): + uid, _ = _seed_user(xp=95, level=1) + result = award_xp(uid, 10) + assert result["xp"] == 105 + assert result["level"] == 2 + assert result["leveled_up"] is True + assert get_table("notifications").count(user_uid=uid, type="level") == 1 + + +def test_check_milestone_badges_awards_prolific(local_db): + uid, _ = _seed_user() + posts = get_table("posts") + for index in range(10): + post_uid = generate_uid() + posts.insert({"uid": post_uid, "user_uid": uid, "slug": f"{post_uid[:8]}-p", + "title": None, "content": f"milestone post {index}", "topic": "random", + "project_uid": None, "image": None, "stars": 0, + "created_at": datetime.now(timezone.utc).isoformat()}) + awarded = check_milestone_badges(uid) + assert "Prolific" in awarded + assert get_table("badges").find_one(user_uid=uid, badge_name="Prolific") is not None + + +def test_create_mention_notifications_targets_known_users(local_db): + actor_uid, actor_name = _seed_user() + target_uid, target_name = _seed_user() + create_mention_notifications( + f"hey @{target_name} and @{actor_name} and @ghost_zzz", + actor_uid, "/posts/x", + ) + assert get_table("notifications").count(user_uid=target_uid, type="mention") == 1 + assert get_table("notifications").count(user_uid=actor_uid, type="mention") == 0