-
Notifications
You must be signed in to change notification settings - Fork 1
feat(tasks): wire up multi-account task sync #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
corrin
wants to merge
4
commits into
main
Choose a base branch
from
feat/wire-task-sync
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c1fbc33
feat(tasks): wire up multi-account task sync
corrin e2716e4
Remove placeholder e2e task-sync test
corrin 851a6c9
feat(tasks): enable Google/O365 accounts as task sources
corrin 875dd51
test(e2e): real Google Tasks → list sync test + runnable e2e harness
corrin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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