diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0006a50..6059193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index a0b2476..4121100 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ +test-results/ +playwright-report/ .tox/ .nox/ .coverage diff --git a/AGENTS.md b/AGENTS.md index 33daba1..eb654c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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: @@ -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. diff --git a/book-corners-plan.md b/book-corners-plan.md index 21228dc..d91a794 100644 --- a/book-corners-plan.md +++ b/book-corners-plan.md @@ -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//` -- [ ] 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//` +- [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 diff --git a/noxfile.py b/noxfile.py index 060f675..a808a20 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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) diff --git a/pytest.ini b/pytest.ini index c646881..a6a16dc 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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"') diff --git a/requirements.txt b/requirements.txt index 51d7476..6bb6693 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..986a136 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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 diff --git a/tests/e2e/test_home.py b/tests/e2e/test_home.py new file mode 100644 index 0000000..d67c135 --- /dev/null +++ b/tests/e2e/test_home.py @@ -0,0 +1,56 @@ +import pytest + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_homepage_loads_with_hero_and_nav(live_server, page, mock_external_apis): + """Verify the homepage renders with hero section and navigation links. + Confirms the base template, hero heading, and navbar are functional.""" + page.goto(f"{live_server.url}/") + + heading = page.locator("h1") + assert heading.is_visible() + + navbar = page.locator(".navbar") + assert navbar.is_visible() + + assert page.locator(".navbar-end a[href='/map/']").is_visible() + + +def test_homepage_latest_entries_htmx_load( + live_server, page, mock_external_apis, approved_libraries +): + """Verify HTMX loads the latest entries grid on page load. + Confirms the card grid populates with library cards after HTMX triggers.""" + page.goto(f"{live_server.url}/") + + latest_section = page.locator("#latest-entries") + latest_section.wait_for(state="attached", timeout=10000) + + first_card = latest_section.locator("article.card").first + first_card.wait_for(state="visible", timeout=10000) + + cards = latest_section.locator("article.card") + assert cards.count() > 0 + + +def test_homepage_load_more_pagination( + live_server, page, mock_external_apis, approved_libraries +): + """Verify the Load more button appends additional entries via HTMX. + Confirms pagination works without a full page reload.""" + page.goto(f"{live_server.url}/") + + grid = page.locator("#latest-entries-grid") + grid.wait_for(state="visible") + + initial_count = grid.locator(".card").count() + + load_more = page.locator("#latest-entries-pagination button") + if load_more.is_visible(): + load_more.click() + page.wait_for_timeout(1000) + + new_count = grid.locator(".card").count() + assert new_count > initial_count diff --git a/tests/e2e/test_library_detail.py b/tests/e2e/test_library_detail.py new file mode 100644 index 0000000..27d070b --- /dev/null +++ b/tests/e2e/test_library_detail.py @@ -0,0 +1,100 @@ +import pytest + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_detail_page_shows_library_info( + live_server, page, mock_external_apis, single_library +): + """Verify the detail page renders library name, address, and city. + Confirms all key fields from the library model are displayed.""" + page.goto(f"{live_server.url}/library/{single_library.slug}/") + + heading = page.locator("h1") + assert "Corner Library Firenze" in heading.text_content() + + body = page.locator("body") + body_text = body.text_content() + assert "Via Rosina 15" in body_text + assert "Florence" in body_text + + +def test_detail_map_loads(live_server, page, mock_external_apis, single_library): + """Verify the detail page Leaflet map initializes with a marker. + Confirms the map container gains the leaflet-container class.""" + page.goto(f"{live_server.url}/library/{single_library.slug}/") + + leaflet_map = page.locator("#library-detail-map.leaflet-container") + leaflet_map.wait_for(state="attached", timeout=10000) + assert leaflet_map.count() == 1 + + +def test_report_toggle_shows_form( + live_server, authenticated_page, single_library +): + """Verify clicking the report button reveals the report form. + Confirms aria-expanded changes and the form section becomes visible.""" + authenticated_page.goto( + f"{live_server.url}/library/{single_library.slug}/" + ) + + toggle = authenticated_page.locator("#report-form-toggle") + assert toggle.get_attribute("aria-expanded") == "false" + + toggle.click() + + form_section = authenticated_page.locator("#report-form") + form_section.wait_for(state="visible") + + assert toggle.get_attribute("aria-expanded") == "true" + assert not form_section.evaluate("el => el.classList.contains('hidden')") + + +def test_photo_toggle_shows_form( + live_server, authenticated_page, single_library +): + """Verify clicking the photo button reveals the photo upload form. + Confirms aria-expanded changes and the form section becomes visible.""" + authenticated_page.goto( + f"{live_server.url}/library/{single_library.slug}/" + ) + + toggle = authenticated_page.locator("#photo-form-toggle") + if not toggle.is_visible(): + pytest.skip("Photo form toggle not visible (no photo_form context)") + + assert toggle.get_attribute("aria-expanded") == "false" + + toggle.click() + + form_section = authenticated_page.locator("#photo-form") + form_section.wait_for(state="visible") + + assert toggle.get_attribute("aria-expanded") == "true" + assert not form_section.evaluate("el => el.classList.contains('hidden')") + + +def test_report_form_htmx_submit( + live_server, authenticated_page, single_library +): + """Verify submitting a report via HTMX shows a success message. + Confirms the full report flow works end-to-end in the browser.""" + authenticated_page.goto( + f"{live_server.url}/library/{single_library.slug}/" + ) + + authenticated_page.click("#report-form-toggle") + authenticated_page.locator("#report-form").wait_for(state="visible") + + authenticated_page.select_option("#report-form select", value="damaged") + authenticated_page.fill( + "#report-form textarea", "The book shelf is broken." + ) + + authenticated_page.click("#report-form button[type='submit']") + + success_alert = authenticated_page.locator("#report-form-panel .alert-success") + success_alert.wait_for(state="visible", timeout=10000) + + assert "report was submitted" in success_alert.text_content().lower() diff --git a/tests/e2e/test_map.py b/tests/e2e/test_map.py new file mode 100644 index 0000000..038c54b --- /dev/null +++ b/tests/e2e/test_map.py @@ -0,0 +1,78 @@ +import pytest + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_map_page_loads_with_leaflet( + live_server, page, mock_external_apis, approved_libraries +): + """Verify the map page renders and Leaflet initializes. + Confirms the map container gains the leaflet-container class.""" + page.goto(f"{live_server.url}/map/") + + map_panel = page.locator("#map-results-panel") + map_panel.wait_for(state="visible") + + leaflet_map = page.locator("#libraries-map.leaflet-container") + leaflet_map.wait_for(state="attached", timeout=10000) + assert leaflet_map.count() == 1 + + +def test_map_view_mode_switching( + live_server, page, mock_external_apis, approved_libraries +): + """Verify clicking view mode buttons toggles between map, list, and split. + Confirms panel visibility changes when switching views.""" + page.goto(f"{live_server.url}/map/") + + page.locator("#libraries-map.leaflet-container").wait_for( + state="attached", timeout=10000 + ) + + page.click("[data-view-mode='list']") + page.wait_for_timeout(500) + + list_panel = page.locator("#list-results-panel") + assert list_panel.is_visible() + + page.click("[data-view-mode='map']") + page.wait_for_timeout(500) + + map_panel = page.locator("#map-results-panel") + assert map_panel.is_visible() + + +def test_map_geojson_loads_on_render( + live_server, page, mock_external_apis, approved_libraries +): + """Verify the map fetches GeoJSON data on initial page load. + Confirms a network request to the GeoJSON endpoint completes.""" + with page.expect_response( + lambda response: "libraries.geojson" in response.url, timeout=15000 + ) as response_info: + page.goto(f"{live_server.url}/map/") + + response = response_info.value + assert response.status == 200 + + +def test_map_list_view_shows_libraries( + live_server, page, mock_external_apis, approved_libraries +): + """Verify switching to list view displays library items. + Confirms the list results container populates with content.""" + page.goto(f"{live_server.url}/map/") + + page.locator("#libraries-map.leaflet-container").wait_for( + state="attached", timeout=10000 + ) + + page.click("[data-view-mode='list']") + + list_container = page.locator("#map-list-results") + list_container.wait_for(state="visible", timeout=10000) + + page.wait_for_timeout(2000) + + assert list_container.inner_html().strip() != "" diff --git a/tests/e2e/test_stats.py b/tests/e2e/test_stats.py new file mode 100644 index 0000000..c41c291 --- /dev/null +++ b/tests/e2e/test_stats.py @@ -0,0 +1,34 @@ +import pytest + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_stats_page_renders_charts( + live_server, page, mock_external_apis, approved_libraries +): + """Verify the statistics page renders both Chart.js canvas elements. + Confirms the countries bar chart and growth line chart are present.""" + page.goto(f"{live_server.url}/stats/") + + countries_canvas = page.locator("#countries-chart") + countries_canvas.wait_for(state="attached") + assert countries_canvas.count() == 1 + + growth_canvas = page.locator("#growth-chart") + growth_canvas.wait_for(state="attached") + assert growth_canvas.count() == 1 + + +def test_stats_page_shows_totals( + live_server, page, mock_external_apis, approved_libraries +): + """Verify the stat cards display correct non-zero counts. + Confirms the total libraries stat reflects the test data.""" + page.goto(f"{live_server.url}/stats/") + + stat_values = page.locator(".stat-value") + assert stat_values.count() >= 2 + + total_text = stat_values.first.text_content().strip() + assert total_text != "0" diff --git a/tests/e2e/test_submit.py b/tests/e2e/test_submit.py new file mode 100644 index 0000000..caed1fc --- /dev/null +++ b/tests/e2e/test_submit.py @@ -0,0 +1,105 @@ +import pytest + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_submit_requires_login(live_server, page, mock_external_apis): + """Verify the submit page redirects anonymous users to login. + Confirms the login-required decorator is enforced.""" + page.goto(f"{live_server.url}/submit/") + + page.wait_for_url(f"**/login/**") + assert "/login/" in page.url + + +def test_submit_page_renders_form_and_map( + live_server, authenticated_page +): + """Verify the submit page shows the form and interactive map. + Confirms both the form fields and Leaflet map initialize.""" + authenticated_page.goto(f"{live_server.url}/submit/") + + assert authenticated_page.locator("#id_photo").is_visible() + assert authenticated_page.locator("#id_address").is_visible() + assert authenticated_page.locator("#id_city").is_visible() + assert authenticated_page.locator("#id_country").is_visible() + + leaflet_map = authenticated_page.locator( + "#submit-library-map.leaflet-container" + ) + leaflet_map.wait_for(state="attached", timeout=10000) + assert leaflet_map.count() == 1 + + +def test_submit_autocomplete_shows_suggestions( + live_server, authenticated_page +): + """Verify typing in the address field shows autocomplete suggestions. + Confirms Photon API mock returns results rendered as a dropdown.""" + authenticated_page.goto(f"{live_server.url}/submit/") + + authenticated_page.locator( + "#submit-library-map.leaflet-container" + ).wait_for(state="attached", timeout=10000) + + authenticated_page.fill("#id_address", "Via Rosina") + + suggestions = authenticated_page.locator("#address-suggestions") + suggestions.wait_for(state="visible", timeout=5000) + + items = suggestions.locator("li") + assert items.count() > 0 + + +def test_submit_form_happy_path(live_server, authenticated_page, tmp_path): + """Verify a complete library submission succeeds end-to-end. + Fills all fields, sets coordinates via geocode, and submits.""" + authenticated_page.goto(f"{live_server.url}/submit/") + + authenticated_page.locator( + "#submit-library-map.leaflet-container" + ).wait_for(state="attached", timeout=10000) + + image_path = tmp_path / "test_photo.jpg" + _create_minimal_jpeg(image_path) + authenticated_page.set_input_files("#id_photo", str(image_path)) + + authenticated_page.fill("#id_name", "Test Submission Library") + authenticated_page.fill("#id_city", "Firenze") + authenticated_page.fill("#id_address", "Via Rosina 15") + authenticated_page.fill("#id_postal_code", "50123") + + authenticated_page.evaluate("""() => { + const lat = document.getElementById('id_latitude'); + const lng = document.getElementById('id_longitude'); + if (lat) lat.value = '43.7696'; + if (lng) lng.value = '11.2558'; + }""") + + country_select = authenticated_page.locator("#id_country") + if country_select.evaluate("el => el.tomselect !== undefined"): + authenticated_page.evaluate("""() => { + const el = document.getElementById('id_country'); + if (el && el.tomselect) { + el.tomselect.setValue('IT', true); + } + }""") + else: + authenticated_page.select_option("#id_country", value="IT") + + authenticated_page.locator( + ".card-body button[type='submit']" + ).click() + + authenticated_page.wait_for_url("**/submit/confirmation/**", timeout=15000) + assert "/submit/confirmation/" in authenticated_page.url + + +def _create_minimal_jpeg(path): + """Create a minimal valid JPEG file for photo upload testing. + Produces a tiny 1x1 JPEG that passes image validation.""" + from PIL import Image + + img = Image.new("RGB", (100, 100), color=(128, 128, 128)) + img.save(str(path), "JPEG")