Skip to content
Merged
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
63 changes: 62 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,69 @@ jobs:
- name: Validate OpenAPI schema
run: uvx nox -s validate_openapi

e2e:
runs-on: ubuntu-latest

services:
db:
image: postgis/postgis:17-3.5
env:
POSTGRES_DB: book_corners
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
DATABASE_URL: postgis://postgres:postgres@localhost:5432/book_corners
DJANGO_SECRET_KEY: ci-secret-key
DJANGO_DEBUG: "false"

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Set up uv
uses: astral-sh/setup-uv@v7

- name: Install GIS dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends gdal-bin libgdal-dev libgeos-dev libproj-dev
sudo rm -rf /var/lib/apt/lists/*

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"

- name: Build Tailwind CSS
run: |
npm ci
npm run build:css

- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('requirements.txt') }}

- name: Run E2E browser tests
run: uvx nox -s e2e

deploy:
needs: tests
needs: [tests, e2e]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
test-results/
playwright-report/
.tox/
.nox/
.coverage
Expand Down
21 changes: 19 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ docker compose up app db tailwind
# Build CSS once
npm run build:css

# Run all tests
# Run all tests (unit + integration, excludes browser E2E)
nox -s tests

# Run browser E2E tests (Playwright + Chromium)
nox -s e2e

# Note: nox is configured to use uv for package installation

# Run a single test
Expand Down Expand Up @@ -55,6 +58,20 @@ zensical serve
zensical build
```

## Browser E2E tests (required for medium/large changes)

After implementing changes that touch templates, views, JavaScript, HTMX interactions, URL routing, or static assets, run the browser E2E suite before considering the task done:

```bash
nox -s e2e
```

This runs 18 Playwright tests covering homepage (HTMX load, pagination), map page (Leaflet init, view switching, GeoJSON fetch), submit form (autocomplete, geocoding, full submission), library detail (report/photo toggles, HTMX submit), and statistics (Chart.js rendering). External APIs (Photon, Nominatim, OSM tiles) are mocked at the browser level for determinism.

The E2E tests require PostGIS running (`docker compose up db -d`) and CSS built (`npm run build:css`). They run in a separate nox session from unit tests and both gate CI deployment.

Tests live in `tests/e2e/` with shared fixtures in `tests/e2e/conftest.py`. When adding new pages or JS interactions, add corresponding E2E test coverage.

## End-to-end smoke test (Docker + browser)

After UI/template/static changes, always run this check before considering the task done:
Expand Down Expand Up @@ -205,7 +222,7 @@ When adding a new model field or modifying queries, ensure fields used in `filte

- **Slug generation**: `Library.save()` auto-generates unique slugs from `city + address + name`, with numeric suffixes for duplicates, truncated to fit `max_length`.
- **Database config**: Uses `dj-database-url` to parse `DATABASE_URL` env var. GIS library paths (`GDAL_LIBRARY_PATH`, `GEOS_LIBRARY_PATH`) are read from environment in `config/settings.py`.
- **Test fixtures**: Shared fixtures (`user`, `admin_user`, `admin_client`) in root `conftest.py`. App-specific fixtures (`library`, `admin_library`, `admin_report`) in `libraries/tests.py`.
- **Test fixtures**: Shared fixtures (`user`, `admin_user`, `admin_client`) in root `conftest.py`. App-specific fixtures (`library`, `admin_library`, `admin_report`) in `libraries/tests.py`. E2E fixtures (`e2e_user`, `approved_libraries`, `single_library`, `mock_external_apis`, `authenticated_page`) in `tests/e2e/conftest.py`.
- **Environment**: `.envrc` (direnv) sets `DATABASE_URL`, `GDAL_LIBRARY_PATH`, and `GEOS_LIBRARY_PATH` for local macOS development. `.env.example` has Docker equivalents.
- **Seed data command**: `seed_libraries` can reset and generate realistic sample `Library` rows with geospatial points and status mix. It accepts `--reset`, `--count`, `--seed`, and `--images-dir` and will reuse local images when provided.
- **Local seed images**: `libraries_examples/` is intentionally gitignored so each developer can use their own local photo set for seeding.
Expand Down
16 changes: 8 additions & 8 deletions book-corners-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1227,15 +1227,15 @@ preparing the project for a larger dataset before adding another major surface a

##### 7.2.2 — Browser-level regression coverage

- [ ] Add a browser E2E test stack (Playwright or equivalent) alongside the current pytest suite
- [ ] Add smoke coverage for `/`, `/map/`, `/submit/`, and `/library/<slug>/`
- [ ] Cover the most JS-heavy flows:
- [ ] Map filters, view switching, and bounds-driven refresh
- [ ] Submit-form address autocomplete and marker positioning
- [x] Add a browser E2E test stack (Playwright or equivalent) alongside the current pytest suite
- [x] Add smoke coverage for `/`, `/map/`, `/submit/`, and `/library/<slug>/`
- [x] Cover the most JS-heavy flows:
- [x] Map filters, view switching, and bounds-driven refresh
- [x] Submit-form address autocomplete and marker positioning
- [ ] EXIF photo metadata prefill
- [ ] Detail-page report and community-photo submission toggles
- [ ] Run browser smoke checks in CI for at least one happy-path flow
- [ ] Keep browser tests deterministic by mocking geocoding and autocomplete calls where needed
- [x] Detail-page report and community-photo submission toggles
- [x] Run browser smoke checks in CI for at least one happy-path flow
- [x] Keep browser tests deterministic by mocking geocoding and autocomplete calls where needed

##### 7.2.3 — API/docs parity and contract validation

Expand Down
21 changes: 19 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,26 @@

@nox.session(python=PYTHON_VERSIONS)
def tests(session: nox.Session) -> None:
"""Run the test suite."""
"""Run the test suite (excludes browser E2E tests)."""
session.install("-r", "requirements.txt")
session.run("pytest", *session.posargs)
session.run("pytest", "-m", "not e2e", *session.posargs)


@nox.session(python=PYTHON_VERSIONS)
def e2e(session: nox.Session) -> None:
"""Run end-to-end browser tests with Playwright."""
session.install("-r", "requirements.txt")
session.run("playwright", "install", "chromium", "--with-deps")
session.run("python", "manage.py", "migrate", "--run-syncdb")
session.run("python", "manage.py", "collectstatic", "--noinput")
session.run(
"pytest",
"tests/e2e/",
"-m",
"e2e",
*session.posargs,
env={"DJANGO_ALLOW_ASYNC_UNSAFE": "true"},
)


@nox.session(python=PYTHON_VERSIONS)
Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
DJANGO_SETTINGS_MODULE = config.settings
pythonpath = .
python_files = tests.py test_*.py
markers =
e2e: marks tests as end-to-end browser tests (deselect with '-m "not e2e"')
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ psycopg2-binary==2.9.11
pycountry==26.2.16
pytest==9.0.2
pytest-django==4.12.0
pytest-playwright==0.7.2
sqlparse==0.5.5
structlog==25.5.0
sentry-sdk[django]==2.54.0
Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/e2e/__init__.py
Empty file.
155 changes: 155 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import io

import pytest
from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point
from django.core.files.base import ContentFile
from PIL import Image
from playwright.sync_api import Page, Route

from libraries.models import Library

User = get_user_model()

TRANSPARENT_PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)

PHOTON_MOCK_RESPONSE = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [11.2558, 43.7696]},
"properties": {
"name": "Via Rosina",
"street": "Via Rosina",
"housenumber": "15",
"city": "Firenze",
"postcode": "50123",
"country": "Italy",
"countrycode": "IT",
},
}
],
}

NOMINATIM_MOCK_RESPONSE = [
{
"lat": "43.7696",
"lon": "11.2558",
"display_name": "Via Rosina 15, 50123 Firenze, Italy",
}
]


def _make_test_image(name="test.jpg"):
"""Create a minimal JPEG image as a Django ContentFile.
Used to satisfy photo-required querysets in views."""
buf = io.BytesIO()
Image.new("RGB", (100, 100), color=(128, 128, 128)).save(buf, "JPEG")
buf.seek(0)
return ContentFile(buf.read(), name=name)


@pytest.fixture(scope="session")
def browser_context_args():
"""Set a fixed viewport size for consistent rendering across runs."""
return {"viewport": {"width": 1280, "height": 720}}


@pytest.fixture
def e2e_user(db):
"""Create a test user for E2E browser tests.
Provides a user that can log in through the real login form."""
return User.objects.create_user(
username="e2euser",
email="e2e@example.com",
password="E2eTestPass123!",
)


@pytest.fixture
def approved_libraries(e2e_user):
"""Create a set of approved libraries for browse and map tests.
Provides enough data for pagination and map marker display."""
libraries = []
for i in range(12):
lib = Library.objects.create(
name=f"Test Library {i}",
photo=_make_test_image(name=f"test_lib_{i}.jpg"),
location=Point(x=11.0 + i * 0.1, y=43.0 + i * 0.05, srid=4326),
address=f"Via Test {i}",
city="Florence",
country="IT",
status=Library.Status.APPROVED,
created_by=e2e_user,
)
libraries.append(lib)
return libraries


@pytest.fixture
def single_library(e2e_user):
"""Create a single approved library for detail page tests.
Provides a library with all fields populated for full rendering."""
return Library.objects.create(
name="Corner Library Firenze",
description="A cozy book exchange near the Duomo.",
location=Point(x=11.2558, y=43.7696, srid=4326),
address="Via Rosina 15",
city="Florence",
country="IT",
postal_code="50123",
status=Library.Status.APPROVED,
created_by=e2e_user,
)


@pytest.fixture
def mock_external_apis(page: Page):
"""Intercept external API calls to keep tests deterministic.
Mocks Photon, Nominatim, and OSM tile requests at the browser level."""

def handle_tile_route(route: Route):
"""Respond with a transparent 1x1 PNG for map tiles."""
route.fulfill(
status=200,
content_type="image/png",
body=TRANSPARENT_PNG_1X1,
)

def handle_photon_route(route: Route):
"""Respond with canned Photon address suggestions."""
route.fulfill(
status=200,
content_type="application/json",
json=PHOTON_MOCK_RESPONSE,
)

def handle_nominatim_route(route: Route):
"""Respond with canned Nominatim geocoding results."""
route.fulfill(
status=200,
content_type="application/json",
json=NOMINATIM_MOCK_RESPONSE,
)

page.route("**/*.tile.openstreetmap.org/**", handle_tile_route)
page.route("**/photon.komoot.io/api/**", handle_photon_route)
page.route("**/nominatim.openstreetmap.org/**", handle_nominatim_route)


@pytest.fixture
def authenticated_page(page: Page, live_server, e2e_user, mock_external_apis):
"""Provide a Playwright page already logged in as e2e_user.
Handles the login flow so individual tests start authenticated."""
page.goto(f"{live_server.url}/login/")
page.fill("#id_username", "e2euser")
page.fill("#id_password", "E2eTestPass123!")
page.locator("form button[type='submit']").first.click()
page.wait_for_url(f"{live_server.url}/")
return page
Loading