Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions context/skip-patterns.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -169,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
Expand Down
22 changes: 22 additions & 0 deletions example-apps/fastapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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)
```
3 changes: 3 additions & 0 deletions example-apps/fastapi/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
3 changes: 3 additions & 0 deletions example-apps/fastapi/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0.0
httpx2>=2.0.0
74 changes: 74 additions & 0 deletions example-apps/fastapi/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions example-apps/fastapi/tests/test_error_tracking.py
Original file line number Diff line number Diff line change
@@ -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"]
50 changes: 50 additions & 0 deletions example-apps/fastapi/tests/test_events.py
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 32 additions & 0 deletions example-apps/fastapi/tests/test_posthog_setup.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions scripts/lib/tests/skip-patterns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down