From c1fbc33fef412161cb9056c5898628e1f47fc35e Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Tue, 30 Jun 2026 09:45:01 +1200 Subject: [PATCH 1/4] feat(tasks): wire up multi-account task sync POST /api/tasks/sync was a stub returning "Sync not yet implemented." and the complete sync_provider_tasks() service was never called, so real provider tasks never reached the combined list. - Add sync_all_task_accounts() orchestrator: loops a user's task-enabled ExternalAccounts, maps DB provider -> task provider (reusing PROVIDER_TO_TASK), fetches via TaskManager, upserts via the existing content-hash sync, and is resilient (one bad account does not abort the batch). Returns {accounts, upserted, failed}. - Implement POST /api/tasks/sync to run it (manual "Sync now"; the frontend store already calls this and refetches). - Add task_sync_loop background job (asyncio, 15-min interval) mirroring token_refresh_loop, started/cancelled in the app lifespan; plus ExternalAccount.get_user_ids_with_task_accounts() to enumerate users. - Fix a latent runtime-breaking import in google_tasks.py (move the stubs-only googleapiclient._apis import under TYPE_CHECKING), exposed now that TaskManager is imported at runtime. - Tests: unit coverage for provider mapping / failure isolation / empty case, an integration test driving the endpoint end-to-end with idempotency, and an e2e tasks/sync test. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/e2e/test_tasks.py | 48 ++++++++ backend/tests/integration/test_tasks.py | 59 ++++++++++ backend/tests/unit/test_task_sync.py | 113 ++++++++++++++++++- backend/underway/app.py | 13 ++- backend/underway/models/external_account.py | 7 ++ backend/underway/providers/google_tasks.py | 5 +- backend/underway/providers/task_sync_loop.py | 46 ++++++++ backend/underway/services/task_sync.py | 45 ++++++++ backend/underway/viewsets/tasks.py | 13 ++- 9 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 backend/tests/e2e/test_tasks.py create mode 100644 backend/underway/providers/task_sync_loop.py diff --git a/backend/tests/e2e/test_tasks.py b/backend/tests/e2e/test_tasks.py new file mode 100644 index 0000000..4a927e9 --- /dev/null +++ b/backend/tests/e2e/test_tasks.py @@ -0,0 +1,48 @@ +"""E2E tests for the tasks page, including provider sync. + +Prerequisites (see tests/e2e/conftest.py): full stack running (backend on :9000, +frontend, ngrok), BASE_URL + PLAYWRIGHT_CHROME_PROFILE set, and at least one task +provider (e.g. Todoist) connected for the signed-in user with active tasks. +""" + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.e2e +def test_tasks_page_loads(base_url: str, authenticated_page: Page) -> None: + """Tasks page loads with its heading and a Sync button.""" + authenticated_page.goto(f"{base_url}/tasks") + heading = authenticated_page.locator("h1") + heading.wait_for(state="visible") + assert heading.inner_text() == "Tasks" + + sync_btn = authenticated_page.get_by_role("button", name="Sync", exact=True) + sync_btn.wait_for(state="visible") + + +@pytest.mark.e2e +def test_sync_imports_provider_tasks(base_url: str, authenticated_page: Page) -> None: + """Clicking Sync pulls tasks from the connected provider(s) into the board. + + Reproduces the gap that POST /api/tasks/sync used to be a stub: after wiring it to + sync_all_task_accounts, a real sync must complete without error and surface tasks. + """ + page = authenticated_page + page.goto(f"{base_url}/tasks") + page.locator("h1", has_text="Tasks").wait_for(state="visible") + + sync_btn = page.get_by_role("button", name="Sync", exact=True) + sync_btn.wait_for(state="visible") + sync_btn.click() + + # The button flips to "Syncing..." while in flight and returns to "Sync" when the + # round-trip to POST /api/tasks/sync (and the follow-up refetch) completes. + expect(sync_btn).to_be_enabled() + + # Sync must not have surfaced an error. + assert page.locator(".error-banner").count() == 0 + + # Tasks from the connected provider are now on the board. + page.locator(".task-card").first.wait_for(state="visible") + assert page.locator(".task-card").count() > 0 diff --git a/backend/tests/integration/test_tasks.py b/backend/tests/integration/test_tasks.py index 0ebc002..6cbd940 100644 --- a/backend/tests/integration/test_tasks.py +++ b/backend/tests/integration/test_tasks.py @@ -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!" @@ -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 diff --git a/backend/tests/unit/test_task_sync.py b/backend/tests/unit/test_task_sync.py index 719ab4d..fee983c 100644 --- a/backend/tests/unit/test_task_sync.py +++ b/backend/tests/unit/test_task_sync.py @@ -1,14 +1,22 @@ """Unit tests for task sync service.""" import uuid +from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +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 -from underway.services.task_sync import _compute_hash, sync_provider_tasks, sync_task_deletions +from underway.services.task_sync import ( + _compute_hash, + sync_all_task_accounts, + sync_provider_tasks, + sync_task_deletions, +) async def _create_user(session: AsyncSession, email: str = "sync@example.com") -> User: @@ -83,6 +91,109 @@ async def test_skips_unchanged_tasks(self, db_session: AsyncSession) -> None: assert count == 0 +class _FakeTaskManager(TaskManager): + """TaskManager whose get_tasks returns canned tasks per account email (no network).""" + + def __init__( + self, + tasks_by_email: dict[str, list[ProviderTask]], + fail_emails: set[str] | None = None, + ) -> None: + super().__init__() + self._tasks_by_email = tasks_by_email + self._fail_emails = fail_emails or set() + self.calls: list[tuple[str, str]] = [] + + async def get_tasks( + self, + session: AsyncSession, + user_id: UUID, + task_user_email: str, + provider_name: str, + ) -> list[ProviderTask]: + self.calls.append((task_user_email, provider_name)) + if task_user_email in self._fail_emails: + raise RuntimeError("provider boom") + else: + return self._tasks_by_email.get(task_user_email, []) + + +async def _create_task_account( + session: AsyncSession, + user: User, + provider: str, + email: str, +) -> ExternalAccount: + account = ExternalAccount( + user_id=user.id, + external_email=email, + provider=provider, + use_for_tasks=True, + needs_reauth=False, + api_key="key" if provider == "todoist" else None, + token=None if provider == "todoist" else "tok", + ) + session.add(account) + await session.flush() + return account + + +class TestSyncAllTaskAccounts: + async def test_maps_db_provider_to_task_provider(self, db_session: AsyncSession) -> None: + user = await _create_user(db_session) + await _create_task_account(db_session, user, "todoist", "td@example.com") + await _create_task_account(db_session, user, "google", "g@example.com") + await _create_task_account(db_session, user, "o365", "o@example.com") + + manager = _FakeTaskManager(tasks_by_email={}) + summary = await sync_all_task_accounts(db_session, user.id, manager) + + assert summary == {"accounts": 3, "upserted": 0, "failed": 0} + # Accounts come back ordered by provider name: google, o365, todoist. + assert manager.calls == [ + ("g@example.com", "google_tasks"), + ("o@example.com", "outlook"), + ("td@example.com", "todoist"), + ] + + async def test_syncs_provider_tasks_into_db(self, db_session: AsyncSession) -> None: + user = await _create_user(db_session) + await _create_task_account(db_session, user, "todoist", "td@example.com") + pt = _make_provider_task(title="Imported") + + manager = _FakeTaskManager(tasks_by_email={"td@example.com": [pt]}) + summary = await sync_all_task_accounts(db_session, user.id, manager) + + assert summary == {"accounts": 1, "upserted": 1, "failed": 0} + result = await db_session.execute(select(Task).where(Task.user_id == user.id)) + task = result.scalar_one() + assert task.title == "Imported" + assert task.provider == "todoist" + + async def test_one_failing_account_does_not_abort_others(self, db_session: AsyncSession) -> None: + user = await _create_user(db_session) + await _create_task_account(db_session, user, "todoist", "ok@example.com") + await _create_task_account(db_session, user, "google", "boom@example.com") + pt = _make_provider_task(title="Survives") + + manager = _FakeTaskManager( + tasks_by_email={"ok@example.com": [pt]}, + fail_emails={"boom@example.com"}, + ) + summary = await sync_all_task_accounts(db_session, user.id, manager) + + assert summary == {"accounts": 2, "upserted": 1, "failed": 1} + result = await db_session.execute(select(Task).where(Task.user_id == user.id)) + task = result.scalar_one() + assert task.title == "Survives" + + async def test_no_accounts_returns_zero_summary(self, db_session: AsyncSession) -> None: + user = await _create_user(db_session) + manager = _FakeTaskManager(tasks_by_email={}) + summary = await sync_all_task_accounts(db_session, user.id, manager) + assert summary == {"accounts": 0, "upserted": 0, "failed": 0} + + class TestSyncTaskDeletions: async def test_deletes_tasks_not_in_provider(self, db_session: AsyncSession) -> None: user = await _create_user(db_session) diff --git a/backend/underway/app.py b/backend/underway/app.py index c0e35eb..a5483e3 100644 --- a/backend/underway/app.py +++ b/backend/underway/app.py @@ -16,6 +16,7 @@ from underway.auth.jwt import create_token_auth from underway.chat.streaming import router as chat_router from underway.config import Settings, get_settings +from underway.providers.task_sync_loop import task_sync_loop from underway.providers.token_refresh import token_refresh_loop from underway.routes.auth import router as auth_router from underway.routes.auth import test_router as auth_test_router @@ -61,14 +62,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Start background tasks on startup, cancel cleanly on shutdown.""" factory = _get_or_create_factory() refresh_task = asyncio.create_task(token_refresh_loop(factory)) - logger.info("Token refresh background task started") + sync_task = asyncio.create_task(task_sync_loop(factory)) + logger.info("Token refresh and task sync background tasks started") try: yield finally: - refresh_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await refresh_task - logger.info("Token refresh background task stopped") + for background_task in (refresh_task, sync_task): + background_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await background_task + logger.info("Token refresh and task sync background tasks stopped") app = FastAPI( title="Underway", diff --git a/backend/underway/models/external_account.py b/backend/underway/models/external_account.py index 3886146..9172ca5 100644 --- a/backend/underway/models/external_account.py +++ b/backend/underway/models/external_account.py @@ -132,6 +132,13 @@ async def get_task_accounts_for_user(cls, session: AsyncSession, user_id: uuid.U result = await session.execute(stmt) return list(result.scalars().all()) + @classmethod + async def get_user_ids_with_task_accounts(cls, session: AsyncSession) -> list[uuid.UUID]: + """Return distinct user ids that have at least one task-enabled account.""" + stmt = select(cls.user_id).where(cls.use_for_tasks.is_(True)).distinct() + result = await session.execute(stmt) + return list(result.scalars().all()) + @classmethod async def get_task_account( cls, diff --git a/backend/underway/providers/google_tasks.py b/backend/underway/providers/google_tasks.py index 33d9d22..14f4277 100644 --- a/backend/underway/providers/google_tasks.py +++ b/backend/underway/providers/google_tasks.py @@ -6,11 +6,11 @@ import logging import uuid from datetime import datetime +from typing import TYPE_CHECKING from uuid import UUID from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials -from googleapiclient._apis.tasks.v1 import TasksResource from googleapiclient.discovery import build from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +18,9 @@ from underway.models.external_account import ExternalAccount from underway.providers.task_provider import ProviderTask, TaskProvider +if TYPE_CHECKING: + from googleapiclient._apis.tasks.v1 import TasksResource + logger = logging.getLogger(__name__) INSTRUCTION_TASK_TITLE = "AI Instructions" diff --git a/backend/underway/providers/task_sync_loop.py b/backend/underway/providers/task_sync_loop.py new file mode 100644 index 0000000..1bfb093 --- /dev/null +++ b/backend/underway/providers/task_sync_loop.py @@ -0,0 +1,46 @@ +"""Background loop that periodically syncs provider tasks into the combined list.""" + +from __future__ import annotations + +import asyncio +import logging + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from underway.models.external_account import ExternalAccount +from underway.providers.task_manager import TaskManager +from underway.services.task_sync import sync_all_task_accounts + +logger = logging.getLogger(__name__) + +TASK_SYNC_INTERVAL_SECONDS = 15 * 60 # 15 minutes + + +async def sync_all_users( + session_factory: async_sessionmaker[AsyncSession], + task_manager: TaskManager, +) -> None: + """Sync every user that has task-enabled accounts. One pass; resilient per user.""" + async with session_factory() as session: + user_ids = await ExternalAccount.get_user_ids_with_task_accounts(session) + + for user_id in user_ids: + try: + async with session_factory() as session: + summary = await sync_all_task_accounts(session, user_id, task_manager) + await session.commit() + logger.info("Task sync for user_id=%s: %s", user_id, summary) + except Exception: + logger.exception("Task sync loop failed for user_id=%s", user_id) + + +async def task_sync_loop(session_factory: async_sessionmaker[AsyncSession]) -> None: + """Background loop that syncs provider tasks for all users every interval.""" + logger.info("Starting task sync background loop") + task_manager = TaskManager() + while True: + try: + await sync_all_users(session_factory, task_manager) + except Exception: + logger.exception("Error in task sync loop") + await asyncio.sleep(TASK_SYNC_INTERVAL_SECONDS) diff --git a/backend/underway/services/task_sync.py b/backend/underway/services/task_sync.py index 417ccaa..5415550 100644 --- a/backend/underway/services/task_sync.py +++ b/backend/underway/services/task_sync.py @@ -11,12 +11,57 @@ from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from underway.models.external_account import PROVIDER_TO_TASK, ExternalAccount from underway.models.task import Task +from underway.providers.task_manager import TaskManager from underway.providers.task_provider import ProviderTask logger = logging.getLogger(__name__) +async def sync_all_task_accounts( + session: AsyncSession, + user_id: UUID, + task_manager: TaskManager, +) -> dict[str, int]: + """Sync every task-enabled external account for a user into the combined task list. + + Resilient: a failure on one account is logged and counted but does not abort the rest. + Returns a summary with the number of accounts processed, tasks upserted, and failures. + """ + accounts = await ExternalAccount.get_task_accounts_for_user(session, user_id) + upserted = 0 + failed = 0 + + for account in accounts: + # account.provider is the DB provider ("todoist"/"google"/"o365"); map it to the + # task-provider key ("todoist"/"google_tasks"/"outlook") used by TaskManager and the + # Task.provider column so reads and write-backs stay consistent. + task_provider = PROVIDER_TO_TASK.get(account.provider) + if task_provider is None: + logger.warning( + "Account %s has provider %r not usable for tasks; skipping", + account.external_email, + account.provider, + ) + failed += 1 + continue + else: + # Provider maps to a known task provider — proceed with sync. + pass + + try: + provider_tasks = await task_manager.get_tasks(session, user_id, account.external_email, task_provider) + upserted += await sync_provider_tasks( + session, user_id, account.external_email, task_provider, provider_tasks + ) + except Exception: + logger.exception("Sync failed for account %s (%s)", account.external_email, task_provider) + failed += 1 + + return {"accounts": len(accounts), "upserted": upserted, "failed": failed} + + async def sync_provider_tasks( session: AsyncSession, user_id: UUID, diff --git a/backend/underway/viewsets/tasks.py b/backend/underway/viewsets/tasks.py index b4637a7..08a435c 100644 --- a/backend/underway/viewsets/tasks.py +++ b/backend/underway/viewsets/tasks.py @@ -11,11 +11,13 @@ from sqlalchemy import select, update from underway.models.task import Task +from underway.providers.task_manager import TaskManager from underway.serializers.task import ( TaskMoveSerializer, TaskOrderSerializer, TaskSerializer, ) +from underway.services.task_sync import sync_all_task_accounts from underway.viewsets.base import SessionMixin @@ -127,10 +129,13 @@ async def reorder(self, request: Request, **kwargs: str) -> dict[str, str]: return {"status": "ok"} @action(methods=["post"], detail=False, url_path="sync") - async def sync(self, request: Request, **kwargs: str) -> dict[str, str]: - """POST /api/tasks/sync — trigger sync from all providers.""" - # Provider sync will be implemented in step 2.3/2.6 - return {"status": "ok", "message": "Sync not yet implemented."} + async def sync(self, request: Request, **kwargs: str) -> dict[str, object]: + """POST /api/tasks/sync — pull tasks from all connected providers into the list.""" + user = request.user + session = self._session + summary = await sync_all_task_accounts(session, user.id, TaskManager()) + await session.flush() + return {"status": "ok", **summary} @action(methods=["post"], detail=True, url_path="update-status") async def update_status(self, request: Request, pk: str = "", **kwargs: str) -> dict[str, str]: From e2716e413af0a93b62c0e9302cd727a34c65e345 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Tue, 30 Jun 2026 09:48:34 +1200 Subject: [PATCH 2/4] Remove placeholder e2e task-sync test It asserted task cards appear, which cannot pass until a task provider is actually connected (no Todoist token; calendar accounts are calendar-only). The sync endpoint is genuinely covered by the integration test (tests/integration/test_tasks.py::TestSync). A real e2e test belongs with the Todoist OAuth work, when there is data to run against. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/e2e/test_tasks.py | 48 --------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 backend/tests/e2e/test_tasks.py diff --git a/backend/tests/e2e/test_tasks.py b/backend/tests/e2e/test_tasks.py deleted file mode 100644 index 4a927e9..0000000 --- a/backend/tests/e2e/test_tasks.py +++ /dev/null @@ -1,48 +0,0 @@ -"""E2E tests for the tasks page, including provider sync. - -Prerequisites (see tests/e2e/conftest.py): full stack running (backend on :9000, -frontend, ngrok), BASE_URL + PLAYWRIGHT_CHROME_PROFILE set, and at least one task -provider (e.g. Todoist) connected for the signed-in user with active tasks. -""" - -import pytest -from playwright.sync_api import Page, expect - - -@pytest.mark.e2e -def test_tasks_page_loads(base_url: str, authenticated_page: Page) -> None: - """Tasks page loads with its heading and a Sync button.""" - authenticated_page.goto(f"{base_url}/tasks") - heading = authenticated_page.locator("h1") - heading.wait_for(state="visible") - assert heading.inner_text() == "Tasks" - - sync_btn = authenticated_page.get_by_role("button", name="Sync", exact=True) - sync_btn.wait_for(state="visible") - - -@pytest.mark.e2e -def test_sync_imports_provider_tasks(base_url: str, authenticated_page: Page) -> None: - """Clicking Sync pulls tasks from the connected provider(s) into the board. - - Reproduces the gap that POST /api/tasks/sync used to be a stub: after wiring it to - sync_all_task_accounts, a real sync must complete without error and surface tasks. - """ - page = authenticated_page - page.goto(f"{base_url}/tasks") - page.locator("h1", has_text="Tasks").wait_for(state="visible") - - sync_btn = page.get_by_role("button", name="Sync", exact=True) - sync_btn.wait_for(state="visible") - sync_btn.click() - - # The button flips to "Syncing..." while in flight and returns to "Sync" when the - # round-trip to POST /api/tasks/sync (and the follow-up refetch) completes. - expect(sync_btn).to_be_enabled() - - # Sync must not have surfaced an error. - assert page.locator(".error-banner").count() == 0 - - # Tasks from the connected provider are now on the board. - page.locator(".task-card").first.wait_for(state="visible") - assert page.locator(".task-card").count() > 0 From 851a6c9422d3ee07fcdca6fc4866c44c05beefa2 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Tue, 30 Jun 2026 10:15:15 +1200 Subject: [PATCH 3/4] feat(tasks): enable Google/O365 accounts as task sources Make a real OAuth task provider possible so sync has something to pull (the Todoist api-key path is being removed). The Google OAuth already requests the tasks scope (GOOGLE_CALENDAR_SCOPES), but nothing could mark a Google/O365 account use_for_tasks, so get_task_accounts_for_user never returned them. - Add POST /api/external-accounts/{id}/use-for-tasks (detail action on the read-only viewset) that sets use_for_tasks + is_primary_tasks, scoped to the current user. Integration tests: enable, cross-user 404, bad-id 400. - Settings UI: a "Use for tasks" button on google/o365 account cards that calls it and refetches. GoogleTaskProvider read path already works once the token carries the tasks scope and the account is task-enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration/test_external_accounts.py | 79 +++++++++++++++++++ .../underway/viewsets/external_accounts.py | 40 +++++++++- frontend/src/views/SettingsView.vue | 43 ++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 backend/tests/integration/test_external_accounts.py diff --git a/backend/tests/integration/test_external_accounts.py b/backend/tests/integration/test_external_accounts.py new file mode 100644 index 0000000..acf90df --- /dev/null +++ b/backend/tests/integration/test_external_accounts.py @@ -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 diff --git a/backend/underway/viewsets/external_accounts.py b/backend/underway/viewsets/external_accounts.py index f85e5ff..9d10002 100644 --- a/backend/underway/viewsets/external_accounts.py +++ b/backend/underway/viewsets/external_accounts.py @@ -1,7 +1,12 @@ -"""ExternalAccount viewset — read-only, filtered to current user.""" +"""ExternalAccount viewset — read-only list/detail, plus a task-enablement action.""" from __future__ import annotations +import uuid + +from fastapi import HTTPException +from fastrest.decorators import action +from fastrest.request import Request from fastrest.viewsets import ReadOnlyModelViewSet from sqlalchemy import select @@ -21,3 +26,36 @@ async def get_queryset(self) -> list[ExternalAccount]: stmt = select(ExternalAccount).where(ExternalAccount.user_id == user.id) result = await self._session.execute(stmt) return list(result.scalars().all()) + + @action(methods=["post"], detail=True, url_path="use-for-tasks") + async def use_for_tasks(self, request: Request, pk: str = "", **kwargs: str) -> dict[str, str]: + """POST /api/external-accounts/{pk}/use-for-tasks — enable an account as a task source.""" + user = request.user + session = self._session + + try: + parsed_id = uuid.UUID(pk) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid account id.") from exc + + result = await session.execute( + select(ExternalAccount).where( + ExternalAccount.id == parsed_id, + ExternalAccount.user_id == user.id, + ) + ) + account = result.scalar_one_or_none() + if account is None: + raise HTTPException(status_code=404, detail="Account not found.") + else: + account.use_for_tasks = True + await ExternalAccount.set_as_primary( + session, + external_email=account.external_email, + provider=account.provider, + user_id=user.id, + account_type="tasks", + ) + await session.flush() + + return {"status": "ok"} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index b5933a1..7feee0b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -58,6 +58,7 @@ interface ModelTestResult { const testResult = ref(null) const testing = ref(false) const connecting = ref<'google' | 'o365' | null>(null) +const enablingTasks = ref(null) async function loadSettings() { try { @@ -79,6 +80,20 @@ async function loadAccounts() { } } +async function enableTasks(accountId: string) { + if (enablingTasks.value) return + error.value = null + enablingTasks.value = accountId + try { + await api.post(`/external-accounts/${accountId}/use-for-tasks`) + await loadAccounts() + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to enable account for tasks' + } finally { + enablingTasks.value = null + } +} + async function saveSettings() { saving.value = true saved.value = false @@ -285,6 +300,14 @@ onMounted(() => { Needs re-auth Calendar Tasks + @@ -394,6 +417,26 @@ button[type='submit']:disabled { .account-status { display: flex; gap: 0.4rem; + align-items: center; +} + +.btn-enable-tasks { + font-size: 0.75rem; + padding: 0.2rem 0.6rem; + border-radius: 4px; + background: var(--color-background-soft); + border: 1px solid var(--color-border); + color: var(--color-text); + cursor: pointer; +} + +.btn-enable-tasks:hover:not(:disabled) { + border-color: var(--color-heading); +} + +.btn-enable-tasks:disabled { + opacity: 0.6; + cursor: default; } .badge { From 875dd516beaabb2e4a6af7e54bb306d06731fdc4 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Tue, 30 Jun 2026 10:19:47 +1200 Subject: [PATCH 4/4] =?UTF-8?q?test(e2e):=20real=20Google=20Tasks=20?= =?UTF-8?q?=E2=86=92=20list=20sync=20test=20+=20runnable=20e2e=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreates the deleted task-sync e2e test as a REAL one (not a placeholder): it creates a sentinel task in a live Google Tasks account, clicks Sync in the UI, and asserts the task appears in the combined list; then edits it upstream and re-syncs (assert reflected), and deletes it upstream and re-syncs (assert reconciled away). Drives both UI and API (via ngrok). Fails loudly if sync does not perform correctly. Sentinel tasks are cleaned up on teardown. - conftest: google_tasks fixture (reads the connected, task-enabled Google account from the app DB; skips with guidance if none) + GoogleTasksSandbox. - scripts/run-e2e.sh: runs the suite with -p no:asyncio -p no:playwright (both plugins inject an event loop that breaks sync Playwright) and documents the close-Chrome / connect-Google prerequisites. Not yet executed green — needs the full stack up and a Google account connected for tasks (Settings: re-auth Google, then Use for tasks). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/run-e2e.sh | 15 +++++ backend/tests/e2e/conftest.py | 111 +++++++++++++++++++++++++++++++- backend/tests/e2e/test_tasks.py | 74 +++++++++++++++++++++ 3 files changed, 197 insertions(+), 3 deletions(-) create mode 100755 backend/scripts/run-e2e.sh create mode 100644 backend/tests/e2e/test_tasks.py diff --git a/backend/scripts/run-e2e.sh b/backend/scripts/run-e2e.sh new file mode 100755 index 0000000..72a7acc --- /dev/null +++ b/backend/scripts/run-e2e.sh @@ -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 "$@" diff --git a/backend/tests/e2e/conftest.py b/backend/tests/e2e/conftest.py index aad5cc8..f4d2b6b 100644 --- a/backend/tests/e2e/conftest.py +++ b/backend/tests/e2e/conftest.py @@ -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 @@ -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 + 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()}" diff --git a/backend/tests/e2e/test_tasks.py b/backend/tests/e2e/test_tasks.py new file mode 100644 index 0000000..4e30969 --- /dev/null +++ b/backend/tests/e2e/test_tasks.py @@ -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"