From bdf7d42d795361a8af4bde7c0137b45617898e45 Mon Sep 17 00:00:00 2001 From: AlexPaiva Date: Thu, 30 Jul 2026 22:55:28 +0100 Subject: [PATCH 1/2] fix(build): skip root-level test dirs when packaging examples shouldSkip gets a path relative to the example root, so the global `/test/` pattern only matches nested dirs like app/test/. A root-level test/ or tests/ has no leading slash and never matches, so next-app-router/tests ships its Playwright specs in the skill output. Adds an anchored regex for the root-level case, plus a regression test covering nested, root-level and the testing/ near-miss. --- context/skip-patterns.yaml | 5 +++++ scripts/lib/tests/skip-patterns.test.js | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/context/skip-patterns.yaml b/context/skip-patterns.yaml index 6a28b1f4..873f5c83 100644 --- a/context/skip-patterns.yaml +++ b/context/skip-patterns.yaml @@ -149,6 +149,11 @@ global: regex: # Skip .env files but allow .env.example - ^.env(?!\.example$) + # Skip a root-level test/ or tests/ dir. The `/test/` substring above only + # catches nested ones, because shouldSkip is given a path relative to the + # example root, so the root dir has no leading slash to match on. The + # separator class also covers path.relative output on Windows. + - ^tests?[/\\] # Example-specific overrides # Add patterns here to skip files only for specific examples diff --git a/scripts/lib/tests/skip-patterns.test.js b/scripts/lib/tests/skip-patterns.test.js index 55a02869..5c776782 100644 --- a/scripts/lib/tests/skip-patterns.test.js +++ b/scripts/lib/tests/skip-patterns.test.js @@ -20,6 +20,18 @@ describe('shouldSkip', () => { expect(shouldSkip('.env.example', patterns)).toBe(false); }); + it('skips a root-level test dir, not only nested ones', () => { + const patterns = mergeSkipPatterns({ + includes: ['/test/'], + regex: [new RegExp('^tests?[/\\\\]')], + allow: [], + }); + expect(shouldSkip('app/test/helpers.py', patterns)).toBe(true); + expect(shouldSkip('test/test_api.py', patterns)).toBe(true); + expect(shouldSkip('tests/example.spec.ts', patterns)).toBe(true); + expect(shouldSkip('testing/settings.py', patterns)).toBe(false); + }); + it('keeps files matching no pattern', () => { const patterns = mergeSkipPatterns(globalPatterns); expect(shouldSkip('Sources/App.swift', patterns)).toBe(false); From 0627d169c59fbffb7d452de8ecebdbfcccf27c43 Mon Sep 17 00:00:00 2001 From: AlexPaiva Date: Thu, 30 Jul 2026 23:01:09 +0100 Subject: [PATCH 2/2] test(fastapi): add PostHog setup, event, and error capture tests Covers what the example exists to demonstrate: the lifespan configures the client and flushes on shutdown, the page and API routes capture their events with the right properties, and the error endpoints hand the exception to capture_exception and surface the returned event id. The routers bind `capture` at import time, so it is patched where it is used rather than on the posthog module. The fixture is autouse, so no test can reach the network, and a temp SQLite file keeps runs isolated. pytest.ini and requirements-dev.txt are skipped for this example; the tests themselves are covered by the global root-level test dir rule. --- context/skip-patterns.yaml | 6 ++ example-apps/fastapi/README.md | 22 ++++++ example-apps/fastapi/pytest.ini | 3 + example-apps/fastapi/requirements-dev.txt | 3 + example-apps/fastapi/tests/conftest.py | 74 +++++++++++++++++++ .../fastapi/tests/test_error_tracking.py | 39 ++++++++++ example-apps/fastapi/tests/test_events.py | 50 +++++++++++++ .../fastapi/tests/test_posthog_setup.py | 32 ++++++++ 8 files changed, 229 insertions(+) create mode 100644 example-apps/fastapi/pytest.ini create mode 100644 example-apps/fastapi/requirements-dev.txt create mode 100644 example-apps/fastapi/tests/conftest.py create mode 100644 example-apps/fastapi/tests/test_error_tracking.py create mode 100644 example-apps/fastapi/tests/test_events.py create mode 100644 example-apps/fastapi/tests/test_posthog_setup.py diff --git a/context/skip-patterns.yaml b/context/skip-patterns.yaml index 873f5c83..33b4371c 100644 --- a/context/skip-patterns.yaml +++ b/context/skip-patterns.yaml @@ -174,6 +174,12 @@ examples: # The XcodeGen project spec is the whole point of this example; # rescue it from the global .yml skip - project.yml + fastapi: + includes: + # Test runner config; the tests themselves are skipped globally + - pytest.ini + - requirements-dev.txt + laravel: includes: - storage/framework diff --git a/example-apps/fastapi/README.md b/example-apps/fastapi/README.md index 2879d3bd..bd240a43 100644 --- a/example-apps/fastapi/README.md +++ b/example-apps/fastapi/README.md @@ -105,6 +105,21 @@ except Exception as e: The `/api/test-error` endpoint demonstrates manual exception capture. Use `?capture=true` to capture in PostHog, or `?capture=false` to skip tracking. +## Tests + +The tests cover what this example exists to demonstrate: PostHog is configured +on startup and flushed on shutdown, the page and API routes capture their +events with the right properties, and the error endpoints hand the exception to +`capture_exception` and surface the returned event id. + +```bash +pip install -r requirements-dev.txt +pytest +``` + +They use a temporary SQLite file and record PostHog calls rather than sending +them, so no project token or network access is needed. + ## Project Structure ``` @@ -121,9 +136,16 @@ basics/fastapi/ │ │ ├── main.py # Page routes (HTML) │ │ └── api.py # API endpoints (JSON) │ └── templates/ # Jinja2 templates +├── tests/ +│ ├── conftest.py # Fixtures: app client, login, recorded PostHog calls +│ ├── test_posthog_setup.py # Lifespan init, flush on shutdown, seeded user +│ ├── test_events.py # Login, signup and burrito event capture +│ └── test_error_tracking.py # Exception capture and returned event id ├── .env.example ├── .gitignore +├── pytest.ini ├── requirements.txt +├── requirements-dev.txt ├── README.md └── run.py # Entry point (uvicorn) ``` diff --git a/example-apps/fastapi/pytest.ini b/example-apps/fastapi/pytest.ini new file mode 100644 index 00000000..c7b23ecb --- /dev/null +++ b/example-apps/fastapi/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/example-apps/fastapi/requirements-dev.txt b/example-apps/fastapi/requirements-dev.txt new file mode 100644 index 00000000..885c36f4 --- /dev/null +++ b/example-apps/fastapi/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0.0 +httpx2>=2.0.0 diff --git a/example-apps/fastapi/tests/conftest.py b/example-apps/fastapi/tests/conftest.py new file mode 100644 index 00000000..c701b060 --- /dev/null +++ b/example-apps/fastapi/tests/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for the example's tests. + +The environment is set before importing the app. Settings are cached with +lru_cache and the database engine, the session serializer and the PostHog +config are all built at import time, so a later override has no effect. +""" + +import os +import tempfile +from pathlib import Path + +import pytest + +_db_file = Path(tempfile.gettempdir()) / "posthog-fastapi-example-test.sqlite3" +_db_file.unlink(missing_ok=True) + +os.environ["DATABASE_URL"] = f"sqlite:///{_db_file}" +os.environ["POSTHOG_PROJECT_TOKEN"] = "phc_test_token" +os.environ["SECRET_KEY"] = "test-secret-key" + +from fastapi.testclient import TestClient # noqa: E402 + +from app.main import app # noqa: E402 + +ADMIN_EMAIL = "admin@example.com" +ADMIN_PASSWORD = "admin" + + +@pytest.fixture(autouse=True) +def posthog_calls(monkeypatch): + """Record PostHog calls instead of sending them. + + Autouse so no test can reach the network. The routers bind `capture` at + import time, so it is patched where it is used rather than on the posthog + module. `capture_exception` and `flush` are read off the module, so those + are patched there. + """ + calls = {"events": [], "exceptions": [], "flushed": False} + + def capture(event, **kwargs): + calls["events"].append((event, kwargs)) + + def capture_exception(exc, **kwargs): + calls["exceptions"].append(exc) + return "test-event-id" + + def flush(): + calls["flushed"] = True + + monkeypatch.setattr("app.routers.api.capture", capture) + monkeypatch.setattr("app.routers.main.capture", capture) + monkeypatch.setattr("posthog.capture_exception", capture_exception) + monkeypatch.setattr("posthog.flush", flush) + + return calls + + +@pytest.fixture +def client(): + """Client that runs the lifespan, so PostHog is configured and the DB seeded.""" + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def logged_in_client(client): + """Client holding a session cookie for the seeded admin user.""" + response = client.post( + "/", + data={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, + follow_redirects=False, + ) + assert response.status_code == 302 + return client diff --git a/example-apps/fastapi/tests/test_error_tracking.py b/example-apps/fastapi/tests/test_error_tracking.py new file mode 100644 index 00000000..93c54aa0 --- /dev/null +++ b/example-apps/fastapi/tests/test_error_tracking.py @@ -0,0 +1,39 @@ +"""The error endpoints hand the exception to PostHog and surface its event id.""" + + +def test_test_error_captures_the_exception_and_returns_its_id(logged_in_client, posthog_calls): + response = logged_in_client.post("/api/test-error") + + assert response.status_code == 500 + assert response.json()["error_id"] == "test-event-id" + assert len(posthog_calls["exceptions"]) == 1 + + +def test_test_error_can_skip_the_capture(logged_in_client, posthog_calls): + response = logged_in_client.post("/api/test-error?capture=false") + + assert response.status_code == 500 + assert "error_id" not in response.json() + assert posthog_calls["exceptions"] == [] + + +def test_trigger_error_captures_both_the_exception_and_an_event(logged_in_client, posthog_calls): + response = logged_in_client.post("/api/trigger-error", data={"error_type": "value"}) + + assert response.status_code == 200 + assert len(posthog_calls["exceptions"]) == 1 + assert isinstance(posthog_calls["exceptions"][0], ValueError) + + events = [event for event, _ in posthog_calls["events"]] + assert "error_triggered" in events + + +def test_trigger_error_falls_back_to_a_generic_error(logged_in_client, posthog_calls): + logged_in_client.post("/api/trigger-error", data={"error_type": "not-a-real-type"}) + + types = [ + kwargs["properties"]["error_type"] + for event, kwargs in posthog_calls["events"] + if event == "error_triggered" + ] + assert types == ["generic"] diff --git a/example-apps/fastapi/tests/test_events.py b/example-apps/fastapi/tests/test_events.py new file mode 100644 index 00000000..aa6e3017 --- /dev/null +++ b/example-apps/fastapi/tests/test_events.py @@ -0,0 +1,50 @@ +"""The events this example demonstrates reach capture() with their properties.""" + + +def event_names(posthog_calls): + return [event for event, _ in posthog_calls["events"]] + + +def properties_for(posthog_calls, name): + return [kwargs["properties"] for event, kwargs in posthog_calls["events"] if event == name] + + +def test_login_captures_user_logged_in(client, posthog_calls): + client.post( + "/", + data={"email": "admin@example.com", "password": "admin"}, + follow_redirects=False, + ) + + assert "user_logged_in" in event_names(posthog_calls) + + +def test_signup_captures_user_signed_up_with_the_method(client, posthog_calls): + client.post( + "/signup", + data={ + "email": "new@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + }, + follow_redirects=False, + ) + + properties = properties_for(posthog_calls, "user_signed_up") + assert len(properties) == 1 + assert properties[0]["signup_method"] == "form" + + +def test_burrito_endpoint_captures_a_running_count(logged_in_client, posthog_calls): + logged_in_client.post("/api/burrito/consider") + logged_in_client.post("/api/burrito/consider") + + counts = [p["total_considerations"] for p in properties_for(posthog_calls, "burrito_considered")] + assert counts == [1, 2] + + +def test_burrito_endpoint_requires_authentication(client, posthog_calls): + response = client.post("/api/burrito/consider") + + assert response.status_code == 401 + assert "burrito_considered" not in event_names(posthog_calls) diff --git a/example-apps/fastapi/tests/test_posthog_setup.py b/example-apps/fastapi/tests/test_posthog_setup.py new file mode 100644 index 00000000..17fd8f3b --- /dev/null +++ b/example-apps/fastapi/tests/test_posthog_setup.py @@ -0,0 +1,32 @@ +"""PostHog is configured on startup and flushed on shutdown.""" + +import posthog +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.main import app + + +def test_lifespan_configures_the_client(client): + settings = get_settings() + + assert posthog.api_key == settings.posthog_project_token + assert posthog.host == settings.posthog_host + + +def test_lifespan_flushes_pending_events_on_shutdown(posthog_calls): + with TestClient(app): + assert posthog_calls["flushed"] is False + + assert posthog_calls["flushed"] is True + + +def test_lifespan_seeds_the_default_user(client): + response = client.post( + "/", + data={"email": "admin@example.com", "password": "admin"}, + follow_redirects=False, + ) + + assert response.status_code == 302 + assert response.headers["location"] == "/dashboard"