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
15 changes: 15 additions & 0 deletions backend/scripts/run-e2e.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Run the Playwright e2e suite.
#
# Prerequisites:
# - Full stack running: backend on :9000, frontend, ngrok (BASE_URL).
# - BASE_URL + PLAYWRIGHT_CHROME_PROFILE set in .env / .env.test.
# - Your real Chrome CLOSED (the persistent profile can't be shared).
# - For the Google Tasks sync test: a Google account connected for tasks
# (Settings → re-auth Google, then "Use for tasks").
#
# pytest-asyncio's auto mode and pytest-playwright each inject a running event loop
# that breaks sync Playwright, so we disable both plugins here.
set -euo pipefail
cd "$(dirname "$0")/.."
exec poetry run pytest tests/e2e -m e2e -p no:asyncio -p no:playwright "$@"
111 changes: 108 additions & 3 deletions backend/tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
"""Playwright e2e test fixtures — uses real Chrome profile with Google auth.

Prerequisites: Full Stack must be running (backend, frontend, ngrok).

All fixtures use sync Playwright (sync_playwright).
Prerequisites:
- Full stack running: backend on :9000, frontend, ngrok (BASE_URL).
- BASE_URL + PLAYWRIGHT_CHROME_PROFILE set (in .env / .env.test).
- Your real Chrome must be CLOSED — the persistent profile can't be shared, and a
headful launch against a locked profile crashes (SIGTRAP).
- These tests use SYNC Playwright. pytest-asyncio's auto mode and pytest-playwright
both inject a running event loop that breaks sync_playwright, so run via
`scripts/run-e2e.sh` (it passes `-p no:asyncio -p no:playwright`).

Some tests need a connected, task-enabled Google account (see `google_tasks_account`).
"""

import asyncio
import contextlib
import os
import uuid
from collections.abc import Generator
from pathlib import Path

import pytest
from dotenv import find_dotenv, load_dotenv
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from playwright.sync_api import Page, sync_playwright
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

from underway.config import get_settings
from underway.models.external_account import ExternalAccount

load_dotenv(find_dotenv()) # BASE_URL etc. — shared with the app
load_dotenv(find_dotenv(".env.test")) # test-only creds — kept out of Settings
Expand Down Expand Up @@ -91,3 +108,91 @@ def authenticated_page(

context.close()
pw.stop()


async def _load_google_tasks_account() -> tuple[str, str | None, str | None] | None:
"""Read a connected, task-enabled Google account from the app DB, or None."""
settings = get_settings()
engine = create_async_engine(settings.database_url)
try:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
result = await session.execute(
select(ExternalAccount).where(
ExternalAccount.provider == "google",
ExternalAccount.use_for_tasks.is_(True),
ExternalAccount.needs_reauth.is_(False),
)
)
account = result.scalars().first()
if account is None:
return None
return account.external_email, account.token, account.refresh_token
Comment on lines +113 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the Google sandbox account to the authenticated e2e user.

Line 126 picks the first eligible Google task account in the whole database, but the page/JWT under test can belong to a different user. As soon as multiple users have task-enabled Google accounts, this fixture starts mutating one account and asserting sync results against another.

Also applies to: 171-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/e2e/conftest.py` around lines 113 - 129, The Google
task-account lookup is too broad because `_load_google_tasks_account()` selects
the first eligible `ExternalAccount` across the entire database, which can
target the wrong user during e2e runs. Update this helper, and the call sites
around the later sync assertions, to scope the query to the authenticated e2e
user’s account identity from the active page/JWT fixture so only that user’s
Google account is mutated and verified. Keep the filtering tied to the existing
`ExternalAccount` lookup and the user context already available in the test
setup.

finally:
await engine.dispose()


class GoogleTasksSandbox:
"""Creates sentinel tasks in a real Google Tasks account and cleans them all up."""

def __init__(self, email: str, service: object) -> None:
self.email = email
self.service = service
self._created_ids: list[str] = []

def create(self, title: str) -> str:
task = self.service.tasks().insert(tasklist="@default", body={"title": title}).execute()
task_id: str = task["id"]
self._created_ids.append(task_id)
return task_id

def update_title(self, task_id: str, title: str) -> None:
self.service.tasks().patch(tasklist="@default", task=task_id, body={"title": title}).execute()

def delete(self, task_id: str) -> None:
self.service.tasks().delete(tasklist="@default", task=task_id).execute()
if task_id in self._created_ids:
self._created_ids.remove(task_id)
else:
pass

def cleanup(self) -> None:
for task_id in list(self._created_ids):
with contextlib.suppress(Exception):
self.service.tasks().delete(tasklist="@default", task=task_id).execute()


@pytest.fixture
def google_tasks(base_url: str) -> Generator[GoogleTasksSandbox]:
"""A real Google Tasks sandbox for the connected, task-enabled Google account.

Skips if no such account is connected — set one up once via Settings (re-auth Google,
then click "Use for tasks"). Any sentinel tasks created are deleted on teardown.
"""
account = asyncio.run(_load_google_tasks_account())
if account is None:
pytest.skip(
"No task-enabled Google account connected. In Settings, re-auth Google "
"(grants the tasks scope) then click 'Use for tasks' before running this test."
)
else:
pass

email, token, refresh_token = account
settings = get_settings()
creds = Credentials(
token=token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
scopes=["https://www.googleapis.com/auth/tasks"],
)
service = build("tasks", "v1", credentials=creds)
sandbox = GoogleTasksSandbox(email, service)
yield sandbox
sandbox.cleanup()


def unique_sentinel() -> str:
"""A unique task title so stale rows can never cause a false pass."""
return f"E2E-SYNC-{uuid.uuid4()}"
74 changes: 74 additions & 0 deletions backend/tests/e2e/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Real e2e test: Google Tasks → Underway combined-list sync.

Exercises the actual linkages end-to-end against a real Google Tasks account and the
running app (via ngrok): a task created in Google must appear in Underway after a sync,
an upstream edit must be reflected, and an upstream delete must be reconciled away. It
fails loudly if any of those don't happen.

Drives BOTH the UI (clicking Sync on /tasks) and the API (POST /api/tasks/sync,
GET /api/tasks). See conftest.py for prerequisites; run via scripts/run-e2e.sh.
"""

from typing import Any

import pytest
from playwright.sync_api import APIResponse, Page

from tests.e2e.conftest import GoogleTasksSandbox, unique_sentinel


def _jwt(page: Page) -> str:
token = page.evaluate("() => localStorage.getItem('token')")
assert token, "expected a JWT in localStorage after authentication"
return str(token)


def _api_get_tasks(page: Page, base_url: str, headers: dict[str, str]) -> list[dict[str, Any]]:
resp: APIResponse = page.request.get(f"{base_url}/api/tasks", headers=headers)
assert resp.ok, f"GET /api/tasks failed: {resp.status}"
tasks: list[dict[str, Any]] = resp.json()
return tasks


def _api_sync(page: Page, base_url: str, headers: dict[str, str]) -> None:
resp: APIResponse = page.request.post(f"{base_url}/api/tasks/sync", headers=headers)
assert resp.ok, f"POST /api/tasks/sync failed: {resp.status}"


@pytest.mark.e2e
def test_google_task_syncs_into_list_and_reconciles(
base_url: str,
google_tasks: GoogleTasksSandbox,
authenticated_page: Page,
) -> None:
page = authenticated_page
headers = {"Authorization": f"Bearer {_jwt(page)}"}

sentinel = unique_sentinel()
task_id = google_tasks.create(sentinel)

# --- UI: clicking Sync pulls the new Google task onto the board ---
page.goto(f"{base_url}/tasks")
page.locator("h1", has_text="Tasks").wait_for(state="visible")
page.get_by_role("button", name="Sync", exact=True).click()
page.locator(".task-card", has_text=sentinel).first.wait_for(state="visible")
assert page.locator(".error-banner").count() == 0

# --- API: it's in the combined list, tagged as a Google Tasks item ---
matches = [t for t in _api_get_tasks(page, base_url, headers) if t["title"] == sentinel]
assert len(matches) == 1, f"sentinel {sentinel!r} did not sync into /api/tasks"
assert matches[0]["provider"] == "google_tasks"

# --- upstream edit is reflected after sync (content-hash update path) ---
updated = f"{sentinel}-UPDATED"
google_tasks.update_title(task_id, updated)
_api_sync(page, base_url, headers)
titles_after_update = {t["title"] for t in _api_get_tasks(page, base_url, headers)}
assert updated in titles_after_update, "upstream title change was not synced"
assert sentinel not in titles_after_update, "stale pre-edit title lingered after sync"

# --- upstream delete is reconciled away (sync_task_deletions) ---
google_tasks.delete(task_id)
_api_sync(page, base_url, headers)
titles_after_delete = {t["title"] for t in _api_get_tasks(page, base_url, headers)}
assert updated not in titles_after_delete, "task remained after upstream deletion"
79 changes: 79 additions & 0 deletions backend/tests/integration/test_external_accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Integration tests for the external-accounts task-enablement action."""

import uuid

from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

from underway.auth.jwt import create_access_token
from underway.models.external_account import ExternalAccount
from underway.models.user import User

SECRET = "test-secret-key-at-least-32-chars!"


async def _create_user(db_session: AsyncSession, email: str = "acct@example.com") -> User:
user = User(app_login=email)
user.id = uuid.uuid4()
db_session.add(user)
await db_session.commit()
return user


def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.app_login, SECRET)
return {"Authorization": f"Bearer {token}"}


async def _create_account(db_session: AsyncSession, user: User, provider: str = "google") -> ExternalAccount:
account = ExternalAccount(
user_id=user.id,
external_email=user.app_login,
provider=provider,
token="tok",
use_for_calendar=True,
use_for_tasks=False,
)
db_session.add(account)
await db_session.commit()
await db_session.refresh(account)
return account


class TestUseForTasks:
async def test_enables_use_for_tasks(self, client: AsyncClient, db_session: AsyncSession) -> None:
user = await _create_user(db_session)
account = await _create_account(db_session, user)

response = await client.post(
f"/api/external-accounts/{account.id}/use-for-tasks",
headers=_auth_headers(user),
)
assert response.status_code == 200
assert response.json()["status"] == "ok"

# Verify via the API (the request commits in its own session, separate from db_session).
listed = await client.get("/api/external-accounts", headers=_auth_headers(user))
accounts = listed.json()
assert len(accounts) == 1
assert accounts[0]["use_for_tasks"] is True
assert accounts[0]["is_primary_tasks"] is True

async def test_other_users_account_returns_404(self, client: AsyncClient, db_session: AsyncSession) -> None:
owner = await _create_user(db_session, "owner@example.com")
intruder = await _create_user(db_session, "intruder@example.com")
account = await _create_account(db_session, owner)

response = await client.post(
f"/api/external-accounts/{account.id}/use-for-tasks",
headers=_auth_headers(intruder),
)
assert response.status_code == 404

async def test_invalid_id_returns_400(self, client: AsyncClient, db_session: AsyncSession) -> None:
user = await _create_user(db_session)
response = await client.post(
"/api/external-accounts/not-a-uuid/use-for-tasks",
headers=_auth_headers(user),
)
assert response.status_code == 400
59 changes: 59 additions & 0 deletions backend/tests/integration/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import uuid

import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

from underway.auth.jwt import create_access_token
from underway.models.external_account import ExternalAccount
from underway.models.task import Task
from underway.models.user import User
from underway.providers.task_manager import TaskManager
from underway.providers.task_provider import ProviderTask

SECRET = "test-secret-key-at-least-32-chars!"

Expand Down Expand Up @@ -259,3 +263,58 @@ async def test_sync_returns_ok(self, client: AsyncClient, db_session: AsyncSessi
user = await _create_user(db_session)
response = await client.post("/api/tasks/sync", headers=_auth_headers(user))
assert response.status_code == 200

async def test_sync_imports_provider_tasks_and_is_idempotent(
self,
client: AsyncClient,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
user = await _create_user(db_session, "synctest@example.com")
account = ExternalAccount(
user_id=user.id,
external_email="synctest@example.com",
provider="todoist",
use_for_tasks=True,
needs_reauth=False,
api_key="key",
)
db_session.add(account)
await db_session.commit()

async def fake_get_tasks(
self: TaskManager,
session: AsyncSession,
user_id: uuid.UUID,
task_user_email: str,
provider_name: str,
) -> list[ProviderTask]:
return [
ProviderTask(
id="t1",
title="Imported from Todoist",
project_id="proj1",
priority=1,
due_date=None,
status="active",
provider_task_id="t1",
)
]

monkeypatch.setattr(TaskManager, "get_tasks", fake_get_tasks)

response = await client.post("/api/tasks/sync", headers=_auth_headers(user))
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["upserted"] == 1

listed = await client.get("/api/tasks", headers=_auth_headers(user))
tasks = listed.json()
assert len(tasks) == 1
assert tasks[0]["title"] == "Imported from Todoist"
assert tasks[0]["provider"] == "todoist"

# Second sync with identical content is a no-op (content-hash unchanged).
response2 = await client.post("/api/tasks/sync", headers=_auth_headers(user))
assert response2.json()["upserted"] == 0
Loading