From d74b85b5c7df775aa896e70edf33c24d34b1b1b7 Mon Sep 17 00:00:00 2001 From: Franccesco Orozco Date: Thu, 24 Sep 2026 14:02:32 -0600 Subject: [PATCH 1/4] feat: add v1/v2 API paths with v2 backed by Bloom's GraphQL API client.v1 exposes the existing REST v1 operations (the top-level attributes remain aliases of the same objects). client.v2 exposes the GraphQL API used by the Bloom Growth web app, with sync and async operations for users, meetings, issues, headlines, to-dos, goals, milestones, and metrics with scores, including descriptions. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 37 + docs/api/v2/goal.md | 139 ++ docs/api/v2/headline.md | 112 ++ docs/api/v2/issue.md | 131 ++ docs/api/v2/meeting.md | 95 ++ docs/api/v2/metric.md | 141 +++ docs/api/v2/milestone.md | 114 ++ docs/api/v2/models.md | 152 +++ docs/api/v2/todo.md | 122 ++ docs/api/v2/user.md | 82 ++ docs/guide/v2-graphql-api.md | 346 +++++ mkdocs.yml | 13 +- src/bloomy/__init__.py | 9 +- src/bloomy/async_client.py | 50 +- src/bloomy/client.py | 52 +- src/bloomy/exceptions.py | 33 + src/bloomy/v1/__init__.py | 77 ++ src/bloomy/v2/__init__.py | 122 ++ src/bloomy/v2/base.py | 477 +++++++ src/bloomy/v2/models.py | 319 +++++ src/bloomy/v2/operations/__init__.py | 29 + src/bloomy/v2/operations/goal.py | 645 ++++++++++ src/bloomy/v2/operations/headline.py | 468 +++++++ src/bloomy/v2/operations/issue.py | 681 ++++++++++ src/bloomy/v2/operations/meeting.py | 226 ++++ src/bloomy/v2/operations/metric.py | 1169 +++++++++++++++++ src/bloomy/v2/operations/milestone.py | 419 +++++++ src/bloomy/v2/operations/todo.py | 636 ++++++++++ src/bloomy/v2/operations/user.py | 114 ++ tests/test_integration_v2_goal_milestone.py | 288 +++++ tests/test_integration_v2_headline.py | 201 +++ tests/test_integration_v2_issue.py | 255 ++++ tests/test_integration_v2_metric.py | 356 ++++++ tests/test_integration_v2_todo.py | 246 ++++ tests/test_v2_base.py | 370 ++++++ tests/test_v2_client_wiring.py | 125 ++ tests/test_v2_goal.py | 646 ++++++++++ tests/test_v2_headline.py | 886 +++++++++++++ tests/test_v2_issue.py | 441 +++++++ tests/test_v2_meeting.py | 177 +++ tests/test_v2_metric.py | 1257 +++++++++++++++++++ tests/test_v2_milestone.py | 433 +++++++ tests/test_v2_todo.py | 1015 +++++++++++++++ tests/test_v2_user.py | 124 ++ 44 files changed, 13796 insertions(+), 34 deletions(-) create mode 100644 docs/api/v2/goal.md create mode 100644 docs/api/v2/headline.md create mode 100644 docs/api/v2/issue.md create mode 100644 docs/api/v2/meeting.md create mode 100644 docs/api/v2/metric.md create mode 100644 docs/api/v2/milestone.md create mode 100644 docs/api/v2/models.md create mode 100644 docs/api/v2/todo.md create mode 100644 docs/api/v2/user.md create mode 100644 docs/guide/v2-graphql-api.md create mode 100644 src/bloomy/v1/__init__.py create mode 100644 src/bloomy/v2/__init__.py create mode 100644 src/bloomy/v2/base.py create mode 100644 src/bloomy/v2/models.py create mode 100644 src/bloomy/v2/operations/__init__.py create mode 100644 src/bloomy/v2/operations/goal.py create mode 100644 src/bloomy/v2/operations/headline.py create mode 100644 src/bloomy/v2/operations/issue.py create mode 100644 src/bloomy/v2/operations/meeting.py create mode 100644 src/bloomy/v2/operations/metric.py create mode 100644 src/bloomy/v2/operations/milestone.py create mode 100644 src/bloomy/v2/operations/todo.py create mode 100644 src/bloomy/v2/operations/user.py create mode 100644 tests/test_integration_v2_goal_milestone.py create mode 100644 tests/test_integration_v2_headline.py create mode 100644 tests/test_integration_v2_issue.py create mode 100644 tests/test_integration_v2_metric.py create mode 100644 tests/test_integration_v2_todo.py create mode 100644 tests/test_v2_base.py create mode 100644 tests/test_v2_client_wiring.py create mode 100644 tests/test_v2_goal.py create mode 100644 tests/test_v2_headline.py create mode 100644 tests/test_v2_issue.py create mode 100644 tests/test_v2_meeting.py create mode 100644 tests/test_v2_metric.py create mode 100644 tests/test_v2_milestone.py create mode 100644 tests/test_v2_todo.py create mode 100644 tests/test_v2_user.py diff --git a/README.md b/README.md index 7b7ade3..ead964f 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,43 @@ client.headline.update(headline_id=456, title="Updated headline") client.headline.delete(headline_id=456) ``` +## v2: The GraphQL API + +`client.v2` is a new namespace that talks to Bloom Growth's GraphQL API +instead of the REST API `client.v1` (and the top-level attributes above) use. +It shares the same client instance, the same API key, and covers the same +entities — users, meetings, issues, headlines, todos, goals, milestones, and +metrics — plus a few things v1 doesn't have, like milestones as their own +resource. + +```python +from bloomy import Client + +client = Client(api_key="your-api-key-here") + +# v1 (REST) — unchanged +meetings = client.meeting.list() + +# v2 (GraphQL) — new +meeting = client.v2.meeting.details(meetings[0].id) +issues = client.v2.issue.list(meeting.id) + +issue = client.v2.issue.create(meeting.id, "New issue", notes="Details") +client.v2.issue.solve(issue.id) + +goal = client.v2.goal.create(meeting.id, "Ship v2") +client.v2.milestone.create(goal.id, "Draft spec", due_date="2026-01-01") + +metric = client.v2.metric.create(meeting.id, "New Customers", goal=10) +client.v2.metric.set_score(metric.id, 12, "2026-09-21") +``` + +v2 raises `GraphQLError` (a subclass of `APIError`) instead of a plain +`APIError`, and its models (`bloomy.v2.models`) are separate classes from the +v1 ones. See the [v2 guide](https://franccesco.github.io/bloomy-python/guide/v2-graphql-api/) +for the full tour, including description/notes behavior, archive-vs-delete +semantics per entity, and metric score/week semantics. + ## Configuration The SDK supports multiple ways to provide your API key: diff --git a/docs/api/v2/goal.md b/docs/api/v2/goal.md new file mode 100644 index 0000000..37b06f0 --- /dev/null +++ b/docs/api/v2/goal.md @@ -0,0 +1,139 @@ +# Goal Operations (v2) + +GraphQL operations for managing goals (rocks). See the [v2 guide](../../guide/v2-graphql-api.md) +for how `client.v2` relates to `client.v1`/`client.goal`, and for the +description ("notes") behavior shared across v2 entities. For working with a +goal's milestones individually, see [Milestone Operations](milestone.md). + +## API Reference + +::: bloomy.v2.operations.goal.GoalOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncGoalOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.goal.AsyncGoalOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from datetime import date + from bloomy import Client + from bloomy.v2.models import GoalStatus + + with Client(api_key="your-api-key") as client: + # Create a goal (due date defaults to 90 days from today) + goal = client.v2.goal.create( + meeting_id=123, + title="Ship v2", + notes="Launch details", + ) + + # Create a goal with milestones in the same call + goal_with_milestones = client.v2.goal.create( + meeting_id=123, + title="Ship v2", + milestones=[ + {"title": "Draft spec", "due_date": date(2026, 1, 1)}, + ("Beta release", date(2026, 2, 1)), + ], + ) + + # Get goal details, including milestones + details = client.v2.goal.details(goal.id) + for milestone in details.milestones: + print(f"{milestone.title}: completed={milestone.completed}") + + # List goals for a meeting + meeting_goals = client.v2.goal.list(meeting_id=123) + + # List goals for the current user, including archived ones + my_goals = client.v2.goal.list(include_archived=True) + + # Update status using the v2 enum (ON_TRACK / OFF_TRACK / COMPLETED) + updated = client.v2.goal.update(goal.id, status=GoalStatus.OFF_TRACK) + + # Archive, then restore + client.v2.goal.archive(goal.id) + client.v2.goal.restore(goal.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + from bloomy.v2.models import GoalStatus + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a goal (due date defaults to 90 days from today) + goal = await client.v2.goal.create( + meeting_id=123, + title="Ship v2", + notes="Launch details", + ) + + # Get goal details, including milestones + details = await client.v2.goal.details(goal.id) + + # Update status using the v2 enum (ON_TRACK / OFF_TRACK / COMPLETED) + updated = await client.v2.goal.update(goal.id, status=GoalStatus.OFF_TRACK) + + # Archive, then restore + await client.v2.goal.archive(goal.id) + await client.v2.goal.restore(goal.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `list()` | List goals for a meeting or a user | `meeting_id`, `user_id`, `include_archived` | `list[Goal]` | +| `details()` | Get a goal, including its milestones | `goal_id` | `Goal` | +| `create()` | Create a goal, optionally with milestones | `meeting_id`, `title`, `user_id`, `due_date`, `status`, `notes`, `milestones` | `Goal` | +| `update()` | Update a goal | `goal_id`, `title`, `status`, `due_date`, `user_id`, `notes` | `Goal` | +| `archive()` | Archive a goal | `goal_id` | `Goal` | +| `restore()` | Restore an archived goal | `goal_id` | `Goal` | + +!!! note "`GoalStatus` is different from the v1 enum" + `bloomy.v2.models.GoalStatus` (`ON_TRACK` / `OFF_TRACK` / `COMPLETED`) is a + distinct enum from `bloomy.GoalStatus` (v1's `on` / `off` / `complete`). + Import the v2 one from `bloomy.v2.models` when working with `client.v2.goal`. + +!!! note "`milestones=` on `create()`" + Each item is either `{"title": ..., "due_date": ..., "completed": ...}` + (`completed` optional, defaults to `False`) or a `(title, due_date)` / + `(title, due_date, completed)` tuple. To add milestones to an existing + goal afterwards, use [`client.v2.milestone.create()`](milestone.md) instead. + +!!! warning "`archive()` can raise `GraphQLError`" + The underlying `EditGoal(archived: true)` mutation can report success + without archiving anything, due to a server-side bug. `archive()` re-reads + the goal after the edit and raises `GraphQLError` if it is still not + archived, so a caught exception here reliably means the archive did not + take effect — it is safe to retry. + +!!! note "Update requirements" + At least one field must be provided to `update()`, or it raises `ValueError`. diff --git a/docs/api/v2/headline.md b/docs/api/v2/headline.md new file mode 100644 index 0000000..4f79eb9 --- /dev/null +++ b/docs/api/v2/headline.md @@ -0,0 +1,112 @@ +# Headline Operations (v2) + +GraphQL operations for managing headlines. See the [v2 guide](../../guide/v2-graphql-api.md) +for how `client.v2` relates to `client.v1`/`client.headline`, and for the +description ("notes") behavior shared across v2 entities. + +## API Reference + +::: bloomy.v2.operations.headline.HeadlineOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncHeadlineOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.headline.AsyncHeadlineOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Create a new headline + headline = client.v2.headline.create( + meeting_id=123, + title="Product launch successful", + notes="Exceeded targets by 15%", + ) + + # Get headline details + details = client.v2.headline.details(headline.id) + + # List headlines for a meeting (open only, by default) + meeting_headlines = client.v2.headline.list(meeting_id=123) + + # Include archived headlines too + all_headlines = client.v2.headline.list(meeting_id=123, include_archived=True) + + # List headlines for the current user instead + my_headlines = client.v2.headline.list() + + # Update the title + updated = client.v2.headline.update(headline.id, title="Updated title") + + # Archive, then restore + client.v2.headline.archive(headline.id) + client.v2.headline.restore(headline.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a new headline + headline = await client.v2.headline.create( + meeting_id=123, + title="Product launch successful", + notes="Exceeded targets by 15%", + ) + + # List headlines for a meeting (open only, by default) + meeting_headlines = await client.v2.headline.list(meeting_id=123) + + # Update the title + updated = await client.v2.headline.update(headline.id, title="Updated title") + + # Archive, then restore + await client.v2.headline.archive(headline.id) + await client.v2.headline.restore(headline.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get a headline | `headline_id` | `Headline` | +| `list()` | List headlines for a meeting or a user | `meeting_id`, `user_id`, `include_archived` | `list[Headline]` | +| `create()` | Create a headline | `meeting_id`, `title`, `user_id`, `notes` | `Headline` | +| `update()` | Update a headline | `headline_id`, `title`, `user_id`, `notes` | `Headline` | +| `archive()` | Archive a headline | `headline_id` | `Headline` | +| `restore()` | Restore an archived headline | `headline_id` | `Headline` | + +!!! note "Filtering" + `list()` accepts either `meeting_id` or `user_id`, not both. Passing both + raises `ValueError`. If neither is given, it defaults to the current user. + +!!! note "Update requirements" + At least one field must be provided to `update()`, or it raises `ValueError`. diff --git a/docs/api/v2/issue.md b/docs/api/v2/issue.md new file mode 100644 index 0000000..18200db --- /dev/null +++ b/docs/api/v2/issue.md @@ -0,0 +1,131 @@ +# Issue Operations (v2) + +GraphQL operations for managing issues. See the [v2 guide](../../guide/v2-graphql-api.md) +for how `client.v2` relates to `client.v1`/`client.issue`, and for the +description ("notes") and archive/restore behavior shared across v2 entities. + +## API Reference + +::: bloomy.v2.operations.issue.IssueOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncIssueOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.issue.AsyncIssueOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Create a new issue + issue = client.v2.issue.create( + meeting_id=123, + title="Server performance degradation", + notes="Response times increased by 50% during peak hours", + ) + + # Get issue details + details = client.v2.issue.details(issue.id) + print(f"Owner: {details.owner.full_name if details.owner else 'Unassigned'}") + + # List short-term issues for a meeting + meeting_issues = client.v2.issue.list(meeting_id=123) + + # Include recently solved and archived issues too + all_issues = client.v2.issue.list( + meeting_id=123, include_solved=True, include_archived=True + ) + + # List long-term (department plan) issues instead + long_term_issues = client.v2.issue.list(meeting_id=123, long_term=True) + + # Update an issue + updated = client.v2.issue.update(issue.id, title="Updated title") + + # Solve, then reopen + solved = client.v2.issue.solve(issue.id) + reopened = client.v2.issue.reopen(issue.id) + + # Archive, then restore + client.v2.issue.archive(issue.id) + client.v2.issue.restore(issue.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a new issue + issue = await client.v2.issue.create( + meeting_id=123, + title="Server performance degradation", + notes="Response times increased by 50% during peak hours", + ) + + # Get issue details + details = await client.v2.issue.details(issue.id) + + # List short-term issues for a meeting + meeting_issues = await client.v2.issue.list(meeting_id=123) + + # Update an issue + updated = await client.v2.issue.update(issue.id, title="Updated title") + + # Solve, then reopen + solved = await client.v2.issue.solve(issue.id) + reopened = await client.v2.issue.reopen(issue.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get an issue | `issue_id` | `Issue` | +| `list()` | List issues for a meeting | `meeting_id`, `long_term`, `include_solved`, `include_archived` | `list[Issue]` | +| `create()` | Create an issue | `meeting_id`, `title`, `user_id`, `notes`, `long_term` | `Issue` | +| `update()` | Update an issue | `issue_id`, `title`, `user_id`, `notes`, `long_term`, `meeting_id` | `Issue` | +| `solve()` | Mark an issue as solved | `issue_id` | `Issue` | +| `reopen()` | Reopen a solved issue | `issue_id` | `Issue` | +| `archive()` | Archive an issue | `issue_id` | `Issue` | +| `restore()` | Restore an archived (short-term) issue | `issue_id` | `Issue` | + +!!! note "Long-term issues" + Setting `long_term=True` on `create()` or `update()` moves an issue to the + long-term (department plan) list, and `list(long_term=True)` returns that + list. `include_solved` and `include_archived` have no effect in that mode. + The API stores long-term issues with an archived flag, but the SDK reports + `archived=False` for them because the web app shows them on the Long-Term + tab. `archive()` removes a long-term issue from that list, and `restore()` + brings it back to the short-term list. Use + `update(issue_id, long_term=False)` to move an issue back to short term + without archiving it. + +!!! note "Update requirements" + At least one field must be provided to `update()`, or it raises `ValueError`. diff --git a/docs/api/v2/meeting.md b/docs/api/v2/meeting.md new file mode 100644 index 0000000..3a311d2 --- /dev/null +++ b/docs/api/v2/meeting.md @@ -0,0 +1,95 @@ +# Meeting Operations (v2) + +GraphQL operations for reading meetings and their attendees. See the +[v2 guide](../../guide/v2-graphql-api.md) for how `client.v2` relates to +`client.v1`/`client.meeting`. + +## API Reference + +::: bloomy.v2.operations.meeting.MeetingOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncMeetingOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.meeting.AsyncMeetingOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # List meetings the current user attends + meetings = client.v2.meeting.list() + for meeting in meetings: + print(f"{meeting.name} (ID: {meeting.id})") + + # List meetings for a specific user + meetings = client.v2.meeting.list(user_id=456) + + # Get meeting details, including attendees + meeting = client.v2.meeting.details(meetings[0].id) + print(f"Attendees: {len(meeting.attendees)}") + + # List just the attendees + attendees = client.v2.meeting.attendees(meeting.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # List meetings the current user attends + meetings = await client.v2.meeting.list() + for meeting in meetings: + print(f"{meeting.name} (ID: {meeting.id})") + + # List meetings for a specific user + meetings = await client.v2.meeting.list(user_id=456) + + # Get meeting details, including attendees + meeting = await client.v2.meeting.details(meetings[0].id) + print(f"Attendees: {len(meeting.attendees)}") + + # List just the attendees + attendees = await client.v2.meeting.attendees(meeting.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `list()` | List meetings a user attends, ordered by name | `user_id` (optional, defaults to current user) | `list[MeetingListItem]` | +| `details()` | Get a meeting, including its attendees | `meeting_id` | `Meeting` | +| `attendees()` | List a meeting's attendees, ordered by full name | `meeting_id` | `list[User]` | + +!!! note "Scope" + `client.v2.meeting` is read-only. There is no `client.v2` equivalent of + `client.v1.meeting.create()`, `delete()`, `issues()`, `todos()`, or + `metrics()` — use `client.meeting` (v1), or query `client.v2.issue`, + `client.v2.todo`, and `client.v2.metric` directly with a `meeting_id`. diff --git a/docs/api/v2/metric.md b/docs/api/v2/metric.md new file mode 100644 index 0000000..3c27f7f --- /dev/null +++ b/docs/api/v2/metric.md @@ -0,0 +1,141 @@ +# Metric Operations (v2) + +GraphQL operations for managing metrics (KPIs) and their scores. See the +[v2 guide](../../guide/v2-graphql-api.md) for how `client.v2` relates to +`client.v1`/`client.scorecard`, and for +[metric score/week semantics](../../guide/v2-graphql-api.md#metric-scores-and-week-semantics). + +## API Reference + +::: bloomy.v2.operations.metric.MetricOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncMetricOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.metric.AsyncMetricOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from datetime import date + from bloomy import Client + from bloomy.v2.models import MetricRule, MetricUnit, MetricFrequency + + with Client(api_key="your-api-key") as client: + # Create a metric with a single goal value + metric = client.v2.metric.create( + meeting_id=123, + title="New Customers", + goal=10, + units=MetricUnit.NONE, + rule=MetricRule.GREATER_THAN, + frequency=MetricFrequency.WEEKLY, + ) + + # Create a BETWEEN-rule metric (min_goal/max_goal instead of goal) + team_size = client.v2.metric.create( + meeting_id=123, + title="Team Size", + min_goal=5, + max_goal=10, + rule=MetricRule.BETWEEN, + ) + + # List metrics for a meeting + meeting_metrics = client.v2.metric.list(meeting_id=123) + + # Filter by frequency + weekly_metrics = client.v2.metric.list(meeting_id=123, frequency=MetricFrequency.WEEKLY) + + # Set this week's score + score = client.v2.metric.set_score(metric.id, 12, date(2026, 9, 21)) + + # Update the score's value and/or note + updated_score = client.v2.metric.update_score(metric.id, score.id, value=14) + + # List scores, most recent first + scores = client.v2.metric.scores(metric.id) + + # Clear a score back to an empty placeholder + client.v2.metric.clear_score(metric.id, score.id) + + # Archive (there is no restore for metrics) + client.v2.metric.archive(metric.id) + ``` + +=== "Async" + + ```python + import asyncio + from datetime import date + from bloomy import AsyncClient + from bloomy.v2.models import MetricRule + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a metric with a single goal value + metric = await client.v2.metric.create( + meeting_id=123, title="New Customers", goal=10 + ) + + # Set this week's score + score = await client.v2.metric.set_score(metric.id, 12, date(2026, 9, 21)) + + # List scores, most recent first + scores = await client.v2.metric.scores(metric.id) + + # Archive (there is no restore for metrics) + await client.v2.metric.archive(metric.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get a metric | `metric_id` | `Metric` | +| `list()` | List metrics for a meeting or a user | `meeting_id`, `user_id`, `frequency` | `list[Metric]` | +| `create()` | Create a metric | `meeting_id`, `title`, `user_id`, `goal`, `units`, `rule`, `frequency`, `min_goal`, `max_goal`, `notes` | `Metric` | +| `update()` | Update a metric | `metric_id`, `title`, `user_id`, `goal`, `min_goal`, `max_goal`, `units`, `rule`, `notes` | `Metric` | +| `archive()` | Archive a metric (no `restore()`) | `metric_id` | `Metric` | +| `scores()` | List a metric's scores | `metric_id`, `start`, `end`, `include_empty` | `list[MetricScore]` | +| `set_score()` | Set a score for a given time | `metric_id`, `value`, `timestamp` | `MetricScore` | +| `update_score()` | Update an existing score's value/note | `metric_id`, `score_id`, `value`, `notes` | `MetricScore` | +| `clear_score()` | Clear a score's value | `metric_id`, `score_id` | `MetricScore` | + +!!! warning "No `restore()`" + Archiving a metric is effectively permanent through this SDK — the + GraphQL API does not support un-archiving one. See + [Archive vs. delete semantics](../../guide/v2-graphql-api.md#archive-vs-delete-semantics-per-entity) + in the v2 guide. + +!!! note "`goal` vs. `min_goal`/`max_goal`" + `goal` is for every `rule` except `MetricRule.BETWEEN`; `min_goal`/ + `max_goal` are for `BETWEEN` only. Combining them across that split raises + `ValueError` locally, before any request is sent. + +!!! note "Filtering and update requirements" + `list()` accepts either `meeting_id` or `user_id`, not both — passing + both raises `ValueError`. `update()` requires at least one field, or it + raises `ValueError`. `update_score()` requires at least one of `value` or + `notes`. diff --git a/docs/api/v2/milestone.md b/docs/api/v2/milestone.md new file mode 100644 index 0000000..c33b2cb --- /dev/null +++ b/docs/api/v2/milestone.md @@ -0,0 +1,114 @@ +# Milestone Operations (v2) + +GraphQL operations for managing a goal's milestones. There is no v1 +equivalent — milestones are new in the v2 API. See the +[v2 guide](../../guide/v2-graphql-api.md) for archive/delete semantics across +v2 entities, and [Goal Operations](goal.md) for creating milestones alongside +a goal. + +## API Reference + +::: bloomy.v2.operations.milestone.MilestoneOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncMilestoneOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.milestone.AsyncMilestoneOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from datetime import date + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Create a milestone on an existing goal + milestone = client.v2.milestone.create( + goal_id=5265048, + title="Draft spec", + due_date=date(2026, 1, 1), + ) + + # List a goal's milestones + milestones = client.v2.milestone.list(goal_id=5265048) + + # Update it (goal_id is required to re-read it afterward) + updated = client.v2.milestone.update( + milestone.id, milestone.goal_id, title="Draft spec v2" + ) + + # Mark it complete + completed = client.v2.milestone.complete(milestone.id, milestone.goal_id) + + # Delete it (no goal_id needed, and there is no restore) + client.v2.milestone.delete(milestone.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + from datetime import date + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a milestone on an existing goal + milestone = await client.v2.milestone.create( + goal_id=5265048, + title="Draft spec", + due_date=date(2026, 1, 1), + ) + + # Mark it complete + completed = await client.v2.milestone.complete(milestone.id, milestone.goal_id) + + # Delete it + await client.v2.milestone.delete(milestone.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `list()` | List a goal's milestones, ordered by due date | `goal_id` | `list[Milestone]` | +| `create()` | Create a milestone on a goal | `goal_id`, `title`, `due_date`, `completed` | `Milestone` | +| `update()` | Update a milestone | `milestone_id`, `goal_id`, `title`, `due_date`, `completed` | `Milestone` | +| `complete()` | Mark a milestone as completed | `milestone_id`, `goal_id` | `Milestone` | +| `delete()` | Delete a milestone | `milestone_id` | `None` | + +!!! note "Why `update()` and `complete()` need `goal_id`" + There is no root GraphQL query to read a single milestone by id — a + milestone can only be read through its parent goal + (`goal(id){ milestones }`). `update()` and `complete()` need to re-read + the milestone after editing it, so both require `goal_id` in addition to + `milestone_id`. `delete()` does not, since deleting one does not require + reading it back. + +!!! note "No archive/restore — only delete" + Milestones do not archive; `delete()` permanently removes one + (soft-deleted server-side, but with no SDK-exposed way to bring it back). + +!!! note "Update requirements" + At least one field must be provided to `update()`, or it raises `ValueError`. diff --git a/docs/api/v2/models.md b/docs/api/v2/models.md new file mode 100644 index 0000000..069c4de --- /dev/null +++ b/docs/api/v2/models.md @@ -0,0 +1,152 @@ +# v2 Models + +Pydantic models returned by `client.v2` operations. See the +[v2 guide](../../guide/v2-graphql-api.md) for how these fit into the wider +SDK. + +## Conventions + +These conventions apply uniformly across every v2 model: + +- **Aliases are camelCase**, matching the GraphQL field names verbatim (e.g. + `Field(alias="dateCreated")`) — unlike v1's PascalCase REST aliases. +- **Owners are nested refs**: an entity's assignee is exposed as + `owner: UserRef | None` (aliased from the GraphQL `assignee` field), never + flattened into `owner_id`/`owner_name`. `owner` is `None` when unowned. +- **Related entities are nested refs** too: an issue's parent meeting is + `meeting: MeetingRef | None`, not flattened into `meeting_id`/`meeting_name`. +- **Datetimes use the `_date` suffix** (`created_date`, `due_date`, + `completed_date`, `archived_date`) and are always timezone-aware UTC + `datetime` objects, converted from the API's unix-seconds floats. +- **Notes**: description text is exposed as `notes: str | None`, computed from + the underlying note pad. `notes_id` is kept alongside it — see + [Description ("notes") behavior](../../guide/v2-graphql-api.md#description-notes-behavior) + in the v2 guide. + +## Reference types + +::: bloomy.v2.models.UserRef + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.MeetingRef + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Users + +::: bloomy.v2.models.User + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Meetings + +::: bloomy.v2.models.MeetingListItem + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.Meeting + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Issues + +::: bloomy.v2.models.Issue + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Headlines + +::: bloomy.v2.models.Headline + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## To-dos + +::: bloomy.v2.models.Todo + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Goals and milestones + +::: bloomy.v2.models.GoalStatus + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.Milestone + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.Goal + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Metrics + +::: bloomy.v2.models.MetricUnit + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.MetricRule + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.MetricFrequency + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.Metric + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +::: bloomy.v2.models.MetricScore + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 diff --git a/docs/api/v2/todo.md b/docs/api/v2/todo.md new file mode 100644 index 0000000..abd4aa6 --- /dev/null +++ b/docs/api/v2/todo.md @@ -0,0 +1,122 @@ +# To-do Operations (v2) + +GraphQL operations for managing to-dos. See the [v2 guide](../../guide/v2-graphql-api.md) +for how `client.v2` relates to `client.v1`/`client.todo`, and for the +description ("notes") behavior shared across v2 entities. + +## API Reference + +::: bloomy.v2.operations.todo.TodoOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncTodoOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.todo.AsyncTodoOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Create a to-do in a meeting (due date defaults to 7 days from today) + todo = client.v2.todo.create( + title="Review Q4 metrics", + meeting_id=123, + notes="Cross-check against last quarter", + ) + + # Create a personal to-do (no meeting_id) + personal_todo = client.v2.todo.create(title="Renew certification") + + # Get to-do details + details = client.v2.todo.details(todo.id) + + # List open to-dos for a meeting + meeting_todos = client.v2.todo.list(meeting_id=123) + + # Include completed and archived to-dos too + all_todos = client.v2.todo.list( + meeting_id=123, include_completed=True, include_archived=True + ) + + # Update title and due date + updated = client.v2.todo.update(todo.id, title="New title", due_date="2026-01-15") + + # Complete, then reopen + client.v2.todo.complete(todo.id) + client.v2.todo.reopen(todo.id) + + # Archive, then restore + client.v2.todo.archive(todo.id) + client.v2.todo.restore(todo.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Create a to-do in a meeting (due date defaults to 7 days from today) + todo = await client.v2.todo.create( + title="Review Q4 metrics", + meeting_id=123, + notes="Cross-check against last quarter", + ) + + # List open to-dos for a meeting + meeting_todos = await client.v2.todo.list(meeting_id=123) + + # Complete, then reopen + await client.v2.todo.complete(todo.id) + await client.v2.todo.reopen(todo.id) + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get a to-do | `todo_id` | `Todo` | +| `list()` | List to-dos for a meeting or a user | `meeting_id`, `user_id`, `include_completed`, `include_archived` | `list[Todo]` | +| `create()` | Create a to-do | `title`, `meeting_id`, `user_id`, `due_date`, `notes` | `Todo` | +| `update()` | Update a to-do | `todo_id`, `title`, `due_date`, `user_id`, `notes` | `Todo` | +| `complete()` | Mark a to-do as complete | `todo_id` | `Todo` | +| `reopen()` | Reopen a completed to-do | `todo_id` | `Todo` | +| `archive()` | Archive a to-do | `todo_id` | `Todo` | +| `restore()` | Restore an archived to-do | `todo_id` | `Todo` | + +!!! note "Filtering" + `list()` accepts either `meeting_id` or `user_id`, not both. Passing both + raises `ValueError`. If neither is given, it defaults to the current user. + `include_archived` has no effect when listing by `user_id`: the + underlying query excludes archived to-dos unconditionally, server-side. + Omit `meeting_id` on `create()` for a personal to-do — it is always + assigned to the caller regardless of `user_id`. + +!!! note "Update requirements" + At least one field must be provided to `update()`, or it raises + `ValueError`. A due date can be changed but not cleared. diff --git a/docs/api/v2/user.md b/docs/api/v2/user.md new file mode 100644 index 0000000..3df5176 --- /dev/null +++ b/docs/api/v2/user.md @@ -0,0 +1,82 @@ +# User Operations (v2) + +GraphQL operations for reading users. See the [v2 guide](../../guide/v2-graphql-api.md) +for how `client.v2` relates to `client.v1`/`client.user`. + +## API Reference + +::: bloomy.v2.operations.user.UserOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + +## Async Version + +The async version `AsyncUserOperations` provides the same methods as above, but with async/await support: + +::: bloomy.v2.operations.user.AsyncUserOperations + options: + show_source: false + show_root_heading: true + show_root_full_path: false + heading_level: 3 + members: false + show_bases: false + show_inheritance_diagram: false + +!!! info "Async Usage" + All methods have the same parameters and return types as their sync counterparts. Simply add `await` before each method call. + +## Usage Examples + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # Get current user details + me = client.v2.user.details() + print(f"Name: {me.full_name}") + + # Get a specific user + user = client.v2.user.details(user_id=456) + + # List every user in the organization + all_users = client.v2.user.list() + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # Get current user details + me = await client.v2.user.details() + print(f"Name: {me.full_name}") + + # Get a specific user + user = await client.v2.user.details(user_id=456) + + # List every user in the organization + all_users = await client.v2.user.list() + + asyncio.run(main()) + ``` + +## Available Methods + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `details()` | Get a user | `user_id` (optional, defaults to current user) | `User` | +| `list()` | List every user in the organization, ordered by full name | None | `list[User]` | + +!!! note "Scope" + `client.v2.user` is read-only. There is no `client.v2` equivalent of + `client.v1.user.search()`, `direct_reports()`, or `positions()` — use + `client.user` (v1) for those. diff --git a/docs/guide/v2-graphql-api.md b/docs/guide/v2-graphql-api.md new file mode 100644 index 0000000..4ac2867 --- /dev/null +++ b/docs/guide/v2-graphql-api.md @@ -0,0 +1,346 @@ +# v2: The GraphQL API + +The SDK talks to two different Bloom Growth APIs, both reachable from the same +`Client`/`AsyncClient` instance: + +- **`client.v1`** — the REST API at `/api/v1`. This is the API the SDK has + always exposed. Nothing about it changed. +- **`client.v2`** — the GraphQL API at `/graphql/`. It covers the same + entities (users, meetings, issues, headlines, todos, goals, milestones, + metrics) through a different transport, with some behavior that does not + match v1 one-to-one. + +This guide explains how the two fit together, tours every `client.v2` +namespace and method, and covers the behavior that is specific to v2: +description ("notes") handling, archive/delete semantics per entity, metric +score/week semantics, and `GraphQLError`. + +## v1 vs v2 + +### v1 (REST, unchanged) + +`client.v1.user`, `client.v1.meeting`, `client.v1.todo`, `client.v1.goal`, +`client.v1.scorecard`, `client.v1.issue`, and `client.v1.headline` are the +same operation classes the SDK has always had. The top-level attributes you +may already be using — `client.user`, `client.meeting`, `client.todo`, and so +on — are aliases pointing at the exact same objects as `client.v1.*`, kept for +backward compatibility: + +```python +from bloomy import Client + +client = Client(api_key="your-api-key") + +client.meeting.list() is client.v1.meeting.list # same bound method, same instance +client.meeting is client.v1.meeting # True +``` + +Existing code that uses `client.user`, `client.meeting.create()`, and so on +keeps working exactly as before. See the [Basic Usage](usage.md) guide and the +[v1 API Reference](../api/operations/users.md) for those operations. + +### v2 (GraphQL, new) + +`client.v2.user`, `client.v2.meeting`, `client.v2.issue`, `client.v2.headline`, +`client.v2.todo`, `client.v2.goal`, `client.v2.milestone`, and +`client.v2.metric` are new operation classes that send GraphQL documents +instead of REST requests. They: + +- Use the **same API key** and the **same underlying HTTP client** as v1 — no + separate authentication step. A v2 request posts to an absolute URL, which + overrides the client's `base_url` for that one request. +- Default their GraphQL endpoint to the scheme and host of `base_url` plus + `/graphql/` (e.g. `https://app.bloomgrowth.com/graphql/`). Pass + `graphql_url` explicitly if your GraphQL endpoint lives somewhere else: + +```python +from bloomy import Client + +client = Client( + api_key="your-api-key", + graphql_url="https://app.bloomgrowth.com/graphql/", +) + +client.v2.meeting.details(123) +``` + +- Return the **same kind of Pydantic models** as v1 (typed, IDE-friendly), but + the models themselves are different classes (`bloomy.v2.models.Issue`, not + `bloomy.models.IssueDetails`) with different fields. See + [Models](../api/v2/models.md) for the field-naming conventions. +- Raise `GraphQLError` (a subclass of `APIError`) instead of a plain + `APIError` for failed requests. See [GraphQLError](#graphqlerror) below. + +=== "Sync" + + ```python + from bloomy import Client + + with Client(api_key="your-api-key") as client: + # v1 (REST) — unchanged + meetings = client.meeting.list() + + # v2 (GraphQL) — new + meeting = client.v2.meeting.details(meetings[0].id) + issues = client.v2.issue.list(meeting.id) + ``` + +=== "Async" + + ```python + import asyncio + from bloomy import AsyncClient + + async def main(): + async with AsyncClient(api_key="your-api-key") as client: + # v1 (REST) — unchanged + meetings = await client.meeting.list() + + # v2 (GraphQL) — new + meeting = await client.v2.meeting.details(meetings[0].id) + issues = await client.v2.issue.list(meeting.id) + + asyncio.run(main()) + ``` + +v1 and v2 can be mixed freely in the same script: they share one client, one +API key, and one connection pool. + +## Quick tour of `client.v2` + +Every namespace below exposes a sync class (`client.v2.`) and an async +mirror with identical method signatures (`await`ed). Full parameter and +return-type documentation lives on each entity's API reference page. + +| Namespace | Sync class | Async class | Model(s) | +|---|---|---|---| +| `client.v2.user` | `UserOperations` | `AsyncUserOperations` | [`User`](../api/v2/models.md#bloomy.v2.models.User) | +| `client.v2.meeting` | `MeetingOperations` | `AsyncMeetingOperations` | [`Meeting`](../api/v2/models.md#bloomy.v2.models.Meeting), [`MeetingListItem`](../api/v2/models.md#bloomy.v2.models.MeetingListItem) | +| `client.v2.issue` | `IssueOperations` | `AsyncIssueOperations` | [`Issue`](../api/v2/models.md#bloomy.v2.models.Issue) | +| `client.v2.headline` | `HeadlineOperations` | `AsyncHeadlineOperations` | [`Headline`](../api/v2/models.md#bloomy.v2.models.Headline) | +| `client.v2.todo` | `TodoOperations` | `AsyncTodoOperations` | [`Todo`](../api/v2/models.md#bloomy.v2.models.Todo) | +| `client.v2.goal` | `GoalOperations` | `AsyncGoalOperations` | [`Goal`](../api/v2/models.md#bloomy.v2.models.Goal), [`Milestone`](../api/v2/models.md#bloomy.v2.models.Milestone) | +| `client.v2.milestone` | `MilestoneOperations` | `AsyncMilestoneOperations` | [`Milestone`](../api/v2/models.md#bloomy.v2.models.Milestone) | +| `client.v2.metric` | `MetricOperations` | `AsyncMetricOperations` | [`Metric`](../api/v2/models.md#bloomy.v2.models.Metric), [`MetricScore`](../api/v2/models.md#bloomy.v2.models.MetricScore) | + +### `client.v2.user` + +| Method | Signature | Description | +|---|---|---| +| `details()` | `details(user_id=None) -> User` | Get a user. Defaults to the current user. | +| `list()` | `list() -> list[User]` | List every user in the organization. | + +### `client.v2.meeting` + +| Method | Signature | Description | +|---|---|---| +| `list()` | `list(user_id=None) -> list[MeetingListItem]` | List meetings a user attends. Defaults to the current user. | +| `details()` | `details(meeting_id) -> Meeting` | Get a meeting, including its attendees. | +| `attendees()` | `attendees(meeting_id) -> list[User]` | List a meeting's attendees. | + +### `client.v2.issue` + +| Method | Signature | Description | +|---|---|---| +| `details()` | `details(issue_id) -> Issue` | Get an issue. | +| `list()` | `list(meeting_id, *, long_term=False, include_solved=False, include_archived=False) -> list[Issue]` | List a meeting's issues. | +| `create()` | `create(meeting_id, title, user_id=None, notes=None, long_term=False) -> Issue` | Create an issue. | +| `update()` | `update(issue_id, *, title=None, user_id=None, notes=None, long_term=None, meeting_id=None) -> Issue` | Update an issue. | +| `solve()` | `solve(issue_id) -> Issue` | Mark an issue as solved. | +| `reopen()` | `reopen(issue_id) -> Issue` | Reopen a solved issue. | +| `archive()` | `archive(issue_id) -> Issue` | Archive an issue. | +| `restore()` | `restore(issue_id) -> Issue` | Restore an archived (short-term) issue. | + +### `client.v2.headline` + +| Method | Signature | Description | +|---|---|---| +| `details()` | `details(headline_id) -> Headline` | Get a headline. | +| `list()` | `list(meeting_id=None, user_id=None, include_archived=False) -> list[Headline]` | List headlines for a meeting or a user (mutually exclusive). | +| `create()` | `create(meeting_id, title, user_id=None, notes=None) -> Headline` | Create a headline. | +| `update()` | `update(headline_id, *, title=None, user_id=None, notes=None) -> Headline` | Update a headline. | +| `archive()` | `archive(headline_id) -> Headline` | Archive a headline. | +| `restore()` | `restore(headline_id) -> Headline` | Restore an archived headline. | + +### `client.v2.todo` + +| Method | Signature | Description | +|---|---|---| +| `details()` | `details(todo_id) -> Todo` | Get a to-do. | +| `list()` | `list(meeting_id=None, user_id=None, *, include_completed=False, include_archived=False) -> list[Todo]` | List to-dos for a meeting or a user (mutually exclusive). | +| `create()` | `create(title, meeting_id=None, user_id=None, due_date=None, notes=None) -> Todo` | Create a to-do. Omit `meeting_id` for a personal to-do. | +| `update()` | `update(todo_id, *, title=None, due_date=None, user_id=None, notes=None) -> Todo` | Update a to-do. | +| `complete()` | `complete(todo_id) -> Todo` | Mark a to-do as complete. | +| `reopen()` | `reopen(todo_id) -> Todo` | Reopen a completed to-do. | +| `archive()` | `archive(todo_id) -> Todo` | Archive a to-do. | +| `restore()` | `restore(todo_id) -> Todo` | Restore an archived to-do. | + +### `client.v2.goal` + +| Method | Signature | Description | +|---|---|---| +| `list()` | `list(meeting_id=None, user_id=None, *, include_archived=False) -> list[Goal]` | List goals for a meeting or a user (mutually exclusive). | +| `details()` | `details(goal_id) -> Goal` | Get a goal, including its milestones. | +| `create()` | `create(meeting_id, title, user_id=None, due_date=None, status=GoalStatus.ON_TRACK, notes=None, milestones=None) -> Goal` | Create a goal, optionally with milestones. | +| `update()` | `update(goal_id, *, title=None, status=None, due_date=None, user_id=None, notes=None) -> Goal` | Update a goal. | +| `archive()` | `archive(goal_id) -> Goal` | Archive a goal. | +| `restore()` | `restore(goal_id) -> Goal` | Restore an archived goal. | + +### `client.v2.milestone` + +| Method | Signature | Description | +|---|---|---| +| `list()` | `list(goal_id) -> list[Milestone]` | List a goal's milestones. | +| `create()` | `create(goal_id, title, due_date, completed=False) -> Milestone` | Create a milestone on a goal. | +| `update()` | `update(milestone_id, goal_id, *, title=None, due_date=None, completed=None) -> Milestone` | Update a milestone. Requires `goal_id` — see [below](#archive-vs-delete-semantics-per-entity). | +| `complete()` | `complete(milestone_id, goal_id) -> Milestone` | Mark a milestone as completed. Requires `goal_id`. | +| `delete()` | `delete(milestone_id) -> None` | Delete a milestone. No `goal_id` needed. | + +### `client.v2.metric` + +| Method | Signature | Description | +|---|---|---| +| `details()` | `details(metric_id) -> Metric` | Get a metric. | +| `list()` | `list(meeting_id=None, user_id=None, frequency=None) -> list[Metric]` | List metrics for a meeting or a user (mutually exclusive). | +| `create()` | `create(meeting_id, title, user_id=None, goal=None, units=MetricUnit.NONE, rule=MetricRule.GREATER_THAN, frequency=MetricFrequency.WEEKLY, min_goal=None, max_goal=None, notes=None) -> Metric` | Create a metric. | +| `update()` | `update(metric_id, *, title=None, user_id=None, goal=None, min_goal=None, max_goal=None, units=None, rule=None, notes=None) -> Metric` | Update a metric. | +| `archive()` | `archive(metric_id) -> Metric` | Archive a metric. There is no `restore()`. | +| `scores()` | `scores(metric_id, start=None, end=None, include_empty=False) -> list[MetricScore]` | List a metric's scores, most recent first. | +| `set_score()` | `set_score(metric_id, value, timestamp) -> MetricScore` | Set a score for a given time. | +| `update_score()` | `update_score(metric_id, score_id, *, value=None, notes=None) -> MetricScore` | Update an existing score's value and/or note. | +| `clear_score()` | `clear_score(metric_id, score_id) -> MetricScore` | Clear a score's value, leaving an empty placeholder. | + +## Description ("notes") behavior + +Every entity with a `notes` field (`Issue`, `Headline`, `Todo`, `Goal`, +`Metric`) stores its description as a collaborative note pad (Etherpad), not +as a plain text column. This has one consequence you need to know: + +!!! warning "`notes=` on `update()` creates a new pad, it does not edit the old one" + Passing `notes=` to `create()` or `update()` always creates a **brand-new** + note pad and points the entity at it (`notes_id`). It never edits the + previous pad's content in place. Each `update(..., notes=...)` call + replaces the entity's `notes_id` with a fresh pad, leaving the old pad + orphaned. There is no way to append to or partially edit an existing note + through this SDK — always pass the full new description text. + +```python +issue = client.v2.issue.create(123, "Title", notes="Original text") +print(issue.notes_id) # e.g. "abc123" + +updated = client.v2.issue.update(issue.id, notes="Replacement text") +print(updated.notes_id) # a different pad id +print(updated.notes) # "Replacement text" +``` + +Reading `notes` immediately after a write can occasionally return stale or +empty text: pad reads are cached for a second or two after a write. If you +read a just-written note back and it looks empty, retry after a short delay +rather than assuming the write failed. + +`notes_id` is kept on the model alongside `notes` for reference, but you do +not need it for normal use — pass a new `notes=` string to `update()` to +replace the description. + +## Archive vs. delete semantics per entity + +Lifecycle operations are not uniform across entities — some archive, some +complete/reopen, and milestones delete outright. There is no cross-entity +`delete()` for issues, headlines, todos, goals, or metrics; they only +archive/restore. + +| Entity | Lifecycle methods | Notes | +|---|---|---| +| Issue | `solve()` / `reopen()`, `archive()` / `restore()` | The API stores long-term issues with an archived flag; the SDK reports them as not archived, matching the web app. `archive()` removes a long-term issue from the long-term list, and `restore()` returns it to the short-term list. Solve an issue before archiving it: `solve()` has no effect on an archived issue. | +| Headline | `archive()` / `restore()` | No complete/solve concept. | +| Todo | `complete()` / `reopen()`, `archive()` / `restore()` | Independent flags: a to-do can be completed and archived at the same time. | +| Goal | `archive()` / `restore()` | `archive()` re-reads the goal after the mutation and raises `GraphQLError` if it is still not archived, working around a server-side bug where the edit can report success without archiving anything. `restore()` re-attaches meeting links detached by a recent archive. | +| Milestone | `delete()` | No archive/restore — `delete()` permanently removes it (soft-deleted server-side; it never reappears). There is no root query to read a milestone directly, which is why `update()` and `complete()` require both `milestone_id` and `goal_id`. | +| Metric | `archive()` | **No `restore()`.** The GraphQL API does not support un-archiving a metric — editing `archived: false` on an already-archived metric re-archives it rather than restoring it. Archiving a metric is effectively permanent through this SDK. | + +## Metric scores and week semantics + +`client.v2.metric` scores have a few behaviors worth knowing before you write +data: + +- **`set_score()` upserts by period for weekly/monthly/quarterly metrics.** + Calling it twice for timestamps in the same period updates the same score + row. For `MetricFrequency.DAILY` metrics, a placeholder score already + exists for every day, so `set_score()` transparently falls back to + `update_score()` when it detects that placeholder instead of creating a + duplicate. +- **Weekly scores are stored on the Sunday-anchored start of their week**, not + the exact instant you pass. Writing a score with `timestamp=datetime.now()` + on a Thursday stores it on that week's preceding Sunday at `00:00 UTC` — + reading `MetricScore.week_date` back will reflect that anchor, not "now". +- **`clear_score()` blanks a score's value** but keeps the row as an empty + placeholder — it does not delete it. `scores(include_empty=True)` will + still return it with `value=None`. +- **`rule=MetricRule.BETWEEN` requires `min_goal`/`max_goal` instead of + `goal`.** Mixing the two raises `ValueError` locally, before any request is + sent: + +```python +from bloomy import Client +from bloomy.v2.models import MetricRule + +with Client(api_key="your-api-key") as client: + # OK: single goal for a non-BETWEEN rule + client.v2.metric.create(123, "Response Time", goal=24, rule=MetricRule.LESS_THAN) + + # OK: min/max goal for BETWEEN + client.v2.metric.create( + 123, "Team Size", min_goal=5, max_goal=10, rule=MetricRule.BETWEEN + ) + + # Raises ValueError: `goal` cannot be used with rule=BETWEEN + client.v2.metric.create(123, "Bad Example", goal=5, rule=MetricRule.BETWEEN) +``` + +## GraphQLError + +v2 operations raise `bloomy.GraphQLError` (a subclass of `APIError`, itself a +subclass of `BloomyError`) instead of a plain `APIError`, so existing +`except APIError` / `except BloomyError` handlers keep working without +changes. `GraphQLError` adds an `errors` attribute: the raw list of GraphQL +error objects from the response. + +A `GraphQLError` is raised for: + +- A response body carrying a top-level `errors` array, whether it came back + with an HTTP 4xx validation status or an HTTP 200 execution status. +- A mutation result shaped `{success, message, errorDetails}` where `success` + is `false` (used by, for example, `issue.update()` and + `milestone.delete()`). + +!!! note "Some v2 mutations return only `{id}`" + A few mutations (`headline`/`goal`/`milestone`/`metric` create and edit) + return only `IdModel { id }` rather than the + `{success, message, errorDetails}` shape. For those, failures surface + exclusively through the top-level `errors` array — there is no + `success: false` body to check. This is transparent to callers: either + way, a failed write raises `GraphQLError`. + +```python +from bloomy import Client, GraphQLError + +with Client(api_key="your-api-key") as client: + try: + client.v2.issue.update(123) # no fields -> ValueError (local, not GraphQLError) + except ValueError as e: + print(f"Validation error: {e}") + + try: + client.v2.issue.details(999999999) + except GraphQLError as e: + print(f"GraphQL error: {e}") + print(f"Status code: {e.status_code}") + print(f"Raw errors: {e.errors}") +``` + +## Next steps + +- [v2 API Reference](../api/v2/user.md) — full method and model documentation + for every `client.v2` namespace. +- [Basic Usage](usage.md) — v1 (REST) patterns. +- [Error Handling](errors.md) — the full `BloomyError` hierarchy. diff --git a/mkdocs.yml b/mkdocs.yml index 097b979..e0616e5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,11 +97,12 @@ nav: - Basic Usage: guide/usage.md - Bulk Operations: guide/bulk-operations.md - Async Support: guide/async.md + - "v2: The GraphQL API": guide/v2-graphql-api.md - Error Handling: guide/errors.md - API Reference: - Client: api/client.md - AsyncClient: api/async_client.md - - Operations: + - v1 Operations: - Users: api/operations/users.md - Meetings: api/operations/meetings.md - Todos: api/operations/todos.md @@ -109,6 +110,16 @@ nav: - Scorecard: api/operations/scorecard.md - Issues: api/operations/issues.md - Headlines: api/operations/headlines.md + - v2 Operations: + - Users: api/v2/user.md + - Meetings: api/v2/meeting.md + - Issues: api/v2/issue.md + - Headlines: api/v2/headline.md + - Todos: api/v2/todo.md + - Goals: api/v2/goal.md + - Milestones: api/v2/milestone.md + - Metrics: api/v2/metric.md + - v2 Models: api/v2/models.md - Configuration: api/configuration.md - Exceptions: api/exceptions.md diff --git a/src/bloomy/__init__.py b/src/bloomy/__init__.py index 47e1f2e..b19b3e4 100644 --- a/src/bloomy/__init__.py +++ b/src/bloomy/__init__.py @@ -5,7 +5,13 @@ from .async_client import AsyncClient from .client import Client from .configuration import Configuration -from .exceptions import APIError, AuthenticationError, BloomyError, ConfigurationError +from .exceptions import ( + APIError, + AuthenticationError, + BloomyError, + ConfigurationError, + GraphQLError, +) from .models import ( ArchivedGoalInfo, BulkCreateError, @@ -64,6 +70,7 @@ "GoalInfo", "GoalListResponse", "GoalStatus", + "GraphQLError", "Headline", "HeadlineDetails", "HeadlineInfo", diff --git a/src/bloomy/async_client.py b/src/bloomy/async_client.py index c46bf5a..662b3f5 100644 --- a/src/bloomy/async_client.py +++ b/src/bloomy/async_client.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Self +from urllib.parse import urlsplit import httpx @@ -21,6 +22,8 @@ AsyncTodoOperations, AsyncUserOperations, ) + from .v1 import AsyncV1 + from .v2 import AsyncV2 class AsyncClient: @@ -29,6 +32,10 @@ class AsyncClient: This client provides async access to all Bloomy API operations including users, meetings, todos, goals, headlines, issues, and scorecards. + `client.v1` groups the REST API operations; the top-level attributes are + aliases of `client.v1.*`, kept for backward compatibility. `client.v2` + groups the GraphQL API operations (`user`, `meeting`, `issue`, ...). + Args: api_key: The API key for authentication. If not provided, it will be loaded from environment variables or configuration files. @@ -64,6 +71,7 @@ def __init__( api_key: str | None = None, base_url: str = "https://app.bloomgrowth.com/api/v1", timeout: float = 30.0, + graphql_url: str | None = None, ) -> None: """Initialize the async Bloomy client. @@ -71,6 +79,8 @@ def __init__( api_key: The API key for authentication. base_url: The base URL for the API. timeout: The timeout in seconds for HTTP requests. Defaults to 30.0. + graphql_url: The absolute URL of the v2 GraphQL endpoint. Defaults to + the scheme and host of `base_url` plus `/graphql/`. Raises: ConfigurationError: If no API key is provided or found in configuration. @@ -84,6 +94,11 @@ def __init__( "environment variable, or in ~/.bloomy/config.yaml configuration file." ) + if graphql_url is None: + parsed = urlsplit(base_url) + graphql_url = f"{parsed.scheme}://{parsed.netloc}/graphql/" + self._graphql_url = graphql_url + self._client = httpx.AsyncClient( base_url=base_url, headers={ @@ -94,23 +109,24 @@ def __init__( ) # Lazy imports to avoid circular dependencies - from .operations.async_.goals import AsyncGoalOperations - from .operations.async_.headlines import AsyncHeadlineOperations - from .operations.async_.issues import AsyncIssueOperations - from .operations.async_.meetings import AsyncMeetingOperations - from .operations.async_.scorecard import AsyncScorecardOperations - from .operations.async_.todos import AsyncTodoOperations - from .operations.async_.users import AsyncUserOperations - - self.user: AsyncUserOperations = AsyncUserOperations(self._client) - self.meeting: AsyncMeetingOperations = AsyncMeetingOperations(self._client) - self.todo: AsyncTodoOperations = AsyncTodoOperations(self._client) - self.goal: AsyncGoalOperations = AsyncGoalOperations(self._client) - self.headline: AsyncHeadlineOperations = AsyncHeadlineOperations(self._client) - self.issue: AsyncIssueOperations = AsyncIssueOperations(self._client) - self.scorecard: AsyncScorecardOperations = AsyncScorecardOperations( - self._client - ) + from .v1 import AsyncV1 + from .v2 import AsyncV2 + + # Initialize the v1 (REST) and v2 (GraphQL) operation namespaces, + # sharing the same httpx client (v2 requests pass an absolute URL, + # which overrides the client's base_url). + self.v1: AsyncV1 = AsyncV1(self._client) + self.v2: AsyncV2 = AsyncV2(self._client, self._graphql_url) + + # Top-level attributes are aliases of `self.v1.*` (same objects), + # kept for backward compatibility. + self.user: AsyncUserOperations = self.v1.user + self.meeting: AsyncMeetingOperations = self.v1.meeting + self.todo: AsyncTodoOperations = self.v1.todo + self.goal: AsyncGoalOperations = self.v1.goal + self.headline: AsyncHeadlineOperations = self.v1.headline + self.issue: AsyncIssueOperations = self.v1.issue + self.scorecard: AsyncScorecardOperations = self.v1.scorecard async def __aenter__(self) -> Self: """Enter the async context manager. diff --git a/src/bloomy/client.py b/src/bloomy/client.py index d3ff661..994bb70 100644 --- a/src/bloomy/client.py +++ b/src/bloomy/client.py @@ -3,18 +3,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, Self +from urllib.parse import urlsplit import httpx from .configuration import Configuration from .exceptions import ConfigurationError -from .operations.goals import GoalOperations -from .operations.headlines import HeadlineOperations -from .operations.issues import IssueOperations -from .operations.meetings import MeetingOperations -from .operations.scorecard import ScorecardOperations -from .operations.todos import TodoOperations -from .operations.users import UserOperations +from .v1 import V1 +from .v2 import V2 if TYPE_CHECKING: from typing import Any @@ -25,6 +21,12 @@ class Client: It provides methods for managing Bloom Growth features. + `client.v1` groups the REST API operations (`user`, `todo`, `meeting`, + `goal`, `scorecard`, `issue`, `headline`); the top-level attributes below + are aliases of `client.v1.*`, kept for backward compatibility. + `client.v2` groups the GraphQL API operations (`user`, `meeting`, + `issue`, ...). + Example: ```python from bloomy import Client @@ -35,6 +37,11 @@ class Client: client.scorecard.list() client.issue.list() client.headline.list() + + # v2 (GraphQL) + client.v2.user.details() + client.v2.meeting.details(349524) + client.v2.issue.list(349524) ``` """ @@ -44,6 +51,7 @@ def __init__( api_key: str | None = None, base_url: str = "https://app.bloomgrowth.com/api/v1", timeout: float = 30.0, + graphql_url: str | None = None, ) -> None: """Initialize a new Client instance. @@ -52,6 +60,8 @@ def __init__( load from environment variable (BG_API_KEY) or configuration file. base_url: The base URL for the API. Defaults to the production API URL. timeout: The timeout in seconds for HTTP requests. Defaults to 30.0. + graphql_url: The absolute URL of the v2 GraphQL endpoint. Defaults to + the scheme and host of `base_url` plus `/graphql/`. Raises: ConfigurationError: If no API key is provided or found in configuration. @@ -72,6 +82,11 @@ def __init__( self._api_key = self.configuration.api_key self._base_url = base_url + if graphql_url is None: + parsed = urlsplit(base_url) + graphql_url = f"{parsed.scheme}://{parsed.netloc}/graphql/" + self._graphql_url = graphql_url + # Initialize HTTP client self._client = httpx.Client( base_url=base_url, @@ -83,14 +98,21 @@ def __init__( timeout=timeout, ) - # Initialize operation classes - self.user = UserOperations(self._client) - self.todo = TodoOperations(self._client) - self.meeting = MeetingOperations(self._client) - self.goal = GoalOperations(self._client) - self.scorecard = ScorecardOperations(self._client) - self.issue = IssueOperations(self._client) - self.headline = HeadlineOperations(self._client) + # Initialize the v1 (REST) and v2 (GraphQL) operation namespaces, + # sharing the same httpx client (v2 requests pass an absolute URL, + # which overrides the client's base_url). + self.v1 = V1(self._client) + self.v2 = V2(self._client, self._graphql_url) + + # Top-level attributes are aliases of `self.v1.*` (same objects), + # kept for backward compatibility. + self.user = self.v1.user + self.todo = self.v1.todo + self.meeting = self.v1.meeting + self.goal = self.v1.goal + self.scorecard = self.v1.scorecard + self.issue = self.v1.issue + self.headline = self.v1.headline def __enter__(self) -> Self: """Context manager entry. diff --git a/src/bloomy/exceptions.py b/src/bloomy/exceptions.py index a5c5cd7..45fa09a 100644 --- a/src/bloomy/exceptions.py +++ b/src/bloomy/exceptions.py @@ -1,5 +1,9 @@ """Exceptions for the Bloomy SDK.""" +from __future__ import annotations + +from typing import Any + class BloomyError(Exception): """Base exception for all Bloomy-related errors.""" @@ -32,3 +36,32 @@ def __init__(self, message: str, status_code: int | None = None) -> None: """ super().__init__(message) self.status_code = status_code + + +class GraphQLError(APIError): + """Raised when the v2 GraphQL API returns an error. + + This covers three cases, all surfaced identically to callers: + a response body with a top-level `errors` array (whether it came back + with an HTTP 4xx validation status or a 200 execution status), and a + mutation result shaped like `{success message errorDetails{message}}` + where `success` is `false`. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + errors: list[dict[str, Any]] | None = None, + ) -> None: + """Initialize a GraphQL error with message, status code, and raw errors. + + Args: + message: The error message. + status_code: The HTTP status code if available. + errors: The raw GraphQL `errors` array (or synthesized `errorDetails` + entries for failed mutation results), if available. + + """ + super().__init__(message, status_code=status_code) + self.errors: list[dict[str, Any]] = errors or [] diff --git a/src/bloomy/v1/__init__.py b/src/bloomy/v1/__init__.py new file mode 100644 index 0000000..2400d60 --- /dev/null +++ b/src/bloomy/v1/__init__.py @@ -0,0 +1,77 @@ +"""v1: the Bloom Growth REST API (unchanged from earlier SDK releases). + +Exposed as `client.v1` / `AsyncClient().v1`. The top-level attributes on +`Client`/`AsyncClient` (`client.user`, `client.meeting`, ...) are aliases +pointing at the SAME operation instances held here, kept for backward +compatibility. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..operations.async_.goals import AsyncGoalOperations +from ..operations.async_.headlines import AsyncHeadlineOperations +from ..operations.async_.issues import AsyncIssueOperations +from ..operations.async_.meetings import AsyncMeetingOperations +from ..operations.async_.scorecard import AsyncScorecardOperations +from ..operations.async_.todos import AsyncTodoOperations +from ..operations.async_.users import AsyncUserOperations +from ..operations.goals import GoalOperations +from ..operations.headlines import HeadlineOperations +from ..operations.issues import IssueOperations +from ..operations.meetings import MeetingOperations +from ..operations.scorecard import ScorecardOperations +from ..operations.todos import TodoOperations +from ..operations.users import UserOperations + +if TYPE_CHECKING: + import httpx + + +class V1: + """Namespace for the Bloom Growth REST API (v1) operations. + + Instantiated once by `bloomy.client.Client` and exposed as `client.v1`. + """ + + def __init__(self, client: httpx.Client) -> None: + """Initialize the v1 namespace. + + Args: + client: The sync HTTP client to use for REST requests. + + """ + self.user = UserOperations(client) + self.todo = TodoOperations(client) + self.meeting = MeetingOperations(client) + self.goal = GoalOperations(client) + self.scorecard = ScorecardOperations(client) + self.issue = IssueOperations(client) + self.headline = HeadlineOperations(client) + + +class AsyncV1: + """Async namespace for the Bloom Growth REST API (v1) operations. + + Instantiated once by `bloomy.async_client.AsyncClient` and exposed as + `client.v1`. + """ + + def __init__(self, client: httpx.AsyncClient) -> None: + """Initialize the async v1 namespace. + + Args: + client: The async HTTP client to use for REST requests. + + """ + self.user = AsyncUserOperations(client) + self.meeting = AsyncMeetingOperations(client) + self.todo = AsyncTodoOperations(client) + self.goal = AsyncGoalOperations(client) + self.headline = AsyncHeadlineOperations(client) + self.issue = AsyncIssueOperations(client) + self.scorecard = AsyncScorecardOperations(client) + + +__all__ = ["V1", "AsyncV1"] diff --git a/src/bloomy/v2/__init__.py b/src/bloomy/v2/__init__.py new file mode 100644 index 0000000..1499a96 --- /dev/null +++ b/src/bloomy/v2/__init__.py @@ -0,0 +1,122 @@ +"""v2: the Bloom Growth GraphQL API. + +Exposed as `client.v2` / `AsyncClient().v2`. See `bloomy.v2.base` for the +transport and shared helper conventions, and `bloomy.v2.models` for the +model conventions (owner/related-entity representation, datetime handling, +notes) that every entity added here follows. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .models import ( + Goal, + GoalStatus, + Headline, + Issue, + Meeting, + MeetingListItem, + MeetingRef, + Metric, + MetricFrequency, + MetricRule, + MetricScore, + MetricUnit, + Milestone, + Todo, + User, + UserRef, +) +from .operations import ( + AsyncGoalOperations, + AsyncHeadlineOperations, + AsyncIssueOperations, + AsyncMeetingOperations, + AsyncMetricOperations, + AsyncMilestoneOperations, + AsyncTodoOperations, + AsyncUserOperations, + GoalOperations, + HeadlineOperations, + IssueOperations, + MeetingOperations, + MetricOperations, + MilestoneOperations, + TodoOperations, + UserOperations, +) + +if TYPE_CHECKING: + import httpx + + +class V2: + """Namespace for the Bloom Growth GraphQL API (v2) operations. + + Instantiated once by `bloomy.client.Client` and exposed as `client.v2`. + """ + + def __init__(self, client: httpx.Client, graphql_url: str) -> None: + """Initialize the v2 namespace. + + Args: + client: The sync HTTP client to use for GraphQL requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + self.user = UserOperations(client, graphql_url) + self.meeting = MeetingOperations(client, graphql_url) + self.issue = IssueOperations(client, graphql_url) + self.headline = HeadlineOperations(client, graphql_url) + self.todo = TodoOperations(client, graphql_url) + self.goal = GoalOperations(client, graphql_url) + self.milestone = MilestoneOperations(client, graphql_url) + self.metric = MetricOperations(client, graphql_url) + + +class AsyncV2: + """Async namespace for the Bloom Growth GraphQL API (v2) operations. + + Instantiated once by `bloomy.async_client.AsyncClient` and exposed as + `client.v2`. + """ + + def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: + """Initialize the async v2 namespace. + + Args: + client: The async HTTP client to use for GraphQL requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + self.user = AsyncUserOperations(client, graphql_url) + self.meeting = AsyncMeetingOperations(client, graphql_url) + self.issue = AsyncIssueOperations(client, graphql_url) + self.headline = AsyncHeadlineOperations(client, graphql_url) + self.todo = AsyncTodoOperations(client, graphql_url) + self.goal = AsyncGoalOperations(client, graphql_url) + self.milestone = AsyncMilestoneOperations(client, graphql_url) + self.metric = AsyncMetricOperations(client, graphql_url) + + +__all__ = [ + "V2", + "AsyncV2", + "Goal", + "GoalStatus", + "Headline", + "Issue", + "Meeting", + "MeetingListItem", + "MeetingRef", + "Metric", + "MetricFrequency", + "MetricRule", + "MetricScore", + "MetricUnit", + "Milestone", + "Todo", + "User", + "UserRef", +] diff --git a/src/bloomy/v2/base.py b/src/bloomy/v2/base.py new file mode 100644 index 0000000..4d704f2 --- /dev/null +++ b/src/bloomy/v2/base.py @@ -0,0 +1,477 @@ +"""Transport and shared helpers for the Bloomy v2 (GraphQL) API. + +Every v2 entity module builds on the classes in this file: `GraphQLOperations` +for sync operations, `AsyncGraphQLOperations` for async. Both execute +`{query, variables}` documents against the GraphQL endpoint over the SAME +httpx client instance the v1 REST operations use (an absolute URL passed to +`client.post()` overrides the client's `base_url`), and share the error +handling, timestamp conversion, and description ("notes") helpers defined on +`AbstractGraphQLOperations`. + +Error handling contract (see `bloomy.exceptions.GraphQLError`): + - A response body carrying a top-level `errors` array raises + `GraphQLError`, regardless of HTTP status (validation errors return + HTTP 400 with `errors`; execution errors return HTTP 200 with + `errors` alongside `data`). + - A response that fails to parse as JSON (e.g. a plain-text 401/500) + falls back to `response.raise_for_status()`. + - A response that parses as JSON without an `errors` key still goes + through `response.raise_for_status()` before its `data` is returned, + so a JSON error body with no `errors` array (or an expired-token 401 + with an empty body) still raises a clear `httpx.HTTPStatusError` + instead of failing deep inside `data` access. + - Mutations shaped `{success message errorDetails{message}}` (the + `GraphQLResponseOfBoolean`/`OfString`/`Base` family) raise + `GraphQLError` when `success` is `false`, via `_check_mutation_result`. +""" + +from __future__ import annotations + +import html +import re +from datetime import UTC, date, datetime +from typing import TYPE_CHECKING, Any, cast + +from ..exceptions import GraphQLError + +if TYPE_CHECKING: + import httpx + +_TAG_RE = re.compile(r"<[^>]+>") + +#: Fields shared by every `{success message errorDetails{message}}` mutation +#: result (the `GraphQLResponseOfBoolean` / `OfString` / `Base` family). +MUTATION_RESULT_FIELDS = "success message errorDetails { message }" + +_CREATE_NOTE_MUTATION = f""" +mutation($text: String) {{ + CreateNote(text: $text) {{ + data + {MUTATION_RESULT_FIELDS} + }} +}} +""" + +_GET_AUTHENTICATED_USER_ID_MUTATION = "mutation { getAuthenticatedUserId { id } }" + + +def prepare_note_text(text: str) -> str: + """Escape plain text for Etherpad's `setHTML`, as `CreateNote` expects. + + Args: + text: The plain text to store. + + Returns: + HTML-escaped text with newlines turned into `
`. + + """ + return html.escape(text).replace("\n", "
") + + +def extract_notes(data: dict[str, Any]) -> str | None: + """Extract plain description text from a notes projection. + + Expects `notesText`, `localHtml`, and `collaborationEnabled` to have been + selected on `data` (selecting `notesText` without `collaborationEnabled` + makes the API resolve it to `""`). + + This is a module-level function, not only a method on + `AbstractGraphQLOperations`, so that entity mixins (which are combined + with both the sync and async base classes, and so cannot themselves + inherit from `AbstractGraphQLOperations`) can import and call it + directly instead of going through `self`. + + Args: + data: The raw GraphQL object carrying the notes fields. + + Returns: + The plain description text, or `None` if empty. + + """ + if data.get("collaborationEnabled"): + text = (data.get("notesText") or "").rstrip("\n") + else: + local_html = data.get("localHtml") or "" + text = html.unescape(_TAG_RE.sub("", local_html)).strip() + return text or None + + +def dig_nodes(data: dict[str, Any] | None, *keys: str) -> list[dict[str, Any]]: + """Walk nested dict keys (each defaulting to `{}`) and read a `nodes` list. + + GraphQL connection results are shaped like + `{"meeting": {"issues": {"nodes": [...]}}}`; any level can be missing + (e.g. a `null` `meeting`), so this walks each key defensively instead of + chaining `.get(...) or {}` inline (which loses precise typing under + strict mode once an untyped `{}` fallback enters the chain). + + Args: + data: The raw GraphQL `data` object (or a nested object within it). + *keys: The nested keys to walk before reading `nodes`. + + Returns: + The `nodes` list found this way, or an empty list if any step, or + `nodes` itself, is missing. + + """ + current: dict[str, Any] = data or {} + for key in keys: + current = current.get(key) or {} + nodes = current.get("nodes") + return list(nodes) if nodes else [] + + +class AbstractGraphQLOperations: + """Shared, transport-agnostic logic for v2 (GraphQL) operations.""" + + def __init__(self, client: Any, graphql_url: str) -> None: + """Initialize the operations class. + + Args: + client: The httpx client (sync or async) to use for requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + self._client = client + self._graphql_url = graphql_url + self._user_id: int | None = None + + # -- request/response plumbing ------------------------------------------------ + + def _build_payload( + self, query: str, variables: dict[str, Any] | None + ) -> dict[str, Any]: + """Build the JSON body for a GraphQL request. + + Returns: + A `{"query": ..., "variables": ...}` dictionary. + + """ + return {"query": query, "variables": variables or {}} + + def _parse_response(self, response: httpx.Response) -> dict[str, Any]: + """Parse a GraphQL HTTP response into its `data` object. + + Args: + response: The raw httpx response from the GraphQL endpoint. + + Returns: + The `data` object of the response (empty dict if absent). + + Raises: + GraphQLError: If the response body carries a JSON `errors` array. + ValueError: If the response body is not valid JSON despite a + successful (2xx) status, re-raising the original JSON-decode + error (a non-2xx status raises `httpx.HTTPStatusError` + instead, via `response.raise_for_status()`). + + """ + try: + raw: Any = response.json() + except ValueError: + response.raise_for_status() + raise + + payload = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {} + errors: list[dict[str, Any]] = payload.get("errors") or [] + if errors: + message = "; ".join( + error.get("message", "Unknown GraphQL error") for error in errors + ) + raise GraphQLError( + message or "GraphQL request failed", + status_code=response.status_code, + errors=errors, + ) + + response.raise_for_status() + return payload.get("data") or {} + + def _check_mutation_result( + self, result: dict[str, Any] | None, *, action: str + ) -> None: + """Raise `GraphQLError` for a failed `{success message errorDetails}` result. + + Args: + result: The mutation's result object (e.g. `data["EditIssue"]`). + action: A short description of the mutation, used in the error + message when the result is missing entirely. + + Raises: + GraphQLError: If `result` is missing or `success` is falsy. + + """ + if result is None: + raise GraphQLError(f"{action} failed: no result returned") + if not result.get("success", False): + error_details: list[dict[str, Any]] = result.get("errorDetails") or [] + detail_messages = [ + detail.get("message", "") for detail in error_details if detail + ] + message = ( + result.get("message") + or "; ".join(filter(None, detail_messages)) + or f"{action} failed" + ) + raise GraphQLError(message, errors=error_details) + + # -- timestamps ----------------------------------------------------------- + + @staticmethod + def _to_timestamp(value: datetime | date | float | int) -> float: + """Convert a datetime/date/number into unix seconds (UTC). + + A naive `datetime` is treated as UTC. A `date` becomes 00:00 UTC of + that day. + + Args: + value: The value to convert. + + Returns: + Unix seconds as a float. + + """ + if isinstance(value, datetime): + aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return aware.timestamp() + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=UTC).timestamp() + return float(value) + + @staticmethod + def _now_timestamp() -> float: + """Return the current time as unix seconds (UTC). + + Returns: + Unix seconds as a float. + + """ + return datetime.now(tz=UTC).timestamp() + + # -- descriptions ("notes") ------------------------------------------------- + # See the module-level `prepare_note_text`/`extract_notes` docstrings. + + _prepare_note_text = staticmethod(prepare_note_text) + _extract_notes = staticmethod(extract_notes) + + +class GraphQLOperations(AbstractGraphQLOperations): + """Sync base class for v2 (GraphQL) operation classes.""" + + def __init__(self, client: httpx.Client, graphql_url: str) -> None: + """Initialize the operations class. + + Args: + client: The sync HTTP client to use for GraphQL requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + super().__init__(client, graphql_url) + self._client: httpx.Client = client + + @property + def user_id(self) -> int: + """Get the current user's ID, fetching it if needed. + + Returns: + The user ID of the authenticated user. + + """ + if self._user_id is None: + self._user_id = self._fetch_authenticated_user_id() + return self._user_id + + def _fetch_authenticated_user_id(self) -> int: + """Fetch the authenticated user's ID via `getAuthenticatedUserId`. + + Returns: + The user ID of the authenticated user. + + """ + data = self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) + return int(data["getAuthenticatedUserId"]["id"]) + + def _execute( + self, query: str, variables: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Execute a GraphQL document and return its `data` object. + + Args: + query: The GraphQL query or mutation document. + variables: The GraphQL variables for the document, if any. + + Returns: + The `data` object of the response. See `_parse_response` for the + errors this can raise. + + """ + response = self._client.post( + self._graphql_url, json=self._build_payload(query, variables) + ) + return self._parse_response(response) + + def _run_mutation( + self, + query: str, + variables: dict[str, Any] | None, + *, + root_field: str, + action: str, + ) -> dict[str, Any]: + """Execute a mutation and check its `{success message errorDetails}` result. + + Args: + query: The GraphQL mutation document. + variables: The GraphQL variables for the document. + root_field: The mutation's root field name in `data` (e.g. `EditIssue`). + action: A short description of the mutation, used in error messages. + + Returns: + The mutation's result object (`data[root_field]`). See + `_check_mutation_result` for the errors this can raise. + + """ + data = self._execute(query, variables) + result = data.get(root_field) + self._check_mutation_result(result, action=action) + return result or {} + + def _create_note(self, text: str) -> str: + """Create a new Etherpad note pad from plain text. + + Args: + text: The plain text to store. Newlines and HTML-sensitive + characters are escaped for Etherpad automatically. + + Returns: + The new pad's id, for use as `notesId` on a create/edit input. + See `_check_mutation_result` for the errors this can raise. + + """ + result = self._run_mutation( + _CREATE_NOTE_MUTATION, + {"text": self._prepare_note_text(text)}, + root_field="CreateNote", + action="CreateNote", + ) + return str(result["data"]) + + +class AsyncGraphQLOperations(AbstractGraphQLOperations): + """Async base class for v2 (GraphQL) operation classes.""" + + def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: + """Initialize the async operations class. + + Args: + client: The async HTTP client to use for GraphQL requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + super().__init__(client, graphql_url) + self._client: httpx.AsyncClient = client + + @property + def user_id(self) -> int: + """Get the cached user ID. + + For async operations, use `get_user_id()` to fetch from the API. + + Raises: + RuntimeError: If the user ID has not been fetched yet. + + """ + if self._user_id is None: + raise RuntimeError("User ID not set. Use get_user_id() to fetch from API.") + return self._user_id + + @user_id.setter + def user_id(self, value: int) -> None: + """Set the cached user ID.""" + self._user_id = value + + async def get_user_id(self) -> int: + """Get the current user's ID, fetching it if needed. + + Returns: + The user ID of the authenticated user. + + """ + if self._user_id is None: + self._user_id = await self._fetch_authenticated_user_id() + return self._user_id + + async def _fetch_authenticated_user_id(self) -> int: + """Fetch the authenticated user's ID via `getAuthenticatedUserId`. + + Returns: + The user ID of the authenticated user. + + """ + data = await self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) + return int(data["getAuthenticatedUserId"]["id"]) + + async def _execute( + self, query: str, variables: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Execute a GraphQL document and return its `data` object. + + Args: + query: The GraphQL query or mutation document. + variables: The GraphQL variables for the document, if any. + + Returns: + The `data` object of the response. See `_parse_response` for the + errors this can raise. + + """ + response = await self._client.post( + self._graphql_url, json=self._build_payload(query, variables) + ) + return self._parse_response(response) + + async def _run_mutation( + self, + query: str, + variables: dict[str, Any] | None, + *, + root_field: str, + action: str, + ) -> dict[str, Any]: + """Execute a mutation and check its `{success message errorDetails}` result. + + Args: + query: The GraphQL mutation document. + variables: The GraphQL variables for the document. + root_field: The mutation's root field name in `data` (e.g. `EditIssue`). + action: A short description of the mutation, used in error messages. + + Returns: + The mutation's result object (`data[root_field]`). See + `_check_mutation_result` for the errors this can raise. + + """ + data = await self._execute(query, variables) + result = data.get(root_field) + self._check_mutation_result(result, action=action) + return result or {} + + async def _create_note(self, text: str) -> str: + """Create a new Etherpad note pad from plain text. + + Args: + text: The plain text to store. Newlines and HTML-sensitive + characters are escaped for Etherpad automatically. + + Returns: + The new pad's id, for use as `notesId` on a create/edit input. + See `_check_mutation_result` for the errors this can raise. + + """ + result = await self._run_mutation( + _CREATE_NOTE_MUTATION, + {"text": self._prepare_note_text(text)}, + root_field="CreateNote", + action="CreateNote", + ) + return str(result["data"]) diff --git a/src/bloomy/v2/models.py b/src/bloomy/v2/models.py new file mode 100644 index 0000000..731fa58 --- /dev/null +++ b/src/bloomy/v2/models.py @@ -0,0 +1,319 @@ +"""Pydantic models for the Bloomy v2 (GraphQL) API. + +Conventions (apply these uniformly to models added for other v2 entities — +headline, todo, goal, milestone, metric): + +- **Aliases are camelCase**, matching the GraphQL field names verbatim + (`Field(alias="dateCreated")`), unlike v1's PascalCase REST aliases. Since + GraphQL response shapes already map ~1:1 onto these models, operation + mixins build most models by spreading the raw response dict as keyword + arguments (`Model(**data, notes=computed_notes)`), relying on + `BloomyBaseModel`'s `validate_by_alias=True` to do the field mapping and + on Pydantic's default `extra="ignore"` to drop unmapped raw keys (e.g. + `notesText`, `collaborationEnabled`). This works cleanly for `Issue` + (see `IssueOperationsMixin._transform_issue`), whose fields all come from + one flat response object. Where a mixin instead reshapes a nested + connection by hand (e.g. `Meeting.attendees`, or flattening a nested + `user { email }` onto `User`, in `MeetingOperationsMixin._transform_*`), + construct the model with **alias keyword names** + (`User(firstName=..., fullName=...)`), not the snake_case field names: + Pydantic's generated `__init__` exposes the alias — not the field name — + to static type checkers whenever `Field(alias=...)` is set, even though + `validate_by_name=True` also accepts the field name at runtime. Mixing the + two (spreading a raw dict vs. hand-building kwargs) is fine; mixing + aliases and field names *within one call* is not checked by pydantic and + will silently pass `None`/defaults for the field-name-spelled arguments. +- **Owners are nested refs**: an entity's assignee is exposed as + `owner: UserRef | None` (aliased from the GraphQL `assignee` field), never + flattened into `owner_id`/`owner_name`. `owner` is optional because + `assignee` can be `null` (e.g. unowned issues). +- **Related entities are nested refs** too: an `Issue`'s parent meeting is + `meeting: MeetingRef | None` (aliased from GraphQL `meeting`), not + flattened `meeting_id`/`meeting_name`. Reuse `MeetingRef`/`UserRef` for + this rather than inventing new ref types per entity. +- **Datetimes use the `_date` suffix** (`created_date`, `due_date`, + `completed_date`, `archived_date`) and are always timezone-aware UTC + `datetime` objects, converted from the API's unix-seconds floats via the + `GqlDatetime`/`GqlOptionalDatetime` annotated types. This mirrors v1's + `_date` naming and lines up with the public API's `due_date` parameters. +- **Notes**: description text is exposed as `notes: str | None`, computed by + `bloomy.v2.base.extract_notes` from the raw + `notesText`/`localHtml`/`collaborationEnabled` projection. `notes_id` is + kept alongside it (aliased from `notesId`) since it is needed to update + the description later (see `v2/base.py`). +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from enum import StrEnum +from typing import Annotated, Any + +from pydantic import BeforeValidator, Field + +from ..models import BloomyBaseModel, OptionalFloat + + +def _parse_gql_datetime(value: Any) -> datetime | None: + """Convert a GraphQL unix-seconds timestamp into a timezone-aware datetime. + + Returns: + The converted datetime, or `None` if `value` is `None`. + + """ + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=UTC) + return datetime.fromtimestamp(float(value), tz=UTC) + + +# Reusable annotated types for the v2 API's unix-seconds timestamps. +type GqlDatetime = Annotated[datetime, BeforeValidator(_parse_gql_datetime)] +type GqlOptionalDatetime = Annotated[ + datetime | None, BeforeValidator(_parse_gql_datetime) +] + + +class UserRef(BloomyBaseModel): + """Minimal reference to a user, as nested inside other v2 entities. + + This is what GraphQL `assignee { id fullName }` projections return; use + `User` for the full `user`/`users` query result. + """ + + id: int + full_name: str | None = Field(default=None, alias="fullName") + + +class MeetingRef(BloomyBaseModel): + """Minimal reference to a meeting, as nested inside other v2 entities. + + This is what GraphQL `meeting { id name }` projections return; use + `Meeting` for the full `meeting` query result. + """ + + id: int + name: str | None = None + + +class User(BloomyBaseModel): + """Model for a full v2 user, from the `user`/`users` queries.""" + + id: int + first_name: str | None = Field(default=None, alias="firstName") + last_name: str | None = Field(default=None, alias="lastName") + full_name: str | None = Field(default=None, alias="fullName") + email: str | None = None + avatar: str | None = None + + +class MeetingListItem(BloomyBaseModel): + """Model for a meeting list item, from `user(id){ meetingsListLookup }`.""" + + id: int + name: str | None = None + meeting_type: str | None = Field(default=None, alias="meetingType") + created_date: GqlDatetime = Field(alias="createdTimestamp") + archived: bool = False + user_is_attendee: bool = Field(default=False, alias="userIsAttendee") + is_current_user_admin: bool = Field(default=False, alias="isCurrentUserAdmin") + + +class Meeting(BloomyBaseModel): + """Model for full meeting details, from the `meeting(id)` query.""" + + id: int + name: str | None = None + org_id: int | None = Field(default=None, alias="orgId") + meeting_type: str | None = Field(default=None, alias="meetingType") + created_date: GqlDatetime = Field(alias="createdTimestamp") + archived: bool = False + attendees: list[User] = Field(default_factory=list) + + +class Issue(BloomyBaseModel): + """Model for an issue, from the `issue(id)`/`meeting(id){ issues }` queries.""" + + id: int + title: str | None = None + owner: UserRef | None = Field(default=None, alias="assignee") + meeting: MeetingRef | None = None + recurrence_id: int | None = Field(default=None, alias="recurrenceId") + long_term: bool = Field(default=False, alias="addToDepartmentPlan") + notes_id: str | None = Field(default=None, alias="notesId") + notes: str | None = None + completed: bool = False + completed_date: GqlOptionalDatetime = Field( + default=None, alias="completedTimestamp" + ) + archived: bool = False + archived_date: GqlOptionalDatetime = Field(default=None, alias="archivedTimestamp") + created_date: GqlDatetime = Field(alias="dateCreated") + priority_vote_rank: int | None = Field(default=None, alias="priorityVoteRank") + num_star_votes: int = Field(default=0, alias="numStarVotes") + issue_number: int | None = Field(default=None, alias="issueNumber") + + +class Headline(BloomyBaseModel): + """Model for a headline, from `headline(id)`/`meeting(id){ headlines }` queries.""" + + id: int + title: str | None = None + owner: UserRef | None = Field(default=None, alias="assignee") + meeting: MeetingRef | None = None + recurrence_id: int | None = Field(default=None, alias="recurrenceId") + notes_id: str | None = Field(default=None, alias="notesId") + notes: str | None = None + archived: bool = False + archived_date: GqlOptionalDatetime = Field(default=None, alias="archivedTimestamp") + created_date: GqlDatetime = Field(alias="dateCreated") + + +class Todo(BloomyBaseModel): + """Model for a to-do, from the `todo(id)`/`meeting(id){ todos }` queries.""" + + id: int + title: str | None = None + owner: UserRef | None = Field(default=None, alias="assignee") + meeting: MeetingRef | None = None + due_date: GqlOptionalDatetime = Field(default=None, alias="dueDate") + completed: bool = False + completed_date: GqlOptionalDatetime = Field( + default=None, alias="completedTimestamp" + ) + archived: bool = False + archived_date: GqlOptionalDatetime = Field(default=None, alias="archivedTimestamp") + created_date: GqlDatetime = Field(alias="dateCreated") + notes_id: str | None = Field(default=None, alias="notesId") + notes: str | None = None + + +class GoalStatus(StrEnum): + """Status of a v2 goal (rock), matching the GraphQL API's `gqlGoalStatus` values. + + Distinct from `bloomy.models.GoalStatus` (v1's REST enum), whose values + (`on`, `off`, `complete`) do not match these. + """ + + ON_TRACK = "ON_TRACK" + OFF_TRACK = "OFF_TRACK" + COMPLETED = "COMPLETED" + + +class Milestone(BloomyBaseModel): + """Model for a goal milestone, from `goal(id){ milestones }`. + + There is no root `milestone(id)` query in the GraphQL API: a milestone is + only reachable through its parent goal (`goal_id`). + """ + + id: int + goal_id: int = Field(alias="goalId") + title: str | None = None + due_date: GqlDatetime = Field(alias="dueDate") + completed: bool = False + status: str | None = None + created_date: GqlOptionalDatetime = Field(default=None, alias="dateCreated") + + +class Goal(BloomyBaseModel): + """Model for a goal (rock), from the `goal(id)`/`goals(userId)` queries.""" + + id: int + title: str | None = None + status: GoalStatus = GoalStatus.ON_TRACK + owner: UserRef | None = Field(default=None, alias="assignee") + due_date: GqlOptionalDatetime = Field(default=None, alias="dueDate") + archived: bool = False + archived_date: GqlOptionalDatetime = Field(default=None, alias="archivedTimestamp") + created_date: GqlDatetime = Field(alias="dateCreated") + notes_id: str | None = Field(default=None, alias="notesId") + notes: str | None = None + milestones: list[Milestone] = Field(default_factory=list) + meetings: list[MeetingRef] = Field(default_factory=list) + + +class MetricUnit(StrEnum): + """Display unit for a v2 metric's scores, matching `gqlUnitType`.""" + + NONE = "NONE" + DOLLAR = "DOLLAR" + PERCENT = "PERCENT" + POUND = "POUND" + EUROS = "EUROS" + PESOS = "PESOS" + YEN = "YEN" + YESNO = "YESNO" + INR = "INR" + DIRHAM = "DIRHAM" + RAND = "RAND" + XCG = "XCG" + + +class MetricRule(StrEnum): + """Comparison rule between a v2 metric's score and its goal. + + Matches the GraphQL API's `gqlLessGreater` values. + + Symbols: `GREATER_THAN` = `>=`, `GREATER_THAN_NOT_EQUAL` = `>`, + `LESS_THAN` = `<`, `LESS_THAN_OR_EQUAL` = `<=`, `EQUAL_TO` = `==`, + `BETWEEN` = `min_goal <= score <= max_goal`. + """ + + EQUAL_TO = "EQUAL_TO" + GREATER_THAN = "GREATER_THAN" + GREATER_THAN_NOT_EQUAL = "GREATER_THAN_NOT_EQUAL" + LESS_THAN = "LESS_THAN" + LESS_THAN_OR_EQUAL = "LESS_THAN_OR_EQUAL" + BETWEEN = "BETWEEN" + + +class MetricFrequency(StrEnum): + """Scoring cadence for a v2 metric, matching `gqlMetricFrequency`.""" + + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + QUARTERLY = "QUARTERLY" + DAILY = "DAILY" + + +class Metric(BloomyBaseModel): + """Model for a metric (KPI), from the `metric(id)`/`meeting(id){ metrics }` queries. + + `id` is always the Measurable id (aliased from `measurableId`). On + `meeting(id){ metrics }` the raw `id` field is instead a meeting-link id + (not the metric id), so this SDK never selects it -- see + `bloomy.v2.operations.metric`. + """ + + id: int = Field(alias="measurableId") + title: str | None = None + owner: UserRef | None = Field(default=None, alias="assignee") + units: MetricUnit + rule: MetricRule + frequency: MetricFrequency + goal: OptionalFloat = Field(default=None, alias="singleGoalValue") + min_goal: OptionalFloat = Field(default=None, alias="minGoalValue") + max_goal: OptionalFloat = Field(default=None, alias="maxGoalValue") + archived: bool = False + created_date: GqlDatetime = Field(alias="dateCreated") + notes_id: str | None = Field(default=None, alias="notesId") + notes: str | None = None + is_externally_synced: bool = Field(default=False, alias="isExternallySynced") + + +class MetricScore(BloomyBaseModel): + """Model for a metric score, from `metric(id){ scoresNonPaginated }`. + + There is no root `score(id)` query: a score is only reachable through + its parent metric (`metric_id`). + """ + + id: int + metric_id: int = Field(alias="measurableId") + value: OptionalFloat = None + week_date: GqlOptionalDatetime = Field(default=None, alias="timestamp") + notes: str | None = None diff --git a/src/bloomy/v2/operations/__init__.py b/src/bloomy/v2/operations/__init__.py new file mode 100644 index 0000000..36fb56f --- /dev/null +++ b/src/bloomy/v2/operations/__init__.py @@ -0,0 +1,29 @@ +"""GraphQL (v2) operations for the Bloomy SDK.""" + +from .goal import AsyncGoalOperations, GoalOperations +from .headline import AsyncHeadlineOperations, HeadlineOperations +from .issue import AsyncIssueOperations, IssueOperations +from .meeting import AsyncMeetingOperations, MeetingOperations +from .metric import AsyncMetricOperations, MetricOperations +from .milestone import AsyncMilestoneOperations, MilestoneOperations +from .todo import AsyncTodoOperations, TodoOperations +from .user import AsyncUserOperations, UserOperations + +__all__ = [ + "AsyncGoalOperations", + "AsyncHeadlineOperations", + "AsyncIssueOperations", + "AsyncMeetingOperations", + "AsyncMetricOperations", + "AsyncMilestoneOperations", + "AsyncTodoOperations", + "AsyncUserOperations", + "GoalOperations", + "HeadlineOperations", + "IssueOperations", + "MeetingOperations", + "MetricOperations", + "MilestoneOperations", + "TodoOperations", + "UserOperations", +] diff --git a/src/bloomy/v2/operations/goal.py b/src/bloomy/v2/operations/goal.py new file mode 100644 index 0000000..31c6e98 --- /dev/null +++ b/src/bloomy/v2/operations/goal.py @@ -0,0 +1,645 @@ +"""Goal (rock) operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from datetime import date, datetime, timedelta +from typing import Any + +from ...exceptions import GraphQLError +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes +from ..models import Goal, GoalStatus, MeetingRef, Milestone + +# Kept in sync with `milestone.py`'s `_MILESTONE_FIELDS` (duplicated rather +# than imported, to keep the two entity modules independent). `dateDeleted` +# is already filtered server-side; the explicit filter matches the web app's +# own query. +_GOAL_MILESTONE_FIELDS = "id goalId title dueDate completed status dateCreated" + +_GOAL_FIELDS = f""" +id +title +status +dueDate +archived +archivedTimestamp +dateCreated +notesId +notesText +localHtml +collaborationEnabled +assignee {{ id fullName }} +meetings {{ + nodes {{ + id + name + }} +}} +milestones( + where: {{ and: [{{ dateDeleted: {{ eq: null }} }}] }} + order: [{{ dueDate: ASC }}] +) {{ + nodes {{ + {_GOAL_MILESTONE_FIELDS} + }} +}} +""" + + +class GoalOperationsMixin: + """Shared GraphQL documents and response transforms for goal operations.""" + + _GOAL_DETAILS_QUERY = f""" + query($id: Long!) {{ + goal(id: $id) {{ + {_GOAL_FIELDS} + }} + }} + """ + + _GOAL_LIST_BY_MEETING_QUERY = f""" + query($meetingId: Long!, $where: GoalQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + goals(where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_GOAL_FIELDS} + }} + }} + }} + }} + """ + + # `goals(userId)` (the root field) unconditionally excludes archived + # goals server-side regardless of `where` (verified live: a `where: + # {archived: {eq: true}}` filter still returns 0 results for a user with + # hundreds of archived goals). `user(id){ goals }` respects the filter + # (verified live), so it is used here instead to make `include_archived` + # actually work. + _GOAL_LIST_BY_USER_QUERY = f""" + query($userId: Long!, $where: GoalQueryModelFilterInput) {{ + user(id: $userId) {{ + goals(where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_GOAL_FIELDS} + }} + }} + }} + }} + """ + + _GOAL_CREATE_MUTATION = """ + mutation($input: GoalCreateModelInput!) { + CreateGoal(input: $input) { + id + } + } + """ + + # CreateGoal/EditGoal return only `IdModel { id }` (verified live), not + # the `{success message errorDetails}` shape `_run_mutation` expects. A + # failed mutation raises through the standard `errors` array instead + # (an invalid input throws server-side, including `IdModel(0)` on a + # failed create), so these are executed with plain `_execute`. `archive` + # additionally re-reads the goal, because `EditGoal{archived: true}` can + # report success without archiving anything (see `archive`'s docstring). + _GOAL_EDIT_MUTATION = """ + mutation($input: GoalEditModelInput!) { + EditGoal(input: $input) { + id + } + } + """ + + def _goal_where(self, *, include_archived: bool) -> dict[str, Any] | None: + """Build the `where` filter for a goal list connection. + + Returns: + A `GoalQueryModelFilterInput`-shaped dictionary, or `None` to + leave the connection unfiltered (both archived and active). + + """ + if include_archived: + return None + return {"and": [{"archived": {"eq": False}}]} + + def _parse_milestone_item( + self, item: dict[str, Any] | tuple[Any, ...] + ) -> tuple[Any, Any, bool]: + """Parse a `create()` milestone item into `(title, due_date, completed)`. + + `due_date` is returned as given (not yet converted to a unix + timestamp): that conversion needs `_to_timestamp`, a base-class + helper this mixin does not itself have access to (see + `GraphQLOperations`/`AsyncGraphQLOperations.create`, which call it + on the parsed result instead). + + Args: + item: Either `{"title": ..., "due_date": ..., "completed": ...}` + (`completed` optional) or a `(title, due_date)` / + `(title, due_date, completed)` tuple. + + Returns: + A `(title, due_date, completed)` tuple. + + """ + if isinstance(item, dict): + return item["title"], item["due_date"], item.get("completed", False) + title, due_date, *rest = item + return title, due_date, (rest[0] if rest else False) + + def _transform_goal(self, data: dict[str, Any]) -> Goal: + """Transform a raw GraphQL goal object into a `Goal` model. + + Returns: + A `Goal` model instance. + + """ + milestone_nodes = dig_nodes(data, "milestones") + meeting_nodes = dig_nodes(data, "meetings") + rest = { + key: value + for key, value in data.items() + if key not in ("milestones", "meetings") + } + return Goal( + **rest, + notes=extract_notes(data), + milestones=[Milestone(**node) for node in milestone_nodes], + meetings=[MeetingRef(**node) for node in meeting_nodes], + ) + + +class GoalOperations(GraphQLOperations, GoalOperationsMixin): + """Class to handle v2 (GraphQL) operations related to goals (rocks).""" + + def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_archived: bool = False, + ) -> builtins.list[Goal]: + """List goals for a meeting or a user. + + Args: + meeting_id: List goals attached to this meeting. + user_id: List goals owned by this user (defaults to the current + user when both `meeting_id` and `user_id` are omitted). + include_archived: If `True`, also include archived goals. + + Returns: + A list of `Goal` model instances (each including its + milestones), ordered by due date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + Example: + ```python + client.v2.goal.list(349524) + # Returns: [Goal(id=1, title='Ship v2', ...), ...] + ``` + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._goal_where(include_archived=include_archived) + + if meeting_id is not None: + data = self._execute( + self._GOAL_LIST_BY_MEETING_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "goals") + else: + if user_id is None: + user_id = self.user_id + data = self._execute( + self._GOAL_LIST_BY_USER_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "user", "goals") + + return [self._transform_goal(node) for node in nodes] + + def details(self, goal_id: int) -> Goal: + """Get details for a goal, including its milestones. + + Args: + goal_id: The ID of the goal. + + Returns: + A `Goal` model instance. + + Example: + ```python + client.v2.goal.details(5265048) + # Returns: Goal(id=5265048, title='Ship v2', ...) + ``` + + """ + data = self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) + return self._transform_goal(data["goal"]) + + def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + due_date: datetime | date | float | int | None = None, + status: GoalStatus | str = GoalStatus.ON_TRACK, + notes: str | None = None, + milestones: builtins.list[dict[str, Any] | tuple[Any, ...]] | None = None, + ) -> Goal: + """Create a new goal. + + Args: + meeting_id: The ID of the meeting to attach the goal to. + title: The title of the goal. + user_id: The ID of the goal owner (defaults to the current user). + due_date: The due date, as a `datetime`, `date`, or unix + timestamp (seconds). Defaults to 90 days from today (00:00 UTC). + status: The initial status. + notes: Description text for the goal. + milestones: Milestones to create alongside the goal, each either + `{"title": ..., "due_date": ..., "completed": ...}` + (`completed` optional, defaults to `False`) or a + `(title, due_date)` / `(title, due_date, completed)` tuple. + + Returns: + The newly created `Goal`. + + Example: + ```python + client.v2.goal.create(349524, "Ship v2", notes="Launch details") + # Returns: Goal(id=456, title='Ship v2', ...) + ``` + + """ + if user_id is None: + user_id = self.user_id + if due_date is None: + due_date = date.today() + timedelta(days=90) + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "dueDate": self._to_timestamp(due_date), + "status": str(status), + "meetingsAndPlans": [ + {"meetingId": meeting_id, "addToDepartmentPlan": False} + ], + } + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + if milestones: + input_["milestones"] = [ + { + "title": parsed_title, + "dueDate": self._to_timestamp(parsed_due_date), + "completed": parsed_completed, + } + for parsed_title, parsed_due_date, parsed_completed in ( + self._parse_milestone_item(item) for item in milestones + ) + ] + + data = self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) + return self.details(data["CreateGoal"]["id"]) + + def update( + self, + goal_id: int, + *, + title: str | None = None, + status: GoalStatus | str | None = None, + due_date: datetime | date | float | int | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Goal: + """Update an existing goal. + + Args: + goal_id: The ID of the goal to update. + title: New title for the goal. + status: New status for the goal. + due_date: New due date, as a `datetime`, `date`, or unix + timestamp (seconds). + user_id: New owner for the goal. + notes: New description text for the goal. + + Returns: + The updated `Goal`. + + Raises: + ValueError: If no update fields are provided. + + Example: + ```python + client.v2.goal.update(5265048, title="New title") + # Returns: Goal(id=5265048, title='New title', ...) + ``` + + """ + if ( + title is None + and status is None + and due_date is None + and user_id is None + and notes is None + ): + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"goalId": goal_id} + if title is not None: + input_["title"] = title + if status is not None: + input_["status"] = str(status) + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if user_id is not None: + input_["assignee"] = user_id + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + self._execute(self._GOAL_EDIT_MUTATION, {"input": input_}) + return self.details(goal_id) + + def archive(self, goal_id: int) -> Goal: + """Archive a goal. + + Note: + Verified live: `EditGoal{archived: true}` can report success + without archiving anything, because the server detaches the goal + from its meetings through an un-awaited call whose exceptions + are lost, and the archive step itself is wrapped in a bare + `try/catch`. This re-reads the goal afterwards and raises + `GraphQLError` if it is still not archived. + + Args: + goal_id: The ID of the goal to archive. + + Returns: + The updated `Goal`. + + Raises: + GraphQLError: If the goal is still not archived after the edit. + + """ + self._execute( + self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": True}} + ) + result = self.details(goal_id) + if not result.archived: + raise GraphQLError( + f"archive goal {goal_id} failed: goal is still not archived" + ) + return result + + def restore(self, goal_id: int) -> Goal: + """Restore an archived goal. + + Note: + Verified live against production: `EditGoal{archived: false}` + reliably un-archives the goal and re-attaches the meeting links + that were detached within +/-3 minutes of the archive. + + Args: + goal_id: The ID of the goal to restore. + + Returns: + The updated `Goal`. + + """ + self._execute( + self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": False}} + ) + return self.details(goal_id) + + +class AsyncGoalOperations(AsyncGraphQLOperations, GoalOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to goals (rocks).""" + + async def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_archived: bool = False, + ) -> builtins.list[Goal]: + """List goals for a meeting or a user. + + Args: + meeting_id: List goals attached to this meeting. + user_id: List goals owned by this user (defaults to the current + user when both `meeting_id` and `user_id` are omitted). + include_archived: If `True`, also include archived goals. + + Returns: + A list of `Goal` model instances (each including its + milestones), ordered by due date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._goal_where(include_archived=include_archived) + + if meeting_id is not None: + data = await self._execute( + self._GOAL_LIST_BY_MEETING_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "goals") + else: + if user_id is None: + user_id = await self.get_user_id() + data = await self._execute( + self._GOAL_LIST_BY_USER_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "user", "goals") + + return [self._transform_goal(node) for node in nodes] + + async def details(self, goal_id: int) -> Goal: + """Get details for a goal, including its milestones. + + Args: + goal_id: The ID of the goal. + + Returns: + A `Goal` model instance. + + """ + data = await self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) + return self._transform_goal(data["goal"]) + + async def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + due_date: datetime | date | float | int | None = None, + status: GoalStatus | str = GoalStatus.ON_TRACK, + notes: str | None = None, + milestones: builtins.list[dict[str, Any] | tuple[Any, ...]] | None = None, + ) -> Goal: + """Create a new goal. + + Args: + meeting_id: The ID of the meeting to attach the goal to. + title: The title of the goal. + user_id: The ID of the goal owner (defaults to the current user). + due_date: The due date, as a `datetime`, `date`, or unix + timestamp (seconds). Defaults to 90 days from today (00:00 UTC). + status: The initial status. + notes: Description text for the goal. + milestones: Milestones to create alongside the goal, each either + `{"title": ..., "due_date": ..., "completed": ...}` + (`completed` optional, defaults to `False`) or a + `(title, due_date)` / `(title, due_date, completed)` tuple. + + Returns: + The newly created `Goal`. + + """ + if user_id is None: + user_id = await self.get_user_id() + if due_date is None: + due_date = date.today() + timedelta(days=90) + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "dueDate": self._to_timestamp(due_date), + "status": str(status), + "meetingsAndPlans": [ + {"meetingId": meeting_id, "addToDepartmentPlan": False} + ], + } + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + if milestones: + input_["milestones"] = [ + { + "title": parsed_title, + "dueDate": self._to_timestamp(parsed_due_date), + "completed": parsed_completed, + } + for parsed_title, parsed_due_date, parsed_completed in ( + self._parse_milestone_item(item) for item in milestones + ) + ] + + data = await self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) + return await self.details(data["CreateGoal"]["id"]) + + async def update( + self, + goal_id: int, + *, + title: str | None = None, + status: GoalStatus | str | None = None, + due_date: datetime | date | float | int | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Goal: + """Update an existing goal. + + Args: + goal_id: The ID of the goal to update. + title: New title for the goal. + status: New status for the goal. + due_date: New due date, as a `datetime`, `date`, or unix + timestamp (seconds). + user_id: New owner for the goal. + notes: New description text for the goal. + + Returns: + The updated `Goal`. + + Raises: + ValueError: If no update fields are provided. + + """ + if ( + title is None + and status is None + and due_date is None + and user_id is None + and notes is None + ): + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"goalId": goal_id} + if title is not None: + input_["title"] = title + if status is not None: + input_["status"] = str(status) + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if user_id is not None: + input_["assignee"] = user_id + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + await self._execute(self._GOAL_EDIT_MUTATION, {"input": input_}) + return await self.details(goal_id) + + async def archive(self, goal_id: int) -> Goal: + """Archive a goal. + + Note: + Verified live: `EditGoal{archived: true}` can report success + without archiving anything, because the server detaches the goal + from its meetings through an un-awaited call whose exceptions + are lost, and the archive step itself is wrapped in a bare + `try/catch`. This re-reads the goal afterwards and raises + `GraphQLError` if it is still not archived. + + Args: + goal_id: The ID of the goal to archive. + + Returns: + The updated `Goal`. + + Raises: + GraphQLError: If the goal is still not archived after the edit. + + """ + await self._execute( + self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": True}} + ) + result = await self.details(goal_id) + if not result.archived: + raise GraphQLError( + f"archive goal {goal_id} failed: goal is still not archived" + ) + return result + + async def restore(self, goal_id: int) -> Goal: + """Restore an archived goal. + + Note: + Verified live against production: `EditGoal{archived: false}` + reliably un-archives the goal and re-attaches the meeting links + that were detached within +/-3 minutes of the archive. + + Args: + goal_id: The ID of the goal to restore. + + Returns: + The updated `Goal`. + + """ + await self._execute( + self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": False}} + ) + return await self.details(goal_id) diff --git a/src/bloomy/v2/operations/headline.py b/src/bloomy/v2/operations/headline.py new file mode 100644 index 0000000..abae47c --- /dev/null +++ b/src/bloomy/v2/operations/headline.py @@ -0,0 +1,468 @@ +"""Headline operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes +from ..models import Headline + +_HEADLINE_FIELDS = """ +id +title +recurrenceId +notesId +notesText +localHtml +collaborationEnabled +archived +archivedTimestamp +dateCreated +assignee { id fullName } +meeting { id name } +""" + + +class HeadlineOperationsMixin: + """Shared GraphQL documents and response transforms for headline operations.""" + + _HEADLINE_DETAILS_QUERY = f""" + query($id: Long!) {{ + headline(id: $id) {{ + {_HEADLINE_FIELDS} + }} + }} + """ + + # `meeting.headlines` returns only OPEN headlines (`CloseTime == null`); + # `meeting.archivedHeadlines` is a separate connection for archived ones. + _HEADLINE_MEETING_LIST_QUERY = f""" + query($meetingId: Long!) {{ + meeting(id: $meetingId) {{ + headlines(order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + }} + }} + """ + + _HEADLINE_MEETING_ARCHIVED_QUERY = f""" + query($meetingId: Long!) {{ + meeting(id: $meetingId) {{ + archivedHeadlines(order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + }} + }} + """ + + # Root `headlines(userId)` returns every headline owned by the user, + # archived ones included, with no way to filter server-side beyond + # `where`; filtering is done client-side in `list()` instead. + _HEADLINE_USER_LIST_QUERY = f""" + query($userId: Long!) {{ + headlines(userId: $userId, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + }} + """ + + _HEADLINE_CREATE_MUTATION = """ + mutation($input: HeadlineCreateModelInput!) { + CreateHeadline(input: $input) { + id + } + } + """ + + # `EditHeadline` returns `IdModel { id }`, not the + # `{success message errorDetails}` shape `EditIssue` returns, so it is + # run with a plain `_execute` rather than `_run_mutation`; a failure + # surfaces as a top-level GraphQL `errors` entry instead. + _HEADLINE_EDIT_MUTATION = """ + mutation($input: HeadlineEditModelInput!) { + EditHeadline(input: $input) { + id + } + } + """ + + def _transform_headline(self, data: dict[str, Any]) -> Headline: + """Transform a raw GraphQL headline object into a `Headline` model. + + Returns: + A `Headline` model instance. + + """ + return Headline(**data, notes=extract_notes(data)) + + +class HeadlineOperations(GraphQLOperations, HeadlineOperationsMixin): + """Class to handle v2 (GraphQL) operations related to headlines.""" + + def details(self, headline_id: int) -> Headline: + """Get details for a headline. + + Args: + headline_id: The ID of the headline. + + Returns: + A `Headline` model instance. + + Example: + ```python + client.v2.headline.details(123) + # Returns: Headline(id=123, title='Headline Title', ...) + ``` + + """ + data = self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) + return self._transform_headline(data["headline"]) + + def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_archived: bool = False, + ) -> builtins.list[Headline]: + """List headlines for a meeting or a user. + + Args: + meeting_id: The ID of the meeting. Mutually exclusive with + `user_id`. + user_id: The ID of the headline owner. Mutually exclusive with + `meeting_id`. If neither is given, defaults to the current + user. + include_archived: If `True`, also include archived headlines. + + Returns: + A list of `Headline` model instances, ordered by creation date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + Example: + ```python + client.v2.headline.list(meeting_id=349524) + # Returns: [Headline(id=1, title='Headline 1', ...), ...] + ``` + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + if meeting_id is not None: + data = self._execute( + self._HEADLINE_MEETING_LIST_QUERY, {"meetingId": meeting_id} + ) + nodes = dig_nodes(data, "meeting", "headlines") + if include_archived: + archived_data = self._execute( + self._HEADLINE_MEETING_ARCHIVED_QUERY, {"meetingId": meeting_id} + ) + nodes = nodes + dig_nodes(archived_data, "meeting", "archivedHeadlines") + return [self._transform_headline(node) for node in nodes] + + if user_id is None: + user_id = self.user_id + data = self._execute(self._HEADLINE_USER_LIST_QUERY, {"userId": user_id}) + nodes = dig_nodes(data, "headlines") + if not include_archived: + nodes = [node for node in nodes if not node.get("archived")] + return [self._transform_headline(node) for node in nodes] + + def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + notes: str | None = None, + ) -> Headline: + """Create a new headline. + + Args: + meeting_id: The ID of the meeting to create the headline in. + `CreateHeadline` requires exactly one meeting. + title: The title of the headline. + user_id: The ID of the headline owner (defaults to the current + user). + notes: Description text for the headline. + + Returns: + The newly created `Headline`. + + Example: + ```python + client.v2.headline.create(349524, "New Headline", notes="Details") + # Returns: Headline(id=456, title='New Headline', ...) + ``` + + """ + if user_id is None: + user_id = self.user_id + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "meetings": [meeting_id], + } + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + data = self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) + return self.details(data["CreateHeadline"]["id"]) + + def update( + self, + headline_id: int, + *, + title: str | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Headline: + """Update an existing headline. + + Args: + headline_id: The ID of the headline to update. + title: New title for the headline. + user_id: New owner for the headline. + notes: New description text for the headline. + + Returns: + The updated `Headline`. + + Raises: + ValueError: If no update fields are provided. + + Example: + ```python + client.v2.headline.update(123, title="New Title") + # Returns: Headline(id=123, title='New Title', ...) + ``` + + """ + if title is None and user_id is None and notes is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"headlineId": headline_id} + if title is not None: + input_["title"] = title + if user_id is not None: + input_["assignee"] = user_id + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + self._execute(self._HEADLINE_EDIT_MUTATION, {"input": input_}) + return self.details(headline_id) + + def archive(self, headline_id: int) -> Headline: + """Archive a headline. + + Args: + headline_id: The ID of the headline to archive. + + Returns: + The updated `Headline`. + + """ + self._execute( + self._HEADLINE_EDIT_MUTATION, + {"input": {"headlineId": headline_id, "archived": True}}, + ) + return self.details(headline_id) + + def restore(self, headline_id: int) -> Headline: + """Restore an archived headline. + + Args: + headline_id: The ID of the headline to restore. + + Returns: + The updated `Headline`. + + """ + self._execute( + self._HEADLINE_EDIT_MUTATION, + {"input": {"headlineId": headline_id, "archived": False}}, + ) + return self.details(headline_id) + + +class AsyncHeadlineOperations(AsyncGraphQLOperations, HeadlineOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to headlines.""" + + async def details(self, headline_id: int) -> Headline: + """Get details for a headline. + + Args: + headline_id: The ID of the headline. + + Returns: + A `Headline` model instance. + + """ + data = await self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) + return self._transform_headline(data["headline"]) + + async def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_archived: bool = False, + ) -> builtins.list[Headline]: + """List headlines for a meeting or a user. + + Args: + meeting_id: The ID of the meeting. Mutually exclusive with + `user_id`. + user_id: The ID of the headline owner. Mutually exclusive with + `meeting_id`. If neither is given, defaults to the current + user. + include_archived: If `True`, also include archived headlines. + + Returns: + A list of `Headline` model instances, ordered by creation date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + if meeting_id is not None: + data = await self._execute( + self._HEADLINE_MEETING_LIST_QUERY, {"meetingId": meeting_id} + ) + nodes = dig_nodes(data, "meeting", "headlines") + if include_archived: + archived_data = await self._execute( + self._HEADLINE_MEETING_ARCHIVED_QUERY, {"meetingId": meeting_id} + ) + nodes = nodes + dig_nodes(archived_data, "meeting", "archivedHeadlines") + return [self._transform_headline(node) for node in nodes] + + if user_id is None: + user_id = await self.get_user_id() + data = await self._execute(self._HEADLINE_USER_LIST_QUERY, {"userId": user_id}) + nodes = dig_nodes(data, "headlines") + if not include_archived: + nodes = [node for node in nodes if not node.get("archived")] + return [self._transform_headline(node) for node in nodes] + + async def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + notes: str | None = None, + ) -> Headline: + """Create a new headline. + + Args: + meeting_id: The ID of the meeting to create the headline in. + `CreateHeadline` requires exactly one meeting. + title: The title of the headline. + user_id: The ID of the headline owner (defaults to the current + user). + notes: Description text for the headline. + + Returns: + The newly created `Headline`. + + """ + if user_id is None: + user_id = await self.get_user_id() + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "meetings": [meeting_id], + } + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + data = await self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) + return await self.details(data["CreateHeadline"]["id"]) + + async def update( + self, + headline_id: int, + *, + title: str | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Headline: + """Update an existing headline. + + Args: + headline_id: The ID of the headline to update. + title: New title for the headline. + user_id: New owner for the headline. + notes: New description text for the headline. + + Returns: + The updated `Headline`. + + Raises: + ValueError: If no update fields are provided. + + """ + if title is None and user_id is None and notes is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"headlineId": headline_id} + if title is not None: + input_["title"] = title + if user_id is not None: + input_["assignee"] = user_id + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + await self._execute(self._HEADLINE_EDIT_MUTATION, {"input": input_}) + return await self.details(headline_id) + + async def archive(self, headline_id: int) -> Headline: + """Archive a headline. + + Args: + headline_id: The ID of the headline to archive. + + Returns: + The updated `Headline`. + + """ + await self._execute( + self._HEADLINE_EDIT_MUTATION, + {"input": {"headlineId": headline_id, "archived": True}}, + ) + return await self.details(headline_id) + + async def restore(self, headline_id: int) -> Headline: + """Restore an archived headline. + + Args: + headline_id: The ID of the headline to restore. + + Returns: + The updated `Headline`. + + """ + await self._execute( + self._HEADLINE_EDIT_MUTATION, + {"input": {"headlineId": headline_id, "archived": False}}, + ) + return await self.details(headline_id) diff --git a/src/bloomy/v2/operations/issue.py b/src/bloomy/v2/operations/issue.py new file mode 100644 index 0000000..fd2427b --- /dev/null +++ b/src/bloomy/v2/operations/issue.py @@ -0,0 +1,681 @@ +"""Issue operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import ( + MUTATION_RESULT_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + dig_nodes, + extract_notes, +) +from ..models import Issue + +_ISSUE_FIELDS = """ +id +title +recurrenceId +addToDepartmentPlan +notesId +notesText +localHtml +collaborationEnabled +completed +completedTimestamp +archived +archivedTimestamp +dateCreated +priorityVoteRank +numStarVotes +issueNumber +assignee { id fullName } +meeting { id name } +""" + + +class IssueOperationsMixin: + """Shared GraphQL documents and response transforms for issue operations.""" + + _ISSUE_DETAILS_QUERY = f""" + query($id: Long!) {{ + issue(id: $id) {{ + {_ISSUE_FIELDS} + }} + }} + """ + + # `meeting.issues` excludes solved and archived issues unconditionally + # (it filters `CloseTime == null` server-side); `recentlySolvedIssues` + # and `archivedIssues` are separate connections for those. + _ISSUE_LIST_QUERY = f""" + query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + issues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + }} + """ + + # The API stores every long-term (department plan) issue with + # `archived: true`, so `meeting.issues` (which drops archived issues + # server-side) never returns them; this dedicated connection does. + _ISSUE_LONG_TERM_QUERY = f""" + query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + longTermIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + }} + """ + + _ISSUE_SOLVED_QUERY = f""" + query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + recentlySolvedIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + }} + """ + + _ISSUE_ARCHIVED_QUERY = f""" + query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + archivedIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + }} + """ + + _ISSUE_CREATE_MUTATION = """ + mutation($input: IssueCreateModelInput!) { + CreateIssue(input: $input) { + id + } + } + """ + + _ISSUE_EDIT_MUTATION = f""" + mutation($input: IssueEditModelInput!) {{ + EditIssue(input: $input) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + def _issue_where(self, *, long_term: bool) -> dict[str, Any]: + """Build the base `where` filter shared by every issue list connection. + + Returns: + An `IssueQueryModelFilterInput`-shaped dictionary. + + """ + conditions: list[dict[str, Any]] = [{"addToDepartmentPlan": {"eq": long_term}}] + if not long_term: + # Excludes issues sent to another meeting; only meaningful for the + # short-term open list (mirrors the web app's own query). + conditions.append({"sentToIssueMeetingName": {"eq": None}}) + return {"and": conditions} + + def _transform_issue(self, data: dict[str, Any]) -> Issue: + """Transform a raw GraphQL issue object into an `Issue` model. + + The API reports every long-term issue as archived, while the web app + lists them on the Long-Term tab. Archiving a long-term issue clears + `addToDepartmentPlan`, so an issue counts as archived only when it is + archived and not long-term. + + Returns: + An `Issue` model instance. + + """ + fields: dict[str, Any] = { + **data, + "archived": bool(data.get("archived")) + and not data.get("addToDepartmentPlan"), + } + return Issue(**fields, notes=extract_notes(data)) + + +class IssueOperations(GraphQLOperations, IssueOperationsMixin): + """Class to handle v2 (GraphQL) operations related to issues.""" + + def details(self, issue_id: int) -> Issue: + """Get details for an issue. + + Args: + issue_id: The ID of the issue. + + Returns: + An `Issue` model instance. + + Example: + ```python + client.v2.issue.details(123) + # Returns: Issue(id=123, title='Issue Title', ...) + ``` + + """ + data = self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) + return self._transform_issue(data["issue"]) + + def list( + self, + meeting_id: int, + *, + long_term: bool = False, + include_solved: bool = False, + include_archived: bool = False, + ) -> builtins.list[Issue]: + """List issues for a meeting. + + Args: + meeting_id: The ID of the meeting. + long_term: If `True`, list long-term (department plan) issues + instead of short-term ones. `include_solved` and + `include_archived` have no effect in this mode. + include_solved: If `True`, also include recently solved issues. + include_archived: If `True`, also include archived issues. + + Returns: + A list of `Issue` model instances, ordered by creation date. + + Example: + ```python + client.v2.issue.list(349524) + # Returns: [Issue(id=1, title='Issue 1', ...), ...] + ``` + + """ + where = self._issue_where(long_term=long_term) + + if long_term: + data = self._execute( + self._ISSUE_LONG_TERM_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes = dig_nodes(data, "meeting", "longTermIssues") + return [self._transform_issue(node) for node in nodes] + + data = self._execute( + self._ISSUE_LIST_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes = dig_nodes(data, "meeting", "issues") + + if include_solved: + solved_data = self._execute( + self._ISSUE_SOLVED_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes += dig_nodes(solved_data, "meeting", "recentlySolvedIssues") + + if include_archived: + archived_data = self._execute( + self._ISSUE_ARCHIVED_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes += dig_nodes(archived_data, "meeting", "archivedIssues") + + seen: dict[int, dict[str, Any]] = {} + for node in nodes: + seen.setdefault(node["id"], node) + return [self._transform_issue(node) for node in seen.values()] + + def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + notes: str | None = None, + long_term: bool = False, + ) -> Issue: + """Create a new issue. + + Args: + meeting_id: The ID of the meeting to create the issue in. For L10 + meetings this doubles as the recurrence id `CreateIssue` needs. + title: The title of the issue. + user_id: The ID of the issue owner (defaults to the current user). + notes: Description text for the issue. + long_term: Whether to create it directly as a long-term + (department plan) issue. + + Returns: + The newly created `Issue`. + + Example: + ```python + client.v2.issue.create(349524, "New Issue", notes="Details") + # Returns: Issue(id=456, title='New Issue', ...) + ``` + + """ + if user_id is None: + user_id = self.user_id + + input_: dict[str, Any] = { + "title": title, + "ownerId": user_id, + "recurrenceId": meeting_id, + "addToDepartmentPlan": long_term, + } + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + data = self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) + return self.details(data["CreateIssue"]["id"]) + + def update( + self, + issue_id: int, + *, + title: str | None = None, + user_id: int | None = None, + notes: str | None = None, + long_term: bool | None = None, + meeting_id: int | None = None, + ) -> Issue: + """Update an existing issue. + + Args: + issue_id: The ID of the issue to update. + title: New title for the issue. + user_id: New owner for the issue. + notes: New description text for the issue. + long_term: Move the issue to/from the long-term (department plan) + list. + meeting_id: Move the issue to a different meeting. + + Returns: + The updated `Issue`. + + Raises: + ValueError: If no update fields are provided. + + Example: + ```python + client.v2.issue.update(123, title="New Title") + # Returns: Issue(id=123, title='New Title', ...) + ``` + + """ + if ( + title is None + and user_id is None + and notes is None + and long_term is None + and meeting_id is None + ): + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"id": issue_id} + if title is not None: + input_["title"] = title + if user_id is not None: + input_["assigneeId"] = user_id + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + if long_term is not None: + input_["addToDepartmentPlan"] = long_term + if meeting_id is not None: + input_["meetingId"] = meeting_id + + self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": input_}, + root_field="EditIssue", + action="update issue", + ) + return self.details(issue_id) + + def solve(self, issue_id: int) -> Issue: + """Mark an issue as solved. + + Note: + Verified live: this has no effect on an already-archived issue + (including a long-term one) — `completedTimestamp` is updated but + `completed` stays `False`. Solve issues before archiving them. + + Args: + issue_id: The ID of the issue to solve. + + Returns: + The updated `Issue`. + + """ + self._run_mutation( + self._ISSUE_EDIT_MUTATION, + { + "input": { + "id": issue_id, + "completed": True, + "completedTimestamp": self._now_timestamp(), + } + }, + root_field="EditIssue", + action="solve issue", + ) + return self.details(issue_id) + + def reopen(self, issue_id: int) -> Issue: + """Reopen a solved issue. + + Args: + issue_id: The ID of the issue to reopen. + + Returns: + The updated `Issue`. + + """ + self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "completed": False, "completedTimestamp": None}}, + root_field="EditIssue", + action="reopen issue", + ) + return self.details(issue_id) + + def archive(self, issue_id: int) -> Issue: + """Archive an issue. + + Args: + issue_id: The ID of the issue to archive. + + Returns: + The updated `Issue`. + + """ + self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "archived": True}}, + root_field="EditIssue", + action="archive issue", + ) + return self.details(issue_id) + + def restore(self, issue_id: int) -> Issue: + """Restore an archived issue. + + An archived long-term issue is restored to the short-term list, + because archiving it removed it from the long-term list. + + Args: + issue_id: The ID of the issue to restore. + + Returns: + The updated `Issue`. + + """ + self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "archived": False}}, + root_field="EditIssue", + action="restore issue", + ) + return self.details(issue_id) + + +class AsyncIssueOperations(AsyncGraphQLOperations, IssueOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to issues.""" + + async def details(self, issue_id: int) -> Issue: + """Get details for an issue. + + Args: + issue_id: The ID of the issue. + + Returns: + An `Issue` model instance. + + """ + data = await self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) + return self._transform_issue(data["issue"]) + + async def list( + self, + meeting_id: int, + *, + long_term: bool = False, + include_solved: bool = False, + include_archived: bool = False, + ) -> builtins.list[Issue]: + """List issues for a meeting. + + Args: + meeting_id: The ID of the meeting. + long_term: If `True`, list long-term (department plan) issues + instead of short-term ones. `include_solved` and + `include_archived` have no effect in this mode. + include_solved: If `True`, also include recently solved issues. + include_archived: If `True`, also include archived issues. + + Returns: + A list of `Issue` model instances, ordered by creation date. + + """ + where = self._issue_where(long_term=long_term) + + if long_term: + data = await self._execute( + self._ISSUE_LONG_TERM_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes = dig_nodes(data, "meeting", "longTermIssues") + return [self._transform_issue(node) for node in nodes] + + data = await self._execute( + self._ISSUE_LIST_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes = dig_nodes(data, "meeting", "issues") + + if include_solved: + solved_data = await self._execute( + self._ISSUE_SOLVED_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes += dig_nodes(solved_data, "meeting", "recentlySolvedIssues") + + if include_archived: + archived_data = await self._execute( + self._ISSUE_ARCHIVED_QUERY, {"meetingId": meeting_id, "where": where} + ) + nodes += dig_nodes(archived_data, "meeting", "archivedIssues") + + seen: dict[int, dict[str, Any]] = {} + for node in nodes: + seen.setdefault(node["id"], node) + return [self._transform_issue(node) for node in seen.values()] + + async def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + notes: str | None = None, + long_term: bool = False, + ) -> Issue: + """Create a new issue. + + Args: + meeting_id: The ID of the meeting to create the issue in. For L10 + meetings this doubles as the recurrence id `CreateIssue` needs. + title: The title of the issue. + user_id: The ID of the issue owner (defaults to the current user). + notes: Description text for the issue. + long_term: Whether to create it directly as a long-term + (department plan) issue. + + Returns: + The newly created `Issue`. + + """ + if user_id is None: + user_id = await self.get_user_id() + + input_: dict[str, Any] = { + "title": title, + "ownerId": user_id, + "recurrenceId": meeting_id, + "addToDepartmentPlan": long_term, + } + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + data = await self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) + return await self.details(data["CreateIssue"]["id"]) + + async def update( + self, + issue_id: int, + *, + title: str | None = None, + user_id: int | None = None, + notes: str | None = None, + long_term: bool | None = None, + meeting_id: int | None = None, + ) -> Issue: + """Update an existing issue. + + Args: + issue_id: The ID of the issue to update. + title: New title for the issue. + user_id: New owner for the issue. + notes: New description text for the issue. + long_term: Move the issue to/from the long-term (department plan) + list. + meeting_id: Move the issue to a different meeting. + + Returns: + The updated `Issue`. + + Raises: + ValueError: If no update fields are provided. + + """ + if ( + title is None + and user_id is None + and notes is None + and long_term is None + and meeting_id is None + ): + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"id": issue_id} + if title is not None: + input_["title"] = title + if user_id is not None: + input_["assigneeId"] = user_id + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + if long_term is not None: + input_["addToDepartmentPlan"] = long_term + if meeting_id is not None: + input_["meetingId"] = meeting_id + + await self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": input_}, + root_field="EditIssue", + action="update issue", + ) + return await self.details(issue_id) + + async def solve(self, issue_id: int) -> Issue: + """Mark an issue as solved. + + Note: + Verified live: this has no effect on an already-archived issue + (including a long-term one) — `completedTimestamp` is updated but + `completed` stays `False`. Solve issues before archiving them. + + Args: + issue_id: The ID of the issue to solve. + + Returns: + The updated `Issue`. + + """ + await self._run_mutation( + self._ISSUE_EDIT_MUTATION, + { + "input": { + "id": issue_id, + "completed": True, + "completedTimestamp": self._now_timestamp(), + } + }, + root_field="EditIssue", + action="solve issue", + ) + return await self.details(issue_id) + + async def reopen(self, issue_id: int) -> Issue: + """Reopen a solved issue. + + Args: + issue_id: The ID of the issue to reopen. + + Returns: + The updated `Issue`. + + """ + await self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "completed": False, "completedTimestamp": None}}, + root_field="EditIssue", + action="reopen issue", + ) + return await self.details(issue_id) + + async def archive(self, issue_id: int) -> Issue: + """Archive an issue. + + Args: + issue_id: The ID of the issue to archive. + + Returns: + The updated `Issue`. + + """ + await self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "archived": True}}, + root_field="EditIssue", + action="archive issue", + ) + return await self.details(issue_id) + + async def restore(self, issue_id: int) -> Issue: + """Restore an archived issue. + + An archived long-term issue is restored to the short-term list, + because archiving it removed it from the long-term list. + + Args: + issue_id: The ID of the issue to restore. + + Returns: + The updated `Issue`. + + """ + await self._run_mutation( + self._ISSUE_EDIT_MUTATION, + {"input": {"id": issue_id, "archived": False}}, + root_field="EditIssue", + action="restore issue", + ) + return await self.details(issue_id) diff --git a/src/bloomy/v2/operations/meeting.py b/src/bloomy/v2/operations/meeting.py new file mode 100644 index 0000000..82c708d --- /dev/null +++ b/src/bloomy/v2/operations/meeting.py @@ -0,0 +1,226 @@ +"""Meeting operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes +from ..models import Meeting, MeetingListItem, User + + +class MeetingOperationsMixin: + """Shared GraphQL documents and response transforms for meeting operations.""" + + # `user(id){ meetingsListLookup }` is used instead of the root `meetings` + # field, which is expensive and can trip the server's execution timeout. + _MEETING_LIST_QUERY = """ + query($userId: Long!) { + user(id: $userId) { + meetingsListLookup( + where: { and: [{ userIsAttendee: { eq: true } }] } + order: [{ name: ASC }] + ) { + nodes { + id + name + meetingType + createdTimestamp + archived + userIsAttendee + isCurrentUserAdmin + } + } + } + } + """ + + _ATTENDEE_FIELDS = "id firstName lastName fullName avatar user { email }" + + _MEETING_DETAILS_QUERY = f""" + query($id: Long!) {{ + meeting(id: $id) {{ + id + name + orgId + meetingType + createdTimestamp + archived + attendees(order: [{{ fullName: ASC }}]) {{ + nodes {{ + {_ATTENDEE_FIELDS} + }} + }} + }} + }} + """ + + _MEETING_ATTENDEES_QUERY = f""" + query($id: Long!) {{ + meeting(id: $id) {{ + attendees(order: [{{ fullName: ASC }}]) {{ + nodes {{ + {_ATTENDEE_FIELDS} + }} + }} + }} + }} + """ + + def _transform_attendee(self, node: dict[str, Any]) -> User: + """Transform a raw `attendees` connection node into a `User` model. + + Returns: + A `User` model instance. + + """ + user_ref: dict[str, Any] = node.get("user") or {} + # Constructed with alias keyword names (matching the raw GraphQL + # field names) rather than the model's snake_case field names: the + # model declares `Field(alias=...)` for these, and that alias is + # what pydantic's generated `__init__` exposes to static type + # checkers even though `validate_by_name=True` also accepts the + # field name at runtime. + return User( + id=node["id"], + firstName=node.get("firstName"), + lastName=node.get("lastName"), + fullName=node.get("fullName"), + avatar=node.get("avatar"), + email=user_ref.get("email"), + ) + + def _transform_meeting_list_item(self, node: dict[str, Any]) -> MeetingListItem: + """Transform a raw `meetingsListLookup` node into a `MeetingListItem`. + + Returns: + A `MeetingListItem` model instance. + + """ + return MeetingListItem(**node) + + def _transform_meeting(self, data: dict[str, Any]) -> Meeting: + """Transform a raw `meeting(id)` response into a `Meeting` model. + + Returns: + A `Meeting` model instance. + + """ + attendee_nodes = dig_nodes(data, "attendees") + return Meeting( + id=data["id"], + name=data.get("name"), + orgId=data.get("orgId"), + meetingType=data.get("meetingType"), + createdTimestamp=data["createdTimestamp"], + archived=data.get("archived", False), + attendees=[self._transform_attendee(node) for node in attendee_nodes], + ) + + +class MeetingOperations(GraphQLOperations, MeetingOperationsMixin): + """Class to handle v2 (GraphQL) operations related to meetings.""" + + def list(self, user_id: int | None = None) -> builtins.list[MeetingListItem]: + """List meetings a user attends. + + Args: + user_id: The ID of the user (defaults to the current user). + + Returns: + A list of `MeetingListItem` model instances, ordered by name. + + Example: + ```python + client.v2.meeting.list() + # Returns: [MeetingListItem(id=1, name='L10', ...), ...] + ``` + + """ + if user_id is None: + user_id = self.user_id + + data = self._execute(self._MEETING_LIST_QUERY, {"userId": user_id}) + nodes = dig_nodes(data, "user", "meetingsListLookup") + return [self._transform_meeting_list_item(node) for node in nodes] + + def details(self, meeting_id: int) -> Meeting: + """Get details for a meeting, including its attendees. + + Args: + meeting_id: The ID of the meeting. + + Returns: + A `Meeting` model instance. + + Example: + ```python + client.v2.meeting.details(349524) + # Returns: Meeting(id=349524, name='v2 API', ...) + ``` + + """ + data = self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) + return self._transform_meeting(data["meeting"]) + + def attendees(self, meeting_id: int) -> builtins.list[User]: + """List the attendees of a meeting. + + Args: + meeting_id: The ID of the meeting. + + Returns: + A list of `User` model instances, ordered by full name. + + """ + data = self._execute(self._MEETING_ATTENDEES_QUERY, {"id": meeting_id}) + nodes = dig_nodes(data, "meeting", "attendees") + return [self._transform_attendee(node) for node in nodes] + + +class AsyncMeetingOperations(AsyncGraphQLOperations, MeetingOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to meetings.""" + + async def list(self, user_id: int | None = None) -> builtins.list[MeetingListItem]: + """List meetings a user attends. + + Args: + user_id: The ID of the user (defaults to the current user). + + Returns: + A list of `MeetingListItem` model instances, ordered by name. + + """ + if user_id is None: + user_id = await self.get_user_id() + + data = await self._execute(self._MEETING_LIST_QUERY, {"userId": user_id}) + nodes = dig_nodes(data, "user", "meetingsListLookup") + return [self._transform_meeting_list_item(node) for node in nodes] + + async def details(self, meeting_id: int) -> Meeting: + """Get details for a meeting, including its attendees. + + Args: + meeting_id: The ID of the meeting. + + Returns: + A `Meeting` model instance. + + """ + data = await self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) + return self._transform_meeting(data["meeting"]) + + async def attendees(self, meeting_id: int) -> builtins.list[User]: + """List the attendees of a meeting. + + Args: + meeting_id: The ID of the meeting. + + Returns: + A list of `User` model instances, ordered by full name. + + """ + data = await self._execute(self._MEETING_ATTENDEES_QUERY, {"id": meeting_id}) + nodes = dig_nodes(data, "meeting", "attendees") + return [self._transform_attendee(node) for node in nodes] diff --git a/src/bloomy/v2/operations/metric.py b/src/bloomy/v2/operations/metric.py new file mode 100644 index 0000000..8059f8f --- /dev/null +++ b/src/bloomy/v2/operations/metric.py @@ -0,0 +1,1169 @@ +"""Metric (KPI/scorecard) operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from datetime import UTC, date, datetime +from typing import Any + +from ...exceptions import GraphQLError +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes +from ..models import Metric, MetricFrequency, MetricRule, MetricScore, MetricUnit + +# Deliberately does NOT select `id`: on `meeting(id){ metrics }` that field is +# the meeting-link id (`L10Recurrence_Measurable`), not the metric id, so +# selecting it risks the raw dict's `id` key shadowing `measurableId` when +# spread into `Metric(**data)` (both map to the model's aliased `id` field +# under `validate_by_name=True`). `measurableId` alone is always the real +# metric id, on every query path (`metric(id)`, `meeting.metrics`, +# `user.metrics`). +_METRIC_FIELDS = """ +measurableId +title +units +rule +frequency +singleGoalValue +minGoalValue +maxGoalValue +archived +metricType +notesId +notesText +localHtml +collaborationEnabled +dateCreated +isExternallySynced +assignee { id fullName } +""" + +_METRIC_SCORE_FIELDS = "id value timestamp notesText measurableId" + + +class MetricOperationsMixin: + """Shared GraphQL documents and response transforms for metric operations.""" + + _METRIC_DETAILS_QUERY = f""" + query($id: Long!) {{ + metric(id: $id) {{ + {_METRIC_FIELDS} + }} + }} + """ + + # `meeting.metrics` also returns metric-divider rows (`metricType: + # DIVIDER`, `measurableId: 0`) mixed in with real metrics; the `where` + # filter excludes them server-side (and archived rows, which this + # connection does not exclude on its own, unlike `user.metrics`). + _METRIC_MEETING_LIST_QUERY = f""" + query($meetingId: Long!, $where: MetricQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + metrics(where: $where, order: [{{ indexInTable: ASC }}]) {{ + nodes {{ + {_METRIC_FIELDS} + }} + }} + }} + }} + """ + + # `user(id){ metrics }` already excludes archived/deleted metrics + # server-side; the `archived` filter is kept for parity with the + # meeting-scoped query above. + _METRIC_USER_LIST_QUERY = f""" + query($userId: Long!, $where: MetricQueryModelFilterInput) {{ + user(id: $userId) {{ + metrics(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_METRIC_FIELDS} + }} + }} + }} + }} + """ + + # CreateMetric/EditMetric return only `IdModel { id }` (verified live + # against production), not the `{success message errorDetails}` shape + # `_run_mutation` expects. A failed create/edit raises through the + # standard `errors` array instead, so these are executed with plain + # `_execute`. + _METRIC_CREATE_MUTATION = """ + mutation($input: MetricCreateModelInput!) { + CreateMetric(input: $input) { + id + } + } + """ + + _METRIC_EDIT_MUTATION = """ + mutation($input: MetricEditModelInput!) { + EditMetric(input: $input) { + id + } + } + """ + + # `scoresNonPaginated` is a plain list, not a `{ nodes }` connection + # (unlike every other list field in the v2 API), so it is read directly + # rather than through `dig_nodes`. + _METRIC_SCORES_QUERY = f""" + query($metricId: Long!, $where: MetricScoreQueryModelFilterInput) {{ + metric(id: $metricId) {{ + scoresNonPaginated(where: $where, order: [{{ timestamp: DESC }}]) {{ + {_METRIC_SCORE_FIELDS} + }} + }} + }} + """ + + # CreateMetricScore/EditMetricScore also return `IdModel { id }`. + # CreateMetricScore additionally returns `{"CreateMetricScore": null}` + # with no `errors` entry when `value` cannot be parsed as a decimal + # (verified live) -- `set_score()` checks for that explicitly. + _METRIC_SCORE_CREATE_MUTATION = """ + mutation($input: MetricScoreCreateModelInput!) { + CreateMetricScore(input: $input) { + id + } + } + """ + + _METRIC_SCORE_EDIT_MUTATION = """ + mutation($input: MetricScoreEditModelInput!) { + EditMetricScore(input: $input) { + id + } + } + """ + + @staticmethod + def _metric_where(*, frequency: MetricFrequency | str | None) -> dict[str, Any]: + """Build the `where` filter shared by both metric list connections. + + Returns: + A `MetricQueryModelFilterInput`-shaped dictionary. + + """ + conditions: list[dict[str, Any]] = [ + {"archived": {"eq": False}}, + {"metricType": {"eq": "METRIC"}}, + ] + if frequency is not None: + conditions.append({"frequency": {"eq": str(frequency)}}) + return {"and": conditions} + + @staticmethod + def _metric_score_where( + start_ts: float | None, end_ts: float | None + ) -> dict[str, Any] | None: + """Build the `where` filter for a score timestamp range. + + Returns: + A `MetricScoreQueryModelFilterInput`-shaped dictionary, or `None` + if neither bound is given (no filtering). + + """ + conditions: list[dict[str, Any]] = [] + if start_ts is not None: + conditions.append({"timestamp": {"gte": start_ts}}) + if end_ts is not None: + conditions.append({"timestamp": {"lte": end_ts}}) + return {"and": conditions} if conditions else None + + @staticmethod + def _metric_goal_conflict( + rule: MetricRule | str | None, + goal: float | str | None, + min_goal: float | str | None, + max_goal: float | str | None, + ) -> str | None: + """Check that goal fields match the BETWEEN/non-BETWEEN rule split. + + `singleGoalValue` (`goal`) and `minGoalValue`/`maxGoalValue` + (`min_goal`/`max_goal`) are mutually exclusive server-side: `goal` is + for every rule except `BETWEEN`, and `min_goal`/`max_goal` are for + `BETWEEN` only. Each caller raises `ValueError` itself (so pydoclint + can see the `raise` and check the docstring), using this to build the + message. + + Returns: + A message describing the conflict, or `None` if the fields are + consistent. + + """ + has_range_goal = min_goal is not None or max_goal is not None + if rule is None: + if goal is not None and has_range_goal: + return "`goal` cannot be combined with `min_goal`/`max_goal`." + return None + is_between = str(rule) == MetricRule.BETWEEN + if is_between and goal is not None: + return ( + "`goal` cannot be used with rule=BETWEEN; " + "use `min_goal`/`max_goal` instead." + ) + if not is_between and has_range_goal: + return "`min_goal`/`max_goal` can only be used with rule=BETWEEN." + return None + + @staticmethod + def _metric_update_fields( + *, + title: str | None, + user_id: int | None, + units: MetricUnit | str | None, + rule: MetricRule | str | None, + goal: float | str | None, + min_goal: float | str | None, + max_goal: float | str | None, + ) -> dict[str, Any]: + """Build the non-`None` `EditMetric` fields shared by `update()`. + + Returns: + A dict of `MetricEditModelInput` fields (excluding `metricId` and + the notes fields, which `update()` sets separately). + + """ + fields: dict[str, Any] = {} + if title is not None: + fields["title"] = title + if user_id is not None: + fields["assignee"] = user_id + for key, value in ( + ("units", units), + ("rule", rule), + ("singleGoalValue", goal), + ("minGoalValue", min_goal), + ("maxGoalValue", max_goal), + ): + if value is not None: + fields[key] = str(value) + return fields + + @staticmethod + def _is_duplicate_score_error(exc: GraphQLError) -> bool: + """Check whether a `GraphQLError` is the DAILY "already exists" error. + + `CreateMetricScore` upserts weekly/monthly/quarterly scores by + period, but a DAILY score throws this error instead when one already + exists for the same day (verified live); `set_score()` falls back to + `EditMetricScore` in that case. + + Returns: + `True` if any raw error's `extensions.message` reports an + existing score for the same metric and date. + + """ + for error in exc.errors: + extensions: dict[str, Any] = error.get("extensions") or {} + message = str(extensions.get("message") or "") + if "already exists" in message.lower(): + return True + return False + + @staticmethod + def _scores_from(data: dict[str, Any]) -> builtins.list[dict[str, Any]]: + """Read the `scoresNonPaginated` list out of a metric query's `data`. + + Returns: + The raw score nodes, or an empty list if the metric was missing. + + """ + metric: dict[str, Any] = data.get("metric") or {} + return list(metric.get("scoresNonPaginated") or []) + + @staticmethod + def _same_day(timestamp: float, target: date) -> bool: + """Check whether a unix-seconds timestamp falls on `target` (UTC). + + Returns: + `True` if `timestamp`'s UTC calendar date equals `target`. + + """ + return datetime.fromtimestamp(timestamp, tz=UTC).date() == target + + def _transform_metric(self, data: dict[str, Any]) -> Metric: + """Transform a raw GraphQL metric object into a `Metric` model. + + Returns: + A `Metric` model instance. + + """ + return Metric(**data, notes=extract_notes(data)) + + def _transform_score(self, data: dict[str, Any]) -> MetricScore: + """Transform a raw GraphQL metric-score object into a `MetricScore` model. + + Scores carry a plain `notesText` note (no Etherpad pad, unlike + metrics themselves), so this reads it directly rather than through + `extract_notes`. + + Returns: + A `MetricScore` model instance. + + """ + return MetricScore(**data, notes=data.get("notesText") or None) + + +class MetricOperations(GraphQLOperations, MetricOperationsMixin): + """Class to handle v2 (GraphQL) operations related to metrics (KPIs). + + Note: + Archiving a metric cannot be undone through the GraphQL API + (`EditMetric(archived: false)` does not restore it -- it re-archives + it). There is no `restore()`. + + """ + + def details(self, metric_id: int) -> Metric: + """Get details for a metric. + + Args: + metric_id: The ID of the metric (its `measurableId`). + + Returns: + A `Metric` model instance. + + Example: + ```python + client.v2.metric.details(2036155) + # Returns: Metric(id=2036155, title='New Customers', ...) + ``` + + """ + data = self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) + return self._transform_metric(data["metric"]) + + def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + frequency: MetricFrequency | str | None = None, + ) -> builtins.list[Metric]: + """List metrics for a meeting or a user. + + Args: + meeting_id: The ID of the meeting. Mutually exclusive with + `user_id`. + user_id: The ID of the metric owner. Mutually exclusive with + `meeting_id`. If neither is given, defaults to the current + user. + frequency: If given, only list metrics with this scoring + frequency. + + Returns: + A list of `Metric` model instances. Archived metrics and + scorecard dividers are excluded. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + Example: + ```python + client.v2.metric.list(meeting_id=349524) + # Returns: [Metric(id=2036155, title='New Customers', ...), ...] + ``` + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._metric_where(frequency=frequency) + + if meeting_id is not None: + data = self._execute( + self._METRIC_MEETING_LIST_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "metrics") + else: + if user_id is None: + user_id = self.user_id + data = self._execute( + self._METRIC_USER_LIST_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "user", "metrics") + + return [ + self._transform_metric(node) + for node in nodes + if node.get("metricType") != "DIVIDER" + ] + + def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + goal: float | str | None = None, + units: MetricUnit | str = MetricUnit.NONE, + rule: MetricRule | str = MetricRule.GREATER_THAN, + frequency: MetricFrequency | str = MetricFrequency.WEEKLY, + min_goal: float | str | None = None, + max_goal: float | str | None = None, + notes: str | None = None, + ) -> Metric: + """Create a new metric (KPI). + + Args: + meeting_id: The ID of the meeting to attach the metric to. + title: The title of the metric. + user_id: The ID of the metric owner (defaults to the current + user). + goal: The single goal value. Use for every `rule` except + `MetricRule.BETWEEN`; mutually exclusive with `min_goal`/ + `max_goal`. + units: The display unit for scores. + rule: The comparison rule between a score and its goal. + frequency: The scoring cadence. + min_goal: The lower goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + max_goal: The upper goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + notes: Description text for the metric. + + Returns: + The newly created `Metric`. + + Raises: + ValueError: If `goal` is combined with `rule=MetricRule.BETWEEN`, + or if `min_goal`/`max_goal` is combined with any other rule. + + Example: + ```python + client.v2.metric.create(349524, "New Customers", goal=10) + # Returns: Metric(id=2036155, title='New Customers', ...) + ``` + + """ + conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) + if conflict: + raise ValueError(conflict) + if user_id is None: + user_id = self.user_id + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "units": str(units), + "rule": str(rule), + "frequency": str(frequency), + "averageOverrideType": "NONE", + "meetings": [meeting_id], + "customGoals": [], + } + if goal is not None: + input_["singleGoalValue"] = str(goal) + if min_goal is not None: + input_["minGoalValue"] = str(min_goal) + if max_goal is not None: + input_["maxGoalValue"] = str(max_goal) + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + data = self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) + return self.details(data["CreateMetric"]["id"]) + + def update( + self, + metric_id: int, + *, + title: str | None = None, + user_id: int | None = None, + goal: float | str | None = None, + min_goal: float | str | None = None, + max_goal: float | str | None = None, + units: MetricUnit | str | None = None, + rule: MetricRule | str | None = None, + notes: str | None = None, + ) -> Metric: + """Update an existing metric. + + Args: + metric_id: The ID of the metric to update. + title: New title for the metric. + user_id: New owner for the metric. + goal: New single goal value. Use for every `rule` except + `MetricRule.BETWEEN`; mutually exclusive with `min_goal`/ + `max_goal`. + min_goal: New lower goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + max_goal: New upper goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + units: New display unit for scores. + rule: New comparison rule between a score and its goal. + notes: New description text for the metric. + + Returns: + The updated `Metric`. + + Raises: + ValueError: If no update fields are provided, if `goal` is + combined with `rule=MetricRule.BETWEEN`, or if `min_goal`/ + `max_goal` is combined with a non-`BETWEEN` `rule`. + + Example: + ```python + client.v2.metric.update(2036155, title="New Title") + # Returns: Metric(id=2036155, title='New Title', ...) + ``` + + """ + if all( + field is None + for field in (title, user_id, goal, min_goal, max_goal, units, rule, notes) + ): + raise ValueError("At least one field must be provided for update") + + conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) + if conflict: + raise ValueError(conflict) + + input_: dict[str, Any] = { + "metricId": metric_id, + **self._metric_update_fields( + title=title, + user_id=user_id, + units=units, + rule=rule, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ), + } + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + self._execute(self._METRIC_EDIT_MUTATION, {"input": input_}) + return self.details(metric_id) + + def archive(self, metric_id: int) -> Metric: + """Archive a metric. + + Note: + This cannot be undone through the GraphQL API -- there is no + `restore()` (see the class `Note`). + + Args: + metric_id: The ID of the metric to archive. + + Returns: + The updated `Metric`. + + """ + self._execute( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, "archived": True}}, + ) + return self.details(metric_id) + + def scores( + self, + metric_id: int, + *, + start: datetime | date | float | int | None = None, + end: datetime | date | float | int | None = None, + include_empty: bool = False, + ) -> builtins.list[MetricScore]: + """List the scores of a metric. + + Args: + metric_id: The ID of the metric. + start: Only include scores at or after this time. + end: Only include scores at or before this time. + include_empty: If `True`, also include placeholder rows with no + value set. + + Returns: + A list of `MetricScore` model instances, most recent first. + + Example: + ```python + client.v2.metric.scores(2036155) + # Returns: [MetricScore(id=1, value=120.0, ...), ...] + ``` + + """ + start_ts = self._to_timestamp(start) if start is not None else None + end_ts = self._to_timestamp(end) if end is not None else None + where = self._metric_score_where(start_ts, end_ts) + + data = self._execute( + self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + ) + nodes = self._scores_from(data) + if not include_empty: + nodes = [node for node in nodes if node.get("value")] + return [self._transform_score(node) for node in nodes] + + def set_score( + self, + metric_id: int, + value: float | str, + timestamp: datetime | date | float | int, + ) -> MetricScore: + """Set a metric's score for a given time. + + For weekly/monthly/quarterly metrics this upserts the score for + whichever period `timestamp` falls in. For DAILY metrics, a score + already exists for every day (as an empty placeholder), so this + looks the existing score up and edits it instead. + + Args: + metric_id: The ID of the metric. + value: The score value (a decimal number). + timestamp: The time the score belongs to, as a `datetime`, + `date`, or unix timestamp (seconds). + + Returns: + The written `MetricScore`, re-read after the write. + + Raises: + GraphQLError: If `value` cannot be parsed as a decimal number. + + Example: + ```python + client.v2.metric.set_score(2036155, 120, date(2026, 9, 21)) + # Returns: MetricScore(id=1, value=120.0, ...) + ``` + + """ + ts = self._to_timestamp(timestamp) + try: + data = self._execute( + self._METRIC_SCORE_CREATE_MUTATION, + { + "input": { + "metricId": metric_id, + "value": str(value), + "timestamp": ts, + } + }, + ) + except GraphQLError as exc: + if not self._is_duplicate_score_error(exc): + raise + existing = self._find_score_for_day(metric_id, ts) + if existing is None: + raise + return self.update_score(metric_id, existing.id, value=value) + + result = data.get("CreateMetricScore") + if result is None: + raise GraphQLError( + f"CreateMetricScore: value {value!r} could not be parsed as a number" + ) + return self._read_score(metric_id, result["id"]) + + def update_score( + self, + metric_id: int, + score_id: int, + *, + value: float | str | None = None, + notes: str | None = None, + ) -> MetricScore: + """Update an existing metric score's value and/or note. + + Args: + metric_id: The ID of the score's parent metric, needed to + re-read it afterwards (there is no root `score(id)` query). + score_id: The ID of the score to update. + value: New score value. + notes: New note text for the score. + + Returns: + The updated `MetricScore`. + + Raises: + ValueError: If neither `value` nor `notes` is provided. + + Example: + ```python + client.v2.metric.update_score(2036155, 499964609, value=125) + # Returns: MetricScore(id=499964609, value=125.0, ...) + ``` + + """ + if value is None and notes is None: + raise ValueError("At least one of `value` or `notes` must be provided") + + input_: dict[str, Any] = {"id": score_id} + if value is not None: + input_["value"] = str(value) + if notes is not None: + input_["notesText"] = notes + + self._execute(self._METRIC_SCORE_EDIT_MUTATION, {"input": input_}) + return self._read_score(metric_id, score_id) + + def clear_score(self, metric_id: int, score_id: int) -> MetricScore: + """Clear a metric score's value, leaving an empty placeholder. + + Args: + metric_id: The ID of the score's parent metric, needed to + re-read it afterwards (there is no root `score(id)` query). + score_id: The ID of the score to clear. + + Returns: + The updated `MetricScore`, with `value=None`. + + """ + self._execute( + self._METRIC_SCORE_EDIT_MUTATION, {"input": {"id": score_id, "value": None}} + ) + return self._read_score(metric_id, score_id) + + def _read_score(self, metric_id: int, score_id: int) -> MetricScore: + """Re-read a single score through its parent metric. + + Returns: + The `MetricScore` model instance. + + Raises: + GraphQLError: If the score is not found under the metric. + + """ + data = self._execute( + self._METRIC_SCORES_QUERY, + {"metricId": metric_id, "where": {"and": [{"id": {"eq": score_id}}]}}, + ) + for node in self._scores_from(data): + if node.get("id") == score_id: + return self._transform_score(node) + raise GraphQLError(f"score {score_id} not found under metric {metric_id}") + + def _find_score_for_day(self, metric_id: int, ts: float) -> MetricScore | None: + """Find the pre-existing score whose UTC calendar day matches `ts`. + + Used by `set_score()` to fall back to `EditMetricScore` for DAILY + metrics, where a placeholder score already exists for every day. + + Returns: + The matching `MetricScore`, or `None` if not found. + + """ + target = datetime.fromtimestamp(ts, tz=UTC).date() + where = self._metric_score_where(ts - 172800, ts + 172800) + data = self._execute( + self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + ) + for node in self._scores_from(data): + node_ts = node.get("timestamp") + if node_ts is not None and self._same_day(node_ts, target): + return self._transform_score(node) + return None + + +class AsyncMetricOperations(AsyncGraphQLOperations, MetricOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to metrics (KPIs). + + Note: + See `MetricOperations` for why there is no `restore()`. + + """ + + async def details(self, metric_id: int) -> Metric: + """Get details for a metric. + + Args: + metric_id: The ID of the metric (its `measurableId`). + + Returns: + A `Metric` model instance. + + """ + data = await self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) + return self._transform_metric(data["metric"]) + + async def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + frequency: MetricFrequency | str | None = None, + ) -> builtins.list[Metric]: + """List metrics for a meeting or a user. + + Args: + meeting_id: The ID of the meeting. Mutually exclusive with + `user_id`. + user_id: The ID of the metric owner. Mutually exclusive with + `meeting_id`. If neither is given, defaults to the current + user. + frequency: If given, only list metrics with this scoring + frequency. + + Returns: + A list of `Metric` model instances. Archived metrics and + scorecard dividers are excluded. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._metric_where(frequency=frequency) + + if meeting_id is not None: + data = await self._execute( + self._METRIC_MEETING_LIST_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "metrics") + else: + if user_id is None: + user_id = await self.get_user_id() + data = await self._execute( + self._METRIC_USER_LIST_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "user", "metrics") + + return [ + self._transform_metric(node) + for node in nodes + if node.get("metricType") != "DIVIDER" + ] + + async def create( + self, + meeting_id: int, + title: str, + user_id: int | None = None, + goal: float | str | None = None, + units: MetricUnit | str = MetricUnit.NONE, + rule: MetricRule | str = MetricRule.GREATER_THAN, + frequency: MetricFrequency | str = MetricFrequency.WEEKLY, + min_goal: float | str | None = None, + max_goal: float | str | None = None, + notes: str | None = None, + ) -> Metric: + """Create a new metric (KPI). + + Args: + meeting_id: The ID of the meeting to attach the metric to. + title: The title of the metric. + user_id: The ID of the metric owner (defaults to the current + user). + goal: The single goal value. Use for every `rule` except + `MetricRule.BETWEEN`; mutually exclusive with `min_goal`/ + `max_goal`. + units: The display unit for scores. + rule: The comparison rule between a score and its goal. + frequency: The scoring cadence. + min_goal: The lower goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + max_goal: The upper goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + notes: Description text for the metric. + + Returns: + The newly created `Metric`. + + Raises: + ValueError: If `goal` is combined with `rule=MetricRule.BETWEEN`, + or if `min_goal`/`max_goal` is combined with any other rule. + + """ + conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) + if conflict: + raise ValueError(conflict) + if user_id is None: + user_id = await self.get_user_id() + + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "units": str(units), + "rule": str(rule), + "frequency": str(frequency), + "averageOverrideType": "NONE", + "meetings": [meeting_id], + "customGoals": [], + } + if goal is not None: + input_["singleGoalValue"] = str(goal) + if min_goal is not None: + input_["minGoalValue"] = str(min_goal) + if max_goal is not None: + input_["maxGoalValue"] = str(max_goal) + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + data = await self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) + return await self.details(data["CreateMetric"]["id"]) + + async def update( + self, + metric_id: int, + *, + title: str | None = None, + user_id: int | None = None, + goal: float | str | None = None, + min_goal: float | str | None = None, + max_goal: float | str | None = None, + units: MetricUnit | str | None = None, + rule: MetricRule | str | None = None, + notes: str | None = None, + ) -> Metric: + """Update an existing metric. + + Args: + metric_id: The ID of the metric to update. + title: New title for the metric. + user_id: New owner for the metric. + goal: New single goal value. Use for every `rule` except + `MetricRule.BETWEEN`; mutually exclusive with `min_goal`/ + `max_goal`. + min_goal: New lower goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + max_goal: New upper goal bound. Only valid with + `rule=MetricRule.BETWEEN`. + units: New display unit for scores. + rule: New comparison rule between a score and its goal. + notes: New description text for the metric. + + Returns: + The updated `Metric`. + + Raises: + ValueError: If no update fields are provided, if `goal` is + combined with `rule=MetricRule.BETWEEN`, or if `min_goal`/ + `max_goal` is combined with a non-`BETWEEN` `rule`. + + """ + if all( + field is None + for field in (title, user_id, goal, min_goal, max_goal, units, rule, notes) + ): + raise ValueError("At least one field must be provided for update") + + conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) + if conflict: + raise ValueError(conflict) + + input_: dict[str, Any] = { + "metricId": metric_id, + **self._metric_update_fields( + title=title, + user_id=user_id, + units=units, + rule=rule, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ), + } + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + await self._execute(self._METRIC_EDIT_MUTATION, {"input": input_}) + return await self.details(metric_id) + + async def archive(self, metric_id: int) -> Metric: + """Archive a metric. + + Note: + This cannot be undone through the GraphQL API -- there is no + `restore()` (see the class `Note`). + + Args: + metric_id: The ID of the metric to archive. + + Returns: + The updated `Metric`. + + """ + await self._execute( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, "archived": True}}, + ) + return await self.details(metric_id) + + async def scores( + self, + metric_id: int, + *, + start: datetime | date | float | int | None = None, + end: datetime | date | float | int | None = None, + include_empty: bool = False, + ) -> builtins.list[MetricScore]: + """List the scores of a metric. + + Args: + metric_id: The ID of the metric. + start: Only include scores at or after this time. + end: Only include scores at or before this time. + include_empty: If `True`, also include placeholder rows with no + value set. + + Returns: + A list of `MetricScore` model instances, most recent first. + + """ + start_ts = self._to_timestamp(start) if start is not None else None + end_ts = self._to_timestamp(end) if end is not None else None + where = self._metric_score_where(start_ts, end_ts) + + data = await self._execute( + self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + ) + nodes = self._scores_from(data) + if not include_empty: + nodes = [node for node in nodes if node.get("value")] + return [self._transform_score(node) for node in nodes] + + async def set_score( + self, + metric_id: int, + value: float | str, + timestamp: datetime | date | float | int, + ) -> MetricScore: + """Set a metric's score for a given time. + + For weekly/monthly/quarterly metrics this upserts the score for + whichever period `timestamp` falls in. For DAILY metrics, a score + already exists for every day (as an empty placeholder), so this + looks the existing score up and edits it instead. + + Args: + metric_id: The ID of the metric. + value: The score value (a decimal number). + timestamp: The time the score belongs to, as a `datetime`, + `date`, or unix timestamp (seconds). + + Returns: + The written `MetricScore`, re-read after the write. + + Raises: + GraphQLError: If `value` cannot be parsed as a decimal number. + + """ + ts = self._to_timestamp(timestamp) + try: + data = await self._execute( + self._METRIC_SCORE_CREATE_MUTATION, + { + "input": { + "metricId": metric_id, + "value": str(value), + "timestamp": ts, + } + }, + ) + except GraphQLError as exc: + if not self._is_duplicate_score_error(exc): + raise + existing = await self._find_score_for_day(metric_id, ts) + if existing is None: + raise + return await self.update_score(metric_id, existing.id, value=value) + + result = data.get("CreateMetricScore") + if result is None: + raise GraphQLError( + f"CreateMetricScore: value {value!r} could not be parsed as a number" + ) + return await self._read_score(metric_id, result["id"]) + + async def update_score( + self, + metric_id: int, + score_id: int, + *, + value: float | str | None = None, + notes: str | None = None, + ) -> MetricScore: + """Update an existing metric score's value and/or note. + + Args: + metric_id: The ID of the score's parent metric, needed to + re-read it afterwards (there is no root `score(id)` query). + score_id: The ID of the score to update. + value: New score value. + notes: New note text for the score. + + Returns: + The updated `MetricScore`. + + Raises: + ValueError: If neither `value` nor `notes` is provided. + + """ + if value is None and notes is None: + raise ValueError("At least one of `value` or `notes` must be provided") + + input_: dict[str, Any] = {"id": score_id} + if value is not None: + input_["value"] = str(value) + if notes is not None: + input_["notesText"] = notes + + await self._execute(self._METRIC_SCORE_EDIT_MUTATION, {"input": input_}) + return await self._read_score(metric_id, score_id) + + async def clear_score(self, metric_id: int, score_id: int) -> MetricScore: + """Clear a metric score's value, leaving an empty placeholder. + + Args: + metric_id: The ID of the score's parent metric, needed to + re-read it afterwards (there is no root `score(id)` query). + score_id: The ID of the score to clear. + + Returns: + The updated `MetricScore`, with `value=None`. + + """ + await self._execute( + self._METRIC_SCORE_EDIT_MUTATION, {"input": {"id": score_id, "value": None}} + ) + return await self._read_score(metric_id, score_id) + + async def _read_score(self, metric_id: int, score_id: int) -> MetricScore: + """Re-read a single score through its parent metric. + + Returns: + The `MetricScore` model instance. + + Raises: + GraphQLError: If the score is not found under the metric. + + """ + data = await self._execute( + self._METRIC_SCORES_QUERY, + {"metricId": metric_id, "where": {"and": [{"id": {"eq": score_id}}]}}, + ) + for node in self._scores_from(data): + if node.get("id") == score_id: + return self._transform_score(node) + raise GraphQLError(f"score {score_id} not found under metric {metric_id}") + + async def _find_score_for_day( + self, metric_id: int, ts: float + ) -> MetricScore | None: + """Find the pre-existing score whose UTC calendar day matches `ts`. + + Used by `set_score()` to fall back to `EditMetricScore` for DAILY + metrics, where a placeholder score already exists for every day. + + Returns: + The matching `MetricScore`, or `None` if not found. + + """ + target = datetime.fromtimestamp(ts, tz=UTC).date() + where = self._metric_score_where(ts - 172800, ts + 172800) + data = await self._execute( + self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + ) + for node in self._scores_from(data): + node_ts = node.get("timestamp") + if node_ts is not None and self._same_day(node_ts, target): + return self._transform_score(node) + return None diff --git a/src/bloomy/v2/operations/milestone.py b/src/bloomy/v2/operations/milestone.py new file mode 100644 index 0000000..a7b4d53 --- /dev/null +++ b/src/bloomy/v2/operations/milestone.py @@ -0,0 +1,419 @@ +"""Milestone operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from datetime import date, datetime +from typing import Any + +from ...exceptions import GraphQLError +from ..base import ( + MUTATION_RESULT_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + dig_nodes, +) +from ..models import Milestone + +#: Selected on every milestone read. Kept in sync with `goal.py`'s inline +#: milestone subselection (duplicated rather than imported, to keep the two +#: entity modules independent). +_MILESTONE_FIELDS = "id goalId title dueDate completed status dateCreated" + + +class MilestoneOperationsMixin: + """Shared GraphQL documents and response transforms for milestone operations.""" + + # There is no root `milestone(id)` query (see module docstring on + # `MilestoneOperations`), so both the list and the by-id read go through + # `goal(id){ milestones(...) }`. `dateDeleted` is already filtered + # server-side (soft-deleted milestones are never returned), but the + # explicit filter is kept for parity with the web app's own query. + _MILESTONE_LIST_QUERY = f""" + query($goalId: Long!) {{ + goal(id: $goalId) {{ + milestones( + where: {{ and: [{{ dateDeleted: {{ eq: null }} }}] }} + order: [{{ dueDate: ASC }}] + ) {{ + nodes {{ + {_MILESTONE_FIELDS} + }} + }} + }} + }} + """ + + _MILESTONE_BY_ID_QUERY = f""" + query($goalId: Long!, $milestoneId: Long!) {{ + goal(id: $goalId) {{ + milestones(where: {{ id: {{ eq: $milestoneId }} }}) {{ + nodes {{ + {_MILESTONE_FIELDS} + }} + }} + }} + }} + """ + + # CreateMilestone/EditMilestone return only `IdModel { id }` (verified + # live against production), not the `{success message errorDetails}` + # shape `_run_mutation` expects. A failed create/edit raises through the + # standard `errors` array instead (an invalid input, e.g. a bad `status` + # string, throws server-side and surfaces there), so these are executed + # with plain `_execute`. + _MILESTONE_CREATE_MUTATION = """ + mutation($input: MilestoneCreateModelInput!) { + CreateMilestone(input: $input) { + id + } + } + """ + + _MILESTONE_EDIT_MUTATION = """ + mutation($input: MilestoneEditModelInput!) { + EditMilestone(input: $input) { + id + } + } + """ + + # DeleteMilestone genuinely returns `GraphQLResponseBase` + # (`{success message errorDetails}`), unlike Create/EditMilestone. + _MILESTONE_DELETE_MUTATION = f""" + mutation($milestoneId: Long!) {{ + DeleteMilestone(milestoneId: $milestoneId) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + def _transform_milestone(self, data: dict[str, Any]) -> Milestone: + """Transform a raw GraphQL milestone object into a `Milestone` model. + + Returns: + A `Milestone` model instance. + + """ + return Milestone(**data) + + +class MilestoneOperations(GraphQLOperations, MilestoneOperationsMixin): + """Class to handle v2 (GraphQL) operations related to goal milestones. + + Note: + The GraphQL API has no root `milestone(id)` query — a milestone can + only be read through its parent goal (`goal(id){ milestones }`). So + `update()` and `complete()`, which need to re-read the milestone + after editing it, require `goal_id` in addition to `milestone_id`. + `delete()` does not, since `DeleteMilestone` does not return a + `Milestone`. + + """ + + def list(self, goal_id: int) -> builtins.list[Milestone]: + """List the milestones of a goal. + + Args: + goal_id: The ID of the goal. + + Returns: + A list of `Milestone` model instances, ordered by due date. + + Example: + ```python + client.v2.milestone.list(5265048) + # Returns: [Milestone(id=1, title='Draft spec', ...), ...] + ``` + + """ + data = self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) + nodes = dig_nodes(data, "goal", "milestones") + return [self._transform_milestone(node) for node in nodes] + + def create( + self, + goal_id: int, + title: str, + due_date: datetime | date | float | int, + completed: bool = False, + ) -> Milestone: + """Create a new milestone on a goal. + + Args: + goal_id: The ID of the goal (rock) to attach the milestone to. + title: The title of the milestone. + due_date: The due date, as a `datetime`, `date`, or unix + timestamp (seconds). + completed: Whether the milestone starts out completed. + + Returns: + The newly created `Milestone`. + + Example: + ```python + client.v2.milestone.create(5265048, "Draft spec", date(2026, 1, 1)) + # Returns: Milestone(id=1, goal_id=5265048, title='Draft spec', ...) + ``` + + """ + input_: dict[str, Any] = { + "rockId": goal_id, + "title": title, + "dueDate": self._to_timestamp(due_date), + "completed": completed, + } + data = self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) + return self._get(goal_id, data["CreateMilestone"]["id"]) + + def update( + self, + milestone_id: int, + goal_id: int, + *, + title: str | None = None, + due_date: datetime | date | float | int | None = None, + completed: bool | None = None, + ) -> Milestone: + """Update an existing milestone. + + Args: + milestone_id: The ID of the milestone to update. + goal_id: The ID of the milestone's parent goal, needed to re-read + it afterwards (see the class `Note`). + title: New title for the milestone. + due_date: New due date, as a `datetime`, `date`, or unix + timestamp (seconds). + completed: New completed state. + + Returns: + The updated `Milestone`. + + Raises: + ValueError: If no update fields are provided. + + Example: + ```python + client.v2.milestone.update(1, 5265048, title="Draft spec v2") + # Returns: Milestone(id=1, title='Draft spec v2', ...) + ``` + + """ + if title is None and due_date is None and completed is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"milestoneId": milestone_id} + if title is not None: + input_["title"] = title + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if completed is not None: + input_["completed"] = completed + + self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) + return self._get(goal_id, milestone_id) + + def complete(self, milestone_id: int, goal_id: int) -> Milestone: + """Mark a milestone as completed. + + Args: + milestone_id: The ID of the milestone to complete. + goal_id: The ID of the milestone's parent goal, needed to re-read + it afterwards (see the class `Note`). + + Returns: + The updated `Milestone`. + + """ + self._execute( + self._MILESTONE_EDIT_MUTATION, + {"input": {"milestoneId": milestone_id, "completed": True}}, + ) + return self._get(goal_id, milestone_id) + + def delete(self, milestone_id: int) -> None: + """Delete (soft-delete) a milestone. + + Args: + milestone_id: The ID of the milestone to delete. + + Example: + ```python + client.v2.milestone.delete(1) + ``` + + """ + self._run_mutation( + self._MILESTONE_DELETE_MUTATION, + {"milestoneId": milestone_id}, + root_field="DeleteMilestone", + action="delete milestone", + ) + + def _get(self, goal_id: int, milestone_id: int) -> Milestone: + """Re-read a single milestone through its parent goal. + + Returns: + The `Milestone` model instance. + + Raises: + GraphQLError: If the milestone is not found under the goal. + + """ + data = self._execute( + self._MILESTONE_BY_ID_QUERY, + {"goalId": goal_id, "milestoneId": milestone_id}, + ) + nodes = dig_nodes(data, "goal", "milestones") + if not nodes: + raise GraphQLError( + f"milestone {milestone_id} not found under goal {goal_id}" + ) + return self._transform_milestone(nodes[0]) + + +class AsyncMilestoneOperations(AsyncGraphQLOperations, MilestoneOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to goal milestones. + + Note: + See `MilestoneOperations` for why `update()` and `complete()` require + `goal_id`. + + """ + + async def list(self, goal_id: int) -> builtins.list[Milestone]: + """List the milestones of a goal. + + Args: + goal_id: The ID of the goal. + + Returns: + A list of `Milestone` model instances, ordered by due date. + + """ + data = await self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) + nodes = dig_nodes(data, "goal", "milestones") + return [self._transform_milestone(node) for node in nodes] + + async def create( + self, + goal_id: int, + title: str, + due_date: datetime | date | float | int, + completed: bool = False, + ) -> Milestone: + """Create a new milestone on a goal. + + Args: + goal_id: The ID of the goal (rock) to attach the milestone to. + title: The title of the milestone. + due_date: The due date, as a `datetime`, `date`, or unix + timestamp (seconds). + completed: Whether the milestone starts out completed. + + Returns: + The newly created `Milestone`. + + """ + input_: dict[str, Any] = { + "rockId": goal_id, + "title": title, + "dueDate": self._to_timestamp(due_date), + "completed": completed, + } + data = await self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) + return await self._get(goal_id, data["CreateMilestone"]["id"]) + + async def update( + self, + milestone_id: int, + goal_id: int, + *, + title: str | None = None, + due_date: datetime | date | float | int | None = None, + completed: bool | None = None, + ) -> Milestone: + """Update an existing milestone. + + Args: + milestone_id: The ID of the milestone to update. + goal_id: The ID of the milestone's parent goal, needed to re-read + it afterwards (see the class `Note`). + title: New title for the milestone. + due_date: New due date, as a `datetime`, `date`, or unix + timestamp (seconds). + completed: New completed state. + + Returns: + The updated `Milestone`. + + Raises: + ValueError: If no update fields are provided. + + """ + if title is None and due_date is None and completed is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"milestoneId": milestone_id} + if title is not None: + input_["title"] = title + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if completed is not None: + input_["completed"] = completed + + await self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) + return await self._get(goal_id, milestone_id) + + async def complete(self, milestone_id: int, goal_id: int) -> Milestone: + """Mark a milestone as completed. + + Args: + milestone_id: The ID of the milestone to complete. + goal_id: The ID of the milestone's parent goal, needed to re-read + it afterwards (see the class `Note`). + + Returns: + The updated `Milestone`. + + """ + await self._execute( + self._MILESTONE_EDIT_MUTATION, + {"input": {"milestoneId": milestone_id, "completed": True}}, + ) + return await self._get(goal_id, milestone_id) + + async def delete(self, milestone_id: int) -> None: + """Delete (soft-delete) a milestone. + + Args: + milestone_id: The ID of the milestone to delete. + + """ + await self._run_mutation( + self._MILESTONE_DELETE_MUTATION, + {"milestoneId": milestone_id}, + root_field="DeleteMilestone", + action="delete milestone", + ) + + async def _get(self, goal_id: int, milestone_id: int) -> Milestone: + """Re-read a single milestone through its parent goal. + + Returns: + The `Milestone` model instance. + + Raises: + GraphQLError: If the milestone is not found under the goal. + + """ + data = await self._execute( + self._MILESTONE_BY_ID_QUERY, + {"goalId": goal_id, "milestoneId": milestone_id}, + ) + nodes = dig_nodes(data, "goal", "milestones") + if not nodes: + raise GraphQLError( + f"milestone {milestone_id} not found under goal {goal_id}" + ) + return self._transform_milestone(nodes[0]) diff --git a/src/bloomy/v2/operations/todo.py b/src/bloomy/v2/operations/todo.py new file mode 100644 index 0000000..acdb38a --- /dev/null +++ b/src/bloomy/v2/operations/todo.py @@ -0,0 +1,636 @@ +"""To-do operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from ..base import ( + MUTATION_RESULT_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + dig_nodes, + extract_notes, +) +from ..models import Todo + +_TODO_FIELDS = """ +id +title +dueDate +completed +completedTimestamp +archived +archivedTimestamp +dateCreated +notesId +notesText +localHtml +collaborationEnabled +assignee { id fullName } +meeting { id name } +""" + + +class TodoOperationsMixin: + """Shared GraphQL documents and response transforms for to-do operations.""" + + _TODO_DETAILS_QUERY = f""" + query($id: Long!) {{ + todo(id: $id) {{ + {_TODO_FIELDS} + }} + }} + """ + + # `meeting.todos` returns EVERY to-do ever attached to the meeting, + # including archived and soft-deleted ones (unlike `meeting.issues`, + # which drops solved/archived issues server-side), so a single query + # with a client-built `where` filter covers every combination of + # `include_completed`/`include_archived` here. + _TODO_LIST_MEETING_QUERY = f""" + query($meetingId: Long!, $where: TodoQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + todos(where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + }} + }} + """ + + # The root `todos(userId)` connection already excludes archived to-dos + # unconditionally, server-side (it is built from `CloseTime == null`), + # so `include_archived` has no effect here; see `list()`. + _TODO_LIST_USER_QUERY = f""" + query($userId: Long!, $where: TodoQueryModelFilterInput) {{ + todos(userId: $userId, where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + }} + """ + + _TODO_CREATE_MUTATION = """ + mutation($input: TodoCreateModelInput!) { + CreateTodo(input: $input) { + id + } + } + """ + + _TODO_EDIT_MUTATION = f""" + mutation($input: TodoEditModelInput!) {{ + EditTodo(input: $input) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + def _todo_list_where( + self, *, include_completed: bool, include_archived: bool + ) -> dict[str, Any] | None: + """Build the `where` filter for a to-do list connection. + + Returns: + A `TodoQueryModelFilterInput`-shaped dictionary, or `None` for no + filter (list everything). + + """ + conditions: list[dict[str, Any]] = [] + if not include_completed: + conditions.append({"completed": {"eq": False}}) + if not include_archived: + conditions.append({"archived": {"eq": False}}) + return {"and": conditions} if conditions else None + + def _default_due_date(self) -> date: + """Compute the default due date: 7 days from today, 00:00 UTC. + + Matches the web app's own default for a new to-do. + + Returns: + A `date` 7 days from today (UTC). + + """ + return datetime.now(tz=UTC).date() + timedelta(days=7) + + def _transform_todo(self, data: dict[str, Any]) -> Todo: + """Transform a raw GraphQL to-do object into a `Todo` model. + + Returns: + A `Todo` model instance. + + """ + return Todo(**data, notes=extract_notes(data)) + + +class TodoOperations(GraphQLOperations, TodoOperationsMixin): + """Class to handle v2 (GraphQL) operations related to to-dos.""" + + def details(self, todo_id: int) -> Todo: + """Get details for a to-do. + + Args: + todo_id: The ID of the to-do. + + Returns: + A `Todo` model instance. + + Example: + ```python + client.v2.todo.details(123) + # Returns: Todo(id=123, title='To-do Title', ...) + ``` + + """ + data = self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) + return self._transform_todo(data["todo"]) + + def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_completed: bool = False, + include_archived: bool = False, + ) -> builtins.list[Todo]: + """List to-dos for a meeting or a user. + + Args: + meeting_id: The ID of the meeting to list to-dos for. + user_id: The ID of the user to list to-dos for. Defaults to the + current user when neither `meeting_id` nor `user_id` is + given. + include_completed: If `True`, also include completed to-dos. + include_archived: If `True`, also include archived to-dos. Has + no effect when listing by `user_id`: the underlying + `todos(userId)` query excludes archived to-dos + unconditionally, server-side. + + Returns: + A list of `Todo` model instances, ordered by due date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + Example: + ```python + client.v2.todo.list(meeting_id=349524) + # Returns: [Todo(id=1, title='To-do 1', ...), ...] + ``` + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._todo_list_where( + include_completed=include_completed, include_archived=include_archived + ) + + if meeting_id is not None: + data = self._execute( + self._TODO_LIST_MEETING_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "todos") + return [self._transform_todo(node) for node in nodes] + + if user_id is None: + user_id = self.user_id + data = self._execute( + self._TODO_LIST_USER_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "todos") + return [self._transform_todo(node) for node in nodes] + + def create( + self, + title: str, + meeting_id: int | None = None, + user_id: int | None = None, + due_date: datetime | date | float | int | None = None, + notes: str | None = None, + ) -> Todo: + """Create a new to-do. + + Args: + title: The title of the to-do. + meeting_id: The ID of the meeting to create the to-do in. Omit + for a personal to-do (`meetingRecurrenceId` is sent as + `null`). + user_id: The ID of the to-do owner (defaults to the current + user). Ignored by the API for a personal to-do, which is + always assigned to the caller. + due_date: The due date. Defaults to 7 days from today at 00:00 + UTC, matching the web app's own default. + notes: Description text for the to-do. + + Returns: + The newly created `Todo`. + + Example: + ```python + client.v2.todo.create("New To-do", meeting_id=349524) + # Returns: Todo(id=456, title='New To-do', ...) + ``` + + """ + if user_id is None: + user_id = self.user_id + if due_date is None: + due_date = self._default_due_date() + + input_: dict[str, Any] = { + "title": title, + "assigneeId": user_id, + "meetingRecurrenceId": meeting_id, + "dueDate": self._to_timestamp(due_date), + } + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + data = self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) + return self.details(data["CreateTodo"]["id"]) + + def update( + self, + todo_id: int, + *, + title: str | None = None, + due_date: datetime | date | float | int | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Todo: + """Update an existing to-do. + + Args: + todo_id: The ID of the to-do to update. + title: New title for the to-do. + due_date: New due date for the to-do. The due date cannot be + cleared, only changed. + user_id: New owner for the to-do. + notes: New description text for the to-do. + + Returns: + The updated `Todo`. + + Raises: + ValueError: If no update fields are provided. + + Example: + ```python + client.v2.todo.update(123, title="New Title") + # Returns: Todo(id=123, title='New Title', ...) + ``` + + """ + if title is None and due_date is None and user_id is None and notes is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"todoId": todo_id} + if title is not None: + input_["title"] = title + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if user_id is not None: + input_["assigneeId"] = user_id + if notes is not None: + input_["notesId"] = self._create_note(notes) + input_["collaborationEnabled"] = True + + self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": input_}, + root_field="EditTodo", + action="update todo", + ) + return self.details(todo_id) + + def complete(self, todo_id: int) -> Todo: + """Mark a to-do as complete. + + Args: + todo_id: The ID of the to-do to complete. + + Returns: + The updated `Todo`. + + """ + self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "completedTimestamp": self._now_timestamp()}}, + root_field="EditTodo", + action="complete todo", + ) + return self.details(todo_id) + + def reopen(self, todo_id: int) -> Todo: + """Reopen a completed to-do. + + Note: + Sends `completedTimestamp: null` explicitly: `EditTodo` + distinguishes an omitted field (no change) from an explicit + `null` (clear it). + + Args: + todo_id: The ID of the to-do to reopen. + + Returns: + The updated `Todo`. + + """ + self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "completedTimestamp": None}}, + root_field="EditTodo", + action="reopen todo", + ) + return self.details(todo_id) + + def archive(self, todo_id: int) -> Todo: + """Archive a to-do. + + Args: + todo_id: The ID of the to-do to archive. + + Returns: + The updated `Todo`. + + """ + self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "archivedTimestamp": self._now_timestamp()}}, + root_field="EditTodo", + action="archive todo", + ) + return self.details(todo_id) + + def restore(self, todo_id: int) -> Todo: + """Restore an archived to-do. + + Note: + Sends `archivedTimestamp: null` explicitly: `EditTodo` + distinguishes an omitted field (no change) from an explicit + `null` (clear it). + + Args: + todo_id: The ID of the to-do to restore. + + Returns: + The updated `Todo`. + + """ + self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "archivedTimestamp": None}}, + root_field="EditTodo", + action="restore todo", + ) + return self.details(todo_id) + + +class AsyncTodoOperations(AsyncGraphQLOperations, TodoOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to to-dos.""" + + async def details(self, todo_id: int) -> Todo: + """Get details for a to-do. + + Args: + todo_id: The ID of the to-do. + + Returns: + A `Todo` model instance. + + """ + data = await self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) + return self._transform_todo(data["todo"]) + + async def list( + self, + meeting_id: int | None = None, + user_id: int | None = None, + *, + include_completed: bool = False, + include_archived: bool = False, + ) -> builtins.list[Todo]: + """List to-dos for a meeting or a user. + + Args: + meeting_id: The ID of the meeting to list to-dos for. + user_id: The ID of the user to list to-dos for. Defaults to the + current user when neither `meeting_id` nor `user_id` is + given. + include_completed: If `True`, also include completed to-dos. + include_archived: If `True`, also include archived to-dos. Has + no effect when listing by `user_id`: the underlying + `todos(userId)` query excludes archived to-dos + unconditionally, server-side. + + Returns: + A list of `Todo` model instances, ordered by due date. + + Raises: + ValueError: If both `meeting_id` and `user_id` are provided. + + """ + if meeting_id is not None and user_id is not None: + raise ValueError("Please provide either meeting_id or user_id, not both.") + + where = self._todo_list_where( + include_completed=include_completed, include_archived=include_archived + ) + + if meeting_id is not None: + data = await self._execute( + self._TODO_LIST_MEETING_QUERY, + {"meetingId": meeting_id, "where": where}, + ) + nodes = dig_nodes(data, "meeting", "todos") + return [self._transform_todo(node) for node in nodes] + + if user_id is None: + user_id = await self.get_user_id() + data = await self._execute( + self._TODO_LIST_USER_QUERY, {"userId": user_id, "where": where} + ) + nodes = dig_nodes(data, "todos") + return [self._transform_todo(node) for node in nodes] + + async def create( + self, + title: str, + meeting_id: int | None = None, + user_id: int | None = None, + due_date: datetime | date | float | int | None = None, + notes: str | None = None, + ) -> Todo: + """Create a new to-do. + + Args: + title: The title of the to-do. + meeting_id: The ID of the meeting to create the to-do in. Omit + for a personal to-do (`meetingRecurrenceId` is sent as + `null`). + user_id: The ID of the to-do owner (defaults to the current + user). Ignored by the API for a personal to-do, which is + always assigned to the caller. + due_date: The due date. Defaults to 7 days from today at 00:00 + UTC, matching the web app's own default. + notes: Description text for the to-do. + + Returns: + The newly created `Todo`. + + """ + if user_id is None: + user_id = await self.get_user_id() + if due_date is None: + due_date = self._default_due_date() + + input_: dict[str, Any] = { + "title": title, + "assigneeId": user_id, + "meetingRecurrenceId": meeting_id, + "dueDate": self._to_timestamp(due_date), + } + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + data = await self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) + return await self.details(data["CreateTodo"]["id"]) + + async def update( + self, + todo_id: int, + *, + title: str | None = None, + due_date: datetime | date | float | int | None = None, + user_id: int | None = None, + notes: str | None = None, + ) -> Todo: + """Update an existing to-do. + + Args: + todo_id: The ID of the to-do to update. + title: New title for the to-do. + due_date: New due date for the to-do. The due date cannot be + cleared, only changed. + user_id: New owner for the to-do. + notes: New description text for the to-do. + + Returns: + The updated `Todo`. + + Raises: + ValueError: If no update fields are provided. + + """ + if title is None and due_date is None and user_id is None and notes is None: + raise ValueError("At least one field must be provided for update") + + input_: dict[str, Any] = {"todoId": todo_id} + if title is not None: + input_["title"] = title + if due_date is not None: + input_["dueDate"] = self._to_timestamp(due_date) + if user_id is not None: + input_["assigneeId"] = user_id + if notes is not None: + input_["notesId"] = await self._create_note(notes) + input_["collaborationEnabled"] = True + + await self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": input_}, + root_field="EditTodo", + action="update todo", + ) + return await self.details(todo_id) + + async def complete(self, todo_id: int) -> Todo: + """Mark a to-do as complete. + + Args: + todo_id: The ID of the to-do to complete. + + Returns: + The updated `Todo`. + + """ + await self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "completedTimestamp": self._now_timestamp()}}, + root_field="EditTodo", + action="complete todo", + ) + return await self.details(todo_id) + + async def reopen(self, todo_id: int) -> Todo: + """Reopen a completed to-do. + + Note: + Sends `completedTimestamp: null` explicitly: `EditTodo` + distinguishes an omitted field (no change) from an explicit + `null` (clear it). + + Args: + todo_id: The ID of the to-do to reopen. + + Returns: + The updated `Todo`. + + """ + await self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "completedTimestamp": None}}, + root_field="EditTodo", + action="reopen todo", + ) + return await self.details(todo_id) + + async def archive(self, todo_id: int) -> Todo: + """Archive a to-do. + + Args: + todo_id: The ID of the to-do to archive. + + Returns: + The updated `Todo`. + + """ + await self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "archivedTimestamp": self._now_timestamp()}}, + root_field="EditTodo", + action="archive todo", + ) + return await self.details(todo_id) + + async def restore(self, todo_id: int) -> Todo: + """Restore an archived to-do. + + Note: + Sends `archivedTimestamp: null` explicitly: `EditTodo` + distinguishes an omitted field (no change) from an explicit + `null` (clear it). + + Args: + todo_id: The ID of the to-do to restore. + + Returns: + The updated `Todo`. + + """ + await self._run_mutation( + self._TODO_EDIT_MUTATION, + {"input": {"todoId": todo_id, "archivedTimestamp": None}}, + root_field="EditTodo", + action="restore todo", + ) + return await self.details(todo_id) diff --git a/src/bloomy/v2/operations/user.py b/src/bloomy/v2/operations/user.py new file mode 100644 index 0000000..cd4c000 --- /dev/null +++ b/src/bloomy/v2/operations/user.py @@ -0,0 +1,114 @@ +"""User operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import AsyncGraphQLOperations, GraphQLOperations +from ..models import User + + +class UserOperationsMixin: + """Shared GraphQL documents and response transforms for user operations.""" + + _USER_FIELDS = "id firstName lastName fullName email avatar" + + _USER_DETAILS_QUERY = f""" + query($id: Long!) {{ + user(id: $id) {{ + {_USER_FIELDS} + }} + }} + """ + + _USER_LIST_QUERY = f""" + query {{ + users(order: [{{ fullName: ASC }}]) {{ + nodes {{ + {_USER_FIELDS} + }} + }} + }} + """ + + def _transform_user(self, data: dict[str, Any]) -> User: + """Transform a raw GraphQL user object into a `User` model. + + Returns: + A `User` model instance. + + """ + return User(**data) + + +class UserOperations(GraphQLOperations, UserOperationsMixin): + """Class to handle v2 (GraphQL) operations related to users.""" + + def details(self, user_id: int | None = None) -> User: + """Get details for a user. + + Args: + user_id: The ID of the user (defaults to the current user). + + Returns: + A `User` model instance. + + Example: + ```python + client.v2.user.details() + # Returns: User(id=1, first_name='John', last_name='Doe', ...) + ``` + + """ + if user_id is None: + user_id = self.user_id + + data = self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) + return self._transform_user(data["user"]) + + def list(self) -> builtins.list[User]: + """List every user in the organization. + + Returns: + A list of `User` model instances, ordered by full name. + + Example: + ```python + client.v2.user.list() + # Returns: [User(id=1, full_name='John Doe', ...), ...] + ``` + + """ + data = self._execute(self._USER_LIST_QUERY) + return [self._transform_user(node) for node in data["users"]["nodes"]] + + +class AsyncUserOperations(AsyncGraphQLOperations, UserOperationsMixin): + """Async class to handle v2 (GraphQL) operations related to users.""" + + async def details(self, user_id: int | None = None) -> User: + """Get details for a user. + + Args: + user_id: The ID of the user (defaults to the current user). + + Returns: + A `User` model instance. + + """ + if user_id is None: + user_id = await self.get_user_id() + + data = await self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) + return self._transform_user(data["user"]) + + async def list(self) -> builtins.list[User]: + """List every user in the organization. + + Returns: + A list of `User` model instances, ordered by full name. + + """ + data = await self._execute(self._USER_LIST_QUERY) + return [self._transform_user(node) for node in data["users"]["nodes"]] diff --git a/tests/test_integration_v2_goal_milestone.py b/tests/test_integration_v2_goal_milestone.py new file mode 100644 index 0000000..c5bfb5f --- /dev/null +++ b/tests/test_integration_v2_goal_milestone.py @@ -0,0 +1,288 @@ +"""Integration tests for v2 (GraphQL) goal and milestone operations against the API. + +Runs against the dedicated "SDK v2 API" test meeting only, and archives +everything it creates (goals cannot be deleted through the GraphQL API, +only archived; milestones are deleted outright). +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from bloomy import AsyncClient, Client +from bloomy.v2.models import Goal, GoalStatus + +MEETING_ID = 349524 # "v2 API" test meeting + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def client() -> Client: + """Create a real Bloomy client for integration tests. + + Yields: + A configured Client instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = Client(api_key=api_key) + yield c + c.close() + + +@pytest_asyncio.fixture +async def async_client() -> AsyncClient: + """Create a real async Bloomy client for integration tests. + + Yields: + A configured AsyncClient instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = AsyncClient(api_key=api_key) + yield c + await c.close() + + +def _title(tag: str) -> str: + return f"SDK v2 integration test goal {tag}" + + +def _poll_sync( + fn: Callable[[], Goal], + predicate: Callable[[Goal], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Goal: + """Poll `fn()` until `predicate(result)` is true (notes pads lag a bit). + + Returns: + The last polled `Goal`. + + """ + result = fn() + for _ in range(attempts): + if predicate(result): + return result + time.sleep(delay) + result = fn() + return result + + +async def _poll_async( + fn: Callable[[], Awaitable[Goal]], + predicate: Callable[[Goal], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Goal: + """Async equivalent of `_poll_sync`. + + Returns: + The last polled `Goal`. + + """ + result = await fn() + for _ in range(attempts): + if predicate(result): + return result + await asyncio.sleep(delay) + result = await fn() + return result + + +class TestGoalMilestoneLifecycleSync: + """Full CRUD lifecycle tests for sync v2 goal and milestone operations.""" + + def test_full_lifecycle(self, client: Client) -> None: + """Create -> details -> list -> update -> archive -> restore. + + Exercises milestone create/update/complete/delete on the same goal. + """ + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = client.v2.goal.create( + MEETING_ID, + title, + notes="Lifecycle notes", + milestones=[{"title": "Initial milestone", "due_date": 1767225600}], + ) + try: + assert isinstance(created, Goal) + assert created.title == title + assert created.owner is not None + assert created.status == GoalStatus.ON_TRACK + assert created.archived is False + assert any(m.title == "Initial milestone" for m in created.milestones) + assert any(m.id == MEETING_ID for m in created.meetings) + + details = _poll_sync( + lambda: client.v2.goal.details(created.id), + lambda g: g.notes is not None, + ) + assert details.notes == "Lifecycle notes" + + listed = client.v2.goal.list(meeting_id=MEETING_ID) + assert any(g.id == created.id for g in listed) + + updated = client.v2.goal.update( + created.id, + title=f"{title} (updated)", + status=GoalStatus.OFF_TRACK, + ) + assert updated.title == f"{title} (updated)" + assert updated.status == GoalStatus.OFF_TRACK + + # Milestones: list -> create -> update -> complete -> delete. + initial_milestones = client.v2.milestone.list(created.id) + assert len(initial_milestones) == 1 + seed_milestone = initial_milestones[0] + + new_milestone = client.v2.milestone.create( + created.id, "Second milestone", 1767225600 + ) + assert new_milestone.goal_id == created.id + assert new_milestone.completed is False + + renamed = client.v2.milestone.update( + new_milestone.id, created.id, title="Second milestone (renamed)" + ) + assert renamed.title == "Second milestone (renamed)" + + completed_milestone = client.v2.milestone.complete( + new_milestone.id, created.id + ) + assert completed_milestone.completed is True + + client.v2.milestone.delete(new_milestone.id) + remaining = client.v2.milestone.list(created.id) + assert all(m.id != new_milestone.id for m in remaining) + assert any(m.id == seed_milestone.id for m in remaining) + finally: + # Cleanup: archive regardless of where the test got to (goals + # cannot be deleted through the GraphQL API). + client.v2.goal.archive(created.id) + + def test_archive_and_restore(self, client: Client) -> None: + """`archive()`/`restore()` toggle the `archived` flag.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.goal.create(MEETING_ID, _title(tag)) + + try: + archived = client.v2.goal.archive(created.id) + assert archived.archived is True + + restored = client.v2.goal.restore(created.id) + assert restored.archived is False + # Restoring re-attaches meeting links detached during archive. + assert any(m.id == MEETING_ID for m in restored.meetings) + finally: + client.v2.goal.archive(created.id) + + def test_list_by_user_finds_the_same_goal(self, client: Client) -> None: + """`list(user_id=...)` (via `user(id){ goals }`) also finds the goal. + + This is a different GraphQL path than `list(meeting_id=...)` (via + `meeting(id){ goals }`), so it is worth checking on its own. + """ + tag = uuid.uuid4().hex[:8] + created = client.v2.goal.create(MEETING_ID, _title(tag)) + + try: + listed = client.v2.goal.list(user_id=client.v2.goal.user_id) + assert any(g.id == created.id for g in listed) + finally: + client.v2.goal.archive(created.id) + + def test_list_requires_only_one_scope(self, client: Client) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + with pytest.raises(ValueError, match="not both"): + client.v2.goal.list(meeting_id=MEETING_ID, user_id=1305290) + + def test_update_requires_a_field(self, client: Client) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.goal.update(1) + + def test_milestone_update_requires_a_field(self, client: Client) -> None: + """Milestone `update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.milestone.update(1, 1) + + +class TestGoalMilestoneLifecycleAsync: + """Full CRUD lifecycle tests for async v2 goal and milestone operations.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self, async_client: AsyncClient) -> None: + """Create -> details -> update -> milestone CRUD -> archive -> restore.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = await async_client.v2.goal.create( + MEETING_ID, title, notes="Async lifecycle notes" + ) + try: + assert created.title == title + + details = await _poll_async( + lambda: async_client.v2.goal.details(created.id), + lambda g: g.notes is not None, + ) + assert details.notes == "Async lifecycle notes" + + updated = await async_client.v2.goal.update( + created.id, title=f"{title} (updated)" + ) + assert updated.title == f"{title} (updated)" + + milestone = await async_client.v2.milestone.create( + created.id, "Async milestone", 1767225600 + ) + assert milestone.goal_id == created.id + + completed_milestone = await async_client.v2.milestone.complete( + milestone.id, created.id + ) + assert completed_milestone.completed is True + + await async_client.v2.milestone.delete(milestone.id) + remaining = await async_client.v2.milestone.list(created.id) + assert all(m.id != milestone.id for m in remaining) + + archived = await async_client.v2.goal.archive(created.id) + assert archived.archived is True + + restored = await async_client.v2.goal.restore(created.id) + assert restored.archived is False + finally: + await async_client.v2.goal.archive(created.id) + + @pytest.mark.asyncio + async def test_update_requires_a_field(self, async_client: AsyncClient) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.goal.update(1) + + @pytest.mark.asyncio + async def test_milestone_update_requires_a_field( + self, async_client: AsyncClient + ) -> None: + """Milestone `update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.milestone.update(1, 1) diff --git a/tests/test_integration_v2_headline.py b/tests/test_integration_v2_headline.py new file mode 100644 index 0000000..f3be149 --- /dev/null +++ b/tests/test_integration_v2_headline.py @@ -0,0 +1,201 @@ +"""Integration tests for v2 (GraphQL) headline operations against the real API. + +Runs against the dedicated "SDK v2 API" test meeting only, and archives +everything it creates. +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from bloomy import AsyncClient, Client +from bloomy.v2.models import Headline + +MEETING_ID = 349524 # "v2 API" test meeting + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def client() -> Client: + """Create a real Bloomy client for integration tests. + + Yields: + A configured Client instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = Client(api_key=api_key) + yield c + c.close() + + +@pytest_asyncio.fixture +async def async_client() -> AsyncClient: + """Create a real async Bloomy client for integration tests. + + Yields: + A configured AsyncClient instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = AsyncClient(api_key=api_key) + yield c + await c.close() + + +def _title(tag: str) -> str: + return f"SDK v2 integration test headline {tag}" + + +def _poll_sync( + fn: Callable[[], Headline], + predicate: Callable[[Headline], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Headline: + """Poll `fn()` until `predicate(result)` is true (notes pads lag a bit). + + Returns: + The last polled `Headline`. + + """ + result = fn() + for _ in range(attempts): + if predicate(result): + return result + time.sleep(delay) + result = fn() + return result + + +async def _poll_async( + fn: Callable[[], Awaitable[Headline]], + predicate: Callable[[Headline], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Headline: + """Async equivalent of `_poll_sync`. + + Returns: + The last polled `Headline`. + + """ + result = await fn() + for _ in range(attempts): + if predicate(result): + return result + await asyncio.sleep(delay) + result = await fn() + return result + + +class TestHeadlineLifecycleSync: + """Full CRUD lifecycle tests for sync v2 headline operations.""" + + def test_full_lifecycle(self, client: Client) -> None: + """Create -> details -> list -> update -> archive -> restore.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = client.v2.headline.create(MEETING_ID, title, notes="Lifecycle notes") + try: + assert isinstance(created, Headline) + assert created.title == title + assert created.owner is not None + assert created.meeting is not None and created.meeting.id == MEETING_ID + assert created.archived is False + + details = _poll_sync( + lambda: client.v2.headline.details(created.id), + lambda h: h.notes is not None, + ) + assert details.notes == "Lifecycle notes" + + listed = client.v2.headline.list(meeting_id=MEETING_ID) + assert any(h.id == created.id for h in listed) + + listed_by_user = client.v2.headline.list(user_id=created.owner.id) + assert any(h.id == created.id for h in listed_by_user) + + updated = client.v2.headline.update(created.id, title=f"{title} (updated)") + assert updated.title == f"{title} (updated)" + + archived = client.v2.headline.archive(created.id) + assert archived.archived is True + + archived_list = client.v2.headline.list( + meeting_id=MEETING_ID, include_archived=True + ) + assert any(h.id == created.id for h in archived_list) + + restored = client.v2.headline.restore(created.id) + assert restored.archived is False + finally: + # Cleanup: archive regardless of where the test got to. + client.v2.headline.archive(created.id) + + def test_list_requires_at_most_one_of_meeting_or_user(self, client: Client) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + with pytest.raises(ValueError, match="not both"): + client.v2.headline.list(meeting_id=MEETING_ID, user_id=1305290) + + def test_update_requires_a_field(self, client: Client) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.headline.update(1) + + +class TestHeadlineLifecycleAsync: + """Full CRUD lifecycle tests for async v2 headline operations.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self, async_client: AsyncClient) -> None: + """Create -> details -> update -> archive -> restore.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = await async_client.v2.headline.create( + MEETING_ID, title, notes="Async lifecycle notes" + ) + try: + assert created.title == title + + details = await _poll_async( + lambda: async_client.v2.headline.details(created.id), + lambda h: h.notes is not None, + ) + assert details.notes == "Async lifecycle notes" + + updated = await async_client.v2.headline.update( + created.id, title=f"{title} (updated)" + ) + assert updated.title == f"{title} (updated)" + + archived = await async_client.v2.headline.archive(created.id) + assert archived.archived is True + + restored = await async_client.v2.headline.restore(created.id) + assert restored.archived is False + finally: + await async_client.v2.headline.archive(created.id) + + @pytest.mark.asyncio + async def test_update_requires_a_field(self, async_client: AsyncClient) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.headline.update(1) diff --git a/tests/test_integration_v2_issue.py b/tests/test_integration_v2_issue.py new file mode 100644 index 0000000..a37ff7d --- /dev/null +++ b/tests/test_integration_v2_issue.py @@ -0,0 +1,255 @@ +"""Integration tests for v2 (GraphQL) issue operations against the real API. + +Runs against the dedicated "SDK v2 API" test meeting only, and archives +everything it creates. +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from bloomy import AsyncClient, Client +from bloomy.v2.models import Issue + +MEETING_ID = 349524 # "v2 API" test meeting + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def client() -> Client: + """Create a real Bloomy client for integration tests. + + Yields: + A configured Client instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = Client(api_key=api_key) + yield c + c.close() + + +@pytest_asyncio.fixture +async def async_client() -> AsyncClient: + """Create a real async Bloomy client for integration tests. + + Yields: + A configured AsyncClient instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = AsyncClient(api_key=api_key) + yield c + await c.close() + + +def _title(tag: str) -> str: + return f"SDK v2 integration test issue {tag}" + + +def _poll_sync( + fn: Callable[[], Issue], + predicate: Callable[[Issue], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Issue: + """Poll `fn()` until `predicate(result)` is true (notes pads lag a bit). + + Returns: + The last polled `Issue`. + + """ + result = fn() + for _ in range(attempts): + if predicate(result): + return result + time.sleep(delay) + result = fn() + return result + + +async def _poll_async( + fn: Callable[[], Awaitable[Issue]], + predicate: Callable[[Issue], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Issue: + """Async equivalent of `_poll_sync`. + + Returns: + The last polled `Issue`. + + """ + result = await fn() + for _ in range(attempts): + if predicate(result): + return result + await asyncio.sleep(delay) + result = await fn() + return result + + +class TestIssueLifecycleSync: + """Full CRUD lifecycle tests for sync v2 issue operations.""" + + def test_full_lifecycle(self, client: Client) -> None: + """Create -> details -> update -> solve -> reopen -> archive -> restore.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = client.v2.issue.create(MEETING_ID, title, notes="Lifecycle notes") + try: + assert isinstance(created, Issue) + assert created.title == title + assert created.owner is not None + assert created.meeting is not None and created.meeting.id == MEETING_ID + assert created.long_term is False + assert created.completed is False + assert created.archived is False + + details = _poll_sync( + lambda: client.v2.issue.details(created.id), + lambda i: i.notes is not None, + ) + assert details.notes == "Lifecycle notes" + + listed = client.v2.issue.list(MEETING_ID) + assert any(i.id == created.id for i in listed) + + updated = client.v2.issue.update(created.id, title=f"{title} (updated)") + assert updated.title == f"{title} (updated)" + + solved = client.v2.issue.solve(created.id) + assert solved.completed is True + assert solved.completed_date is not None + + reopened = client.v2.issue.reopen(created.id) + assert reopened.completed is False + assert reopened.completed_date is None + finally: + # Cleanup: archive regardless of where the test got to. + client.v2.issue.archive(created.id) + + def test_archive_and_restore(self, client: Client) -> None: + """`archive()`/`restore()` toggle the `archived` flag.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.issue.create(MEETING_ID, _title(tag)) + + try: + archived = client.v2.issue.archive(created.id) + assert archived.archived is True + + archived_list = client.v2.issue.list(MEETING_ID, include_archived=True) + assert any(i.id == created.id for i in archived_list) + + restored = client.v2.issue.restore(created.id) + assert restored.archived is False + finally: + client.v2.issue.archive(created.id) + + def test_long_term_toggle(self, client: Client) -> None: + """`update(long_term=True)` moves an issue to `list(long_term=True)`.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.issue.create(MEETING_ID, _title(tag)) + + try: + long_term = client.v2.issue.update(created.id, long_term=True) + assert long_term.long_term is True + assert long_term.archived is False + + long_term_list = client.v2.issue.list(MEETING_ID, long_term=True) + assert any(i.id == created.id for i in long_term_list) + finally: + client.v2.issue.update(created.id, long_term=False) + client.v2.issue.archive(created.id) + + def test_archive_long_term_issue(self, client: Client) -> None: + """Archiving a long-term issue removes it from the long-term list.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.issue.create(MEETING_ID, _title(tag), long_term=True) + + try: + archived = client.v2.issue.archive(created.id) + assert archived.archived is True + assert archived.long_term is False + + long_term_list = client.v2.issue.list(MEETING_ID, long_term=True) + assert all(i.id != created.id for i in long_term_list) + finally: + client.v2.issue.archive(created.id) + + def test_update_requires_a_field(self, client: Client) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.issue.update(1) + + +class TestIssueLifecycleAsync: + """Full CRUD lifecycle tests for async v2 issue operations.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self, async_client: AsyncClient) -> None: + """Create -> details -> update -> solve -> reopen -> archive.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = await async_client.v2.issue.create( + MEETING_ID, title, notes="Async lifecycle notes" + ) + try: + assert created.title == title + + details = await _poll_async( + lambda: async_client.v2.issue.details(created.id), + lambda i: i.notes is not None, + ) + assert details.notes == "Async lifecycle notes" + + updated = await async_client.v2.issue.update( + created.id, title=f"{title} (updated)" + ) + assert updated.title == f"{title} (updated)" + + solved = await async_client.v2.issue.solve(created.id) + assert solved.completed is True + + reopened = await async_client.v2.issue.reopen(created.id) + assert reopened.completed is False + finally: + await async_client.v2.issue.archive(created.id) + + @pytest.mark.asyncio + async def test_archive_and_restore(self, async_client: AsyncClient) -> None: + """`archive()`/`restore()` toggle the `archived` flag.""" + tag = uuid.uuid4().hex[:8] + created = await async_client.v2.issue.create(MEETING_ID, _title(tag)) + + try: + archived = await async_client.v2.issue.archive(created.id) + assert archived.archived is True + + restored = await async_client.v2.issue.restore(created.id) + assert restored.archived is False + finally: + await async_client.v2.issue.archive(created.id) + + @pytest.mark.asyncio + async def test_update_requires_a_field(self, async_client: AsyncClient) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.issue.update(1) diff --git a/tests/test_integration_v2_metric.py b/tests/test_integration_v2_metric.py new file mode 100644 index 0000000..0a36174 --- /dev/null +++ b/tests/test_integration_v2_metric.py @@ -0,0 +1,356 @@ +"""Integration tests for v2 (GraphQL) metric operations against the real API. + +Runs against the dedicated "SDK v2 API" test meeting only, and archives +everything it creates (metrics cannot be un-archived through the GraphQL +API, only archived -- there is no `restore()`). +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from bloomy import AsyncClient, Client +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Metric, MetricFrequency, MetricRule, MetricUnit + +MEETING_ID = 349524 # "v2 API" test meeting + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def client() -> Client: + """Create a real Bloomy client for integration tests. + + Yields: + A configured Client instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = Client(api_key=api_key) + yield c + c.close() + + +@pytest_asyncio.fixture +async def async_client() -> AsyncClient: + """Create a real async Bloomy client for integration tests. + + Yields: + A configured AsyncClient instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = AsyncClient(api_key=api_key) + yield c + await c.close() + + +def _title(tag: str) -> str: + return f"SDK v2 integration test metric {tag}" + + +def _poll_sync( + fn: Callable[[], Metric], + predicate: Callable[[Metric], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Metric: + """Poll `fn()` until `predicate(result)` is true (notes pads lag a bit). + + Returns: + The last polled `Metric`. + + """ + result = fn() + for _ in range(attempts): + if predicate(result): + return result + time.sleep(delay) + result = fn() + return result + + +async def _poll_async( + fn: Callable[[], Awaitable[Metric]], + predicate: Callable[[Metric], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Metric: + """Async equivalent of `_poll_sync`. + + Returns: + The last polled `Metric`. + + """ + result = await fn() + for _ in range(attempts): + if predicate(result): + return result + await asyncio.sleep(delay) + result = await fn() + return result + + +class TestMetricLifecycleSync: + """Full CRUD + scoring lifecycle tests for sync v2 metric operations.""" + + def test_full_lifecycle(self, client: Client) -> None: + """Create -> details -> list -> update -> score -> archive.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = client.v2.metric.create( + MEETING_ID, + title, + goal=100, + notes="Lifecycle notes", + ) + try: + assert isinstance(created, Metric) + assert created.title == title + assert created.owner is not None + assert created.units == MetricUnit.NONE + assert created.rule == MetricRule.GREATER_THAN + assert created.frequency == MetricFrequency.WEEKLY + assert created.goal == 100.0 + assert created.archived is False + assert created.is_externally_synced is False + + details = _poll_sync( + lambda: client.v2.metric.details(created.id), + lambda m: m.notes is not None, + ) + assert details.notes == "Lifecycle notes" + + listed = client.v2.metric.list(meeting_id=MEETING_ID) + assert any(m.id == created.id for m in listed) + + listed_by_frequency = client.v2.metric.list( + meeting_id=MEETING_ID, frequency=MetricFrequency.WEEKLY + ) + assert any(m.id == created.id for m in listed_by_frequency) + + listed_by_user = client.v2.metric.list(user_id=client.v2.metric.user_id) + assert any(m.id == created.id for m in listed_by_user) + + updated = client.v2.metric.update(created.id, title=f"{title} (updated)") + assert updated.title == f"{title} (updated)" + + # Scoring: set (upsert) -> re-set (same week) -> update -> clear. + now = datetime.now(tz=UTC) + score = client.v2.metric.set_score(created.id, 120, now) + assert score.value == 120.0 + assert score.metric_id == created.id + + upserted = client.v2.metric.set_score(created.id, 130, now) + assert upserted.id == score.id + assert upserted.value == 130.0 + + scores = client.v2.metric.scores(created.id) + assert any(s.id == score.id and s.value == 130.0 for s in scores) + + edited = client.v2.metric.update_score( + created.id, score.id, value=140, notes="edited" + ) + assert edited.value == 140.0 + assert edited.notes == "edited" + + cleared = client.v2.metric.clear_score(created.id, score.id) + assert cleared.value is None + + scores_without_empty = client.v2.metric.scores(created.id) + assert all(s.id != score.id for s in scores_without_empty) + + scores_with_empty = client.v2.metric.scores(created.id, include_empty=True) + assert any(s.id == score.id for s in scores_with_empty) + finally: + # Cleanup: archive regardless of where the test got to (there is + # no restore -- see the module docstring). + client.v2.metric.archive(created.id) + + def test_daily_set_score_falls_back_to_edit(self, client: Client) -> None: + """A second `set_score()` on the same DAILY day edits, not duplicates.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.metric.create( + MEETING_ID, _title(tag), frequency=MetricFrequency.DAILY + ) + + try: + today = datetime.now(tz=UTC) + first = client.v2.metric.set_score(created.id, 1, today) + second = client.v2.metric.set_score(created.id, 2, today) + + assert second.id == first.id + assert second.value == 2.0 + finally: + client.v2.metric.archive(created.id) + + def test_set_score_unparseable_value_raises(self, client: Client) -> None: + """`set_score()` raises when the value cannot be parsed as a number.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.metric.create(MEETING_ID, _title(tag)) + + try: + with pytest.raises(GraphQLError, match="could not be parsed"): + client.v2.metric.set_score( + created.id, "not-a-number", datetime.now(tz=UTC) + ) + finally: + client.v2.metric.archive(created.id) + + def test_between_rule_uses_min_max_goal(self, client: Client) -> None: + """`create(rule=BETWEEN, min_goal=..., max_goal=...)` round-trips.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.metric.create( + MEETING_ID, + _title(tag), + rule=MetricRule.BETWEEN, + min_goal=10, + max_goal=20, + ) + + try: + assert created.rule == MetricRule.BETWEEN + assert created.min_goal == 10.0 + finally: + client.v2.metric.archive(created.id) + + def test_create_goal_with_between_raises(self, client: Client) -> None: + """`create(rule=BETWEEN, goal=...)` raises `ValueError` before any request.""" + with pytest.raises(ValueError, match="BETWEEN"): + client.v2.metric.create( + MEETING_ID, "unused", rule=MetricRule.BETWEEN, goal=10 + ) + + def test_list_requires_only_one_scope(self, client: Client) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + with pytest.raises(ValueError, match="not both"): + client.v2.metric.list(meeting_id=MEETING_ID, user_id=1305290) + + def test_update_requires_a_field(self, client: Client) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.metric.update(1) + + def test_update_score_requires_a_field(self, client: Client) -> None: + """`update_score()` with neither `value` nor `notes` raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one of"): + client.v2.metric.update_score(1, 1) + + +class TestMetricLifecycleAsync: + """Full CRUD + scoring lifecycle tests for async v2 metric operations.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self, async_client: AsyncClient) -> None: + """Create -> details -> update -> score -> archive.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = await async_client.v2.metric.create( + MEETING_ID, title, goal=50, notes="Async lifecycle notes" + ) + try: + assert created.title == title + assert created.goal == 50.0 + + details = await _poll_async( + lambda: async_client.v2.metric.details(created.id), + lambda m: m.notes is not None, + ) + assert details.notes == "Async lifecycle notes" + + listed = await async_client.v2.metric.list(meeting_id=MEETING_ID) + assert any(m.id == created.id for m in listed) + + updated = await async_client.v2.metric.update( + created.id, title=f"{title} (updated)" + ) + assert updated.title == f"{title} (updated)" + + now = datetime.now(tz=UTC) + score = await async_client.v2.metric.set_score(created.id, 77, now) + assert score.value == 77.0 + + edited = await async_client.v2.metric.update_score( + created.id, score.id, value=88 + ) + assert edited.value == 88.0 + + cleared = await async_client.v2.metric.clear_score(created.id, score.id) + assert cleared.value is None + finally: + await async_client.v2.metric.archive(created.id) + + @pytest.mark.asyncio + async def test_daily_set_score_falls_back_to_edit( + self, async_client: AsyncClient + ) -> None: + """A second `set_score()` on the same DAILY day edits, not duplicates.""" + tag = uuid.uuid4().hex[:8] + created = await async_client.v2.metric.create( + MEETING_ID, _title(tag), frequency=MetricFrequency.DAILY + ) + + try: + today = datetime.now(tz=UTC) + first = await async_client.v2.metric.set_score(created.id, 1, today) + second = await async_client.v2.metric.set_score(created.id, 2, today) + + assert second.id == first.id + assert second.value == 2.0 + finally: + await async_client.v2.metric.archive(created.id) + + @pytest.mark.asyncio + async def test_scores_time_range(self, async_client: AsyncClient) -> None: + """`scores(start=..., end=...)` narrows the returned scores.""" + tag = uuid.uuid4().hex[:8] + created = await async_client.v2.metric.create(MEETING_ID, _title(tag)) + + try: + now = datetime.now(tz=UTC) + await async_client.v2.metric.set_score(created.id, 5, now) + + # Weekly scores are stored at the Sunday-anchored start of their + # week (verified live), which can be up to 6 days before `now`; + # widen the window well past that instead of assuming the score + # timestamp is close to `now`. + scores = await async_client.v2.metric.scores( + created.id, + start=now - timedelta(days=10), + end=now + timedelta(days=3), + ) + assert any(s.value == 5.0 for s in scores) + finally: + await async_client.v2.metric.archive(created.id) + + @pytest.mark.asyncio + async def test_update_requires_a_field(self, async_client: AsyncClient) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.metric.update(1) + + @pytest.mark.asyncio + async def test_update_score_requires_a_field( + self, async_client: AsyncClient + ) -> None: + """`update_score()` with neither `value` nor `notes` raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one of"): + await async_client.v2.metric.update_score(1, 1) diff --git a/tests/test_integration_v2_todo.py b/tests/test_integration_v2_todo.py new file mode 100644 index 0000000..3eec92c --- /dev/null +++ b/tests/test_integration_v2_todo.py @@ -0,0 +1,246 @@ +"""Integration tests for v2 (GraphQL) to-do operations against the real API. + +Runs against the dedicated "SDK v2 API" test meeting only, and archives +everything it creates. +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from bloomy import AsyncClient, Client +from bloomy.v2.models import Todo + +MEETING_ID = 349524 # "v2 API" test meeting + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def client() -> Client: + """Create a real Bloomy client for integration tests. + + Yields: + A configured Client instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = Client(api_key=api_key) + yield c + c.close() + + +@pytest_asyncio.fixture +async def async_client() -> AsyncClient: + """Create a real async Bloomy client for integration tests. + + Yields: + A configured AsyncClient instance. + + """ + api_key = os.environ.get("BG_API_KEY") + if not api_key: + pytest.skip("BG_API_KEY not set") + c = AsyncClient(api_key=api_key) + yield c + await c.close() + + +def _title(tag: str) -> str: + return f"SDK v2 integration test to-do {tag}" + + +def _poll_sync( + fn: Callable[[], Todo], + predicate: Callable[[Todo], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Todo: + """Poll `fn()` until `predicate(result)` is true (notes pads lag a bit). + + Returns: + The last polled `Todo`. + + """ + result = fn() + for _ in range(attempts): + if predicate(result): + return result + time.sleep(delay) + result = fn() + return result + + +async def _poll_async( + fn: Callable[[], Awaitable[Todo]], + predicate: Callable[[Todo], bool], + attempts: int = 6, + delay: float = 1.5, +) -> Todo: + """Async equivalent of `_poll_sync`. + + Returns: + The last polled `Todo`. + + """ + result = await fn() + for _ in range(attempts): + if predicate(result): + return result + await asyncio.sleep(delay) + result = await fn() + return result + + +class TestTodoLifecycleSync: + """Full CRUD lifecycle tests for sync v2 to-do operations.""" + + def test_full_lifecycle(self, client: Client) -> None: + """Create -> details -> list -> update -> complete -> reopen -> archive.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = client.v2.todo.create( + title, meeting_id=MEETING_ID, notes="Lifecycle notes" + ) + try: + assert isinstance(created, Todo) + assert created.title == title + assert created.owner is not None + assert created.meeting is not None and created.meeting.id == MEETING_ID + assert created.completed is False + assert created.archived is False + assert created.due_date is not None + + details = _poll_sync( + lambda: client.v2.todo.details(created.id), + lambda t: t.notes is not None, + ) + assert details.notes == "Lifecycle notes" + + listed = client.v2.todo.list(meeting_id=MEETING_ID) + assert any(t.id == created.id for t in listed) + + updated = client.v2.todo.update(created.id, title=f"{title} (updated)") + assert updated.title == f"{title} (updated)" + + completed = client.v2.todo.complete(created.id) + assert completed.completed is True + assert completed.completed_date is not None + + reopened = client.v2.todo.reopen(created.id) + assert reopened.completed is False + assert reopened.completed_date is None + finally: + # Cleanup: archive regardless of where the test got to. + client.v2.todo.archive(created.id) + + def test_archive_and_restore(self, client: Client) -> None: + """`archive()`/`restore()` toggle the `archived` flag.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.todo.create(_title(tag), meeting_id=MEETING_ID) + + try: + archived = client.v2.todo.archive(created.id) + assert archived.archived is True + + archived_list = client.v2.todo.list( + meeting_id=MEETING_ID, include_archived=True + ) + assert any(t.id == created.id for t in archived_list) + + restored = client.v2.todo.restore(created.id) + assert restored.archived is False + finally: + client.v2.todo.archive(created.id) + + def test_personal_todo(self, client: Client) -> None: + """`create()` without `meeting_id` makes a personal to-do.""" + tag = uuid.uuid4().hex[:8] + created = client.v2.todo.create(_title(tag)) + + try: + assert created.meeting is None + + listed = client.v2.todo.list() + assert any(t.id == created.id for t in listed) + finally: + client.v2.todo.archive(created.id) + + def test_list_requires_only_one_scope(self, client: Client) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + with pytest.raises(ValueError, match="not both"): + client.v2.todo.list(meeting_id=MEETING_ID, user_id=1305290) + + def test_update_requires_a_field(self, client: Client) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + client.v2.todo.update(1) + + +class TestTodoLifecycleAsync: + """Full CRUD lifecycle tests for async v2 to-do operations.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self, async_client: AsyncClient) -> None: + """Create -> details -> update -> complete -> reopen -> archive.""" + tag = uuid.uuid4().hex[:8] + title = _title(tag) + + created = await async_client.v2.todo.create( + title, meeting_id=MEETING_ID, notes="Async lifecycle notes" + ) + try: + assert created.title == title + + details = await _poll_async( + lambda: async_client.v2.todo.details(created.id), + lambda t: t.notes is not None, + ) + assert details.notes == "Async lifecycle notes" + + updated = await async_client.v2.todo.update( + created.id, title=f"{title} (updated)" + ) + assert updated.title == f"{title} (updated)" + + completed = await async_client.v2.todo.complete(created.id) + assert completed.completed is True + + reopened = await async_client.v2.todo.reopen(created.id) + assert reopened.completed is False + finally: + await async_client.v2.todo.archive(created.id) + + @pytest.mark.asyncio + async def test_archive_and_restore(self, async_client: AsyncClient) -> None: + """`archive()`/`restore()` toggle the `archived` flag.""" + tag = uuid.uuid4().hex[:8] + created = await async_client.v2.todo.create(_title(tag), meeting_id=MEETING_ID) + + try: + archived = await async_client.v2.todo.archive(created.id) + assert archived.archived is True + + restored = await async_client.v2.todo.restore(created.id) + assert restored.archived is False + finally: + await async_client.v2.todo.archive(created.id) + + @pytest.mark.asyncio + async def test_update_requires_a_field(self, async_client: AsyncClient) -> None: + """`update()` with no fields raises `ValueError`.""" + with pytest.raises(ValueError, match="At least one field"): + await async_client.v2.todo.update(1) diff --git a/tests/test_v2_base.py b/tests/test_v2_base.py new file mode 100644 index 0000000..2c3aa5a --- /dev/null +++ b/tests/test_v2_base.py @@ -0,0 +1,370 @@ +"""Tests for the v2 (GraphQL) transport and shared helpers.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from unittest.mock import AsyncMock, MagicMock, Mock + +import httpx +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.base import ( + AsyncGraphQLOperations, + GraphQLOperations, + extract_notes, + prepare_note_text, +) + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + + +def _response( + json_data: object, status_code: int = 200, raise_error: Exception | None = None +) -> Mock: + response = Mock(spec=httpx.Response) + response.status_code = status_code + response.json.return_value = json_data + if raise_error is not None: + response.raise_for_status.side_effect = raise_error + else: + response.raise_for_status = Mock() + return response + + +class TestParseResponse: + """Tests for `_parse_response` (shared by `_execute`).""" + + def test_returns_data_on_success(self) -> None: + """A clean 200 with `data` and no `errors` returns `data`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"user": {"id": 1}}}) + + result = ops._execute("query {}") + + assert result == {"user": {"id": 1}} + + def test_missing_data_returns_empty_dict(self) -> None: + """A response with no `data` key returns an empty dict.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response({}) + + assert ops._execute("query {}") == {} + + def test_http_400_with_errors_raises_graphql_error(self) -> None: + """A validation error (HTTP 400 + `errors`) raises `GraphQLError`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "field does not exist"}]}, status_code=400 + ) + + with pytest.raises(GraphQLError) as exc_info: + ops._execute("query {}") + + assert exc_info.value.status_code == 400 + assert exc_info.value.errors == [{"message": "field does not exist"}] + assert "field does not exist" in str(exc_info.value) + + def test_execution_error_200_with_errors_raises_graphql_error(self) -> None: + """An execution error (HTTP 200 + `errors` alongside `data`) still raises.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "boom"}], "data": {"issue": None}} + ) + + with pytest.raises(GraphQLError) as exc_info: + ops._execute("query {}") + + assert exc_info.value.status_code == 200 + assert exc_info.value.errors == [{"message": "boom"}] + + def test_non_json_failure_falls_back_to_raise_for_status(self) -> None: + """A non-JSON failure response (e.g. plain-text 500) raises via httpx.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + response = Mock(spec=httpx.Response) + response.status_code = 500 + response.json.side_effect = ValueError("not json") + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500", request=Mock(), response=response + ) + client.post.return_value = response + + with pytest.raises(httpx.HTTPStatusError): + ops._execute("query {}") + + def test_json_without_errors_still_calls_raise_for_status(self) -> None: + """A JSON body without `errors` still surfaces a non-2xx via httpx. + + This is what makes an expired-token 401 (or any JSON error body that + does not carry an `errors` array) raise a clear error instead of + failing deep inside `data` access. + """ + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + response = _response( + {"message": "Unauthorized"}, + status_code=401, + raise_error=httpx.HTTPStatusError("401", request=Mock(), response=Mock()), + ) + client.post.return_value = response + + with pytest.raises(httpx.HTTPStatusError): + ops._execute("query {}") + + +class TestCheckMutationResult: + """Tests for `_check_mutation_result` (shared by `_run_mutation`).""" + + def test_success_does_not_raise(self) -> None: + """A `success: true` result passes silently.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + ops._check_mutation_result({"success": True}, action="do thing") + + def test_success_false_raises_with_message(self) -> None: + """A `success: false` result raises with the top-level `message`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="not allowed"): + ops._check_mutation_result( + {"success": False, "message": "not allowed", "errorDetails": None}, + action="do thing", + ) + + def test_success_false_falls_back_to_error_details(self) -> None: + """Without a top-level `message`, `errorDetails` messages are used.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="bad title") as exc_info: + ops._check_mutation_result( + { + "success": False, + "message": None, + "errorDetails": [{"message": "bad title"}], + }, + action="do thing", + ) + assert exc_info.value.errors == [{"message": "bad title"}] + + def test_missing_result_raises(self) -> None: + """A missing mutation result (`None`) raises with the action name.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="do thing failed"): + ops._check_mutation_result(None, action="do thing") + + +class TestRunMutation: + """Tests for `_run_mutation`.""" + + def test_run_mutation_returns_result_on_success(self) -> None: + """`_run_mutation` returns the root field's result on success.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"EditIssue": {"success": True, "message": None}}} + ) + + result = ops._run_mutation( + "mutation {}", {"input": {}}, root_field="EditIssue", action="update" + ) + + assert result == {"success": True, "message": None} + + def test_run_mutation_raises_on_failure(self) -> None: + """`_run_mutation` raises `GraphQLError` when `success` is false.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "EditIssue": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + ops._run_mutation( + "mutation {}", {"input": {}}, root_field="EditIssue", action="update" + ) + + +class TestCreateNote: + """Tests for `_create_note`.""" + + def test_create_note_escapes_and_returns_pad_id(self) -> None: + """`_create_note` escapes the text and returns the new pad id.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"CreateNote": {"success": True, "data": "pad-123"}}} + ) + + pad_id = ops._create_note("line one\nline two & raw") + + assert pad_id == "pad-123" + sent_variables = client.post.call_args.kwargs["json"]["variables"] + assert sent_variables == { + "text": "line one
line two & <b>raw</b>" + } + + def test_create_note_raises_on_failure(self) -> None: + """`_create_note` raises `GraphQLError` when `CreateNote` fails.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "CreateNote": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError): + ops._create_note("text") + + +class TestTimestampHelpers: + """Tests for `_to_timestamp` and `_now_timestamp`.""" + + def test_naive_datetime_treated_as_utc(self) -> None: + """A naive datetime is treated as UTC.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + naive = datetime(2024, 1, 1, 0, 0, 0) + aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + + assert ops._to_timestamp(naive) == aware.timestamp() + + def test_aware_datetime_preserved(self) -> None: + """A timezone-aware datetime converts directly.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + + assert ops._to_timestamp(aware) == aware.timestamp() + + def test_date_becomes_midnight_utc(self) -> None: + """A `date` becomes 00:00 UTC of that day.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + day = date(2024, 1, 1) + expected = datetime(2024, 1, 1, tzinfo=UTC).timestamp() + + assert ops._to_timestamp(day) == expected + + def test_numbers_pass_through_as_float(self) -> None: + """A float/int is returned as-is (as a float).""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + assert ops._to_timestamp(1700000000) == 1700000000.0 + assert ops._to_timestamp(1700000000.5) == 1700000000.5 + + def test_now_timestamp_is_close_to_now(self) -> None: + """`_now_timestamp` is close to the current time.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + assert abs(ops._now_timestamp() - datetime.now(tz=UTC).timestamp()) < 5 + + +class TestNotesHelpers: + """Tests for `prepare_note_text` and `extract_notes`.""" + + def test_prepare_note_text_escapes_and_converts_newlines(self) -> None: + """Newlines become `
`, and HTML-sensitive characters are escaped.""" + assert prepare_note_text("a & b\n") == "a & b
<c>" + + def test_extract_notes_collaboration_enabled(self) -> None: + """When `collaborationEnabled`, use `notesText`, stripping trailing newlines.""" + data = { + "collaborationEnabled": True, + "notesText": "hello world\n\n", + "localHtml": None, + } + + assert extract_notes(data) == "hello world" + + def test_extract_notes_local_html_fallback(self) -> None: + """When not `collaborationEnabled`, strip tags and unescape `localHtml`.""" + data = { + "collaborationEnabled": False, + "notesText": "", + "localHtml": "

hello & world

", + } + + assert extract_notes(data) == "hello & world" + + def test_extract_notes_empty_returns_none(self) -> None: + """An empty description returns `None`.""" + data = {"collaborationEnabled": True, "notesText": "", "localHtml": None} + + assert extract_notes(data) is None + + +class TestSyncUserId: + """Tests for the sync `user_id` property.""" + + def test_fetches_and_caches(self) -> None: + """`user_id` fetches via `getAuthenticatedUserId` and caches the result.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"getAuthenticatedUserId": {"id": 42}}} + ) + + assert ops.user_id == 42 + assert ops.user_id == 42 + client.post.assert_called_once() + + +class TestAsyncUserId: + """Tests for the async `user_id`/`get_user_id`.""" + + def test_user_id_raises_before_fetch(self) -> None: + """Accessing `user_id` before `get_user_id()` raises `RuntimeError`.""" + client = AsyncMock() + ops = AsyncGraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(RuntimeError): + _ = ops.user_id + + @pytest.mark.asyncio + async def test_get_user_id_fetches_and_caches(self) -> None: + """`get_user_id()` fetches via `getAuthenticatedUserId` and caches it.""" + client = AsyncMock() + ops = AsyncGraphQLOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"getAuthenticatedUserId": {"id": 7}}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + assert await ops.get_user_id() == 7 + assert ops.user_id == 7 + client.post.assert_called_once() + + def test_user_id_setter(self) -> None: + """The `user_id` setter caches without a fetch.""" + client = AsyncMock() + ops = AsyncGraphQLOperations(client, GRAPHQL_URL) + ops.user_id = 99 + + assert ops.user_id == 99 diff --git a/tests/test_v2_client_wiring.py b/tests/test_v2_client_wiring.py new file mode 100644 index 0000000..68e4556 --- /dev/null +++ b/tests/test_v2_client_wiring.py @@ -0,0 +1,125 @@ +"""Tests for the v1/v2 namespace wiring on `Client`/`AsyncClient`.""" + +from __future__ import annotations + +from unittest.mock import patch + +from bloomy import AsyncClient, Client +from bloomy.v1 import V1, AsyncV1 +from bloomy.v2 import V2, AsyncV2 + + +class TestClientV1V2Wiring: + """Tests for `Client.v1`/`Client.v2` and the top-level aliases.""" + + def test_v1_and_v2_namespaces_exist(self) -> None: + """`client.v1` and `client.v2` are the expected namespace types.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + assert isinstance(client.v1, V1) + assert isinstance(client.v2, V2) + + def test_top_level_attributes_are_v1_aliases(self) -> None: + """Top-level attributes are the SAME objects as `client.v1.*`.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + assert client.user is client.v1.user + assert client.todo is client.v1.todo + assert client.meeting is client.v1.meeting + assert client.goal is client.v1.goal + assert client.scorecard is client.v1.scorecard + assert client.issue is client.v1.issue + assert client.headline is client.v1.headline + + def test_v2_namespace_has_user_meeting_issue(self) -> None: + """`client.v2` exposes `user`, `meeting`, and `issue` operations.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + assert client.v2.user is not None + assert client.v2.meeting is not None + assert client.v2.issue is not None + + def test_default_graphql_url_derived_from_base_url(self) -> None: + """The default `graphql_url` is the base URL's scheme+host plus `/graphql/`.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + assert client._graphql_url == "https://app.bloomgrowth.com/graphql/" + + def test_default_graphql_url_follows_custom_base_url(self) -> None: + """A custom `base_url` produces a matching default `graphql_url`.""" + with patch("bloomy.client.httpx.Client"): + client = Client( + api_key="test-key", base_url="https://staging.example.com/api/v1" + ) + + assert client._graphql_url == "https://staging.example.com/graphql/" + + def test_explicit_graphql_url_overrides_default(self) -> None: + """An explicit `graphql_url` is used as-is.""" + with patch("bloomy.client.httpx.Client"): + client = Client( + api_key="test-key", graphql_url="https://custom.example.com/gql" + ) + + assert client._graphql_url == "https://custom.example.com/gql" + + def test_v1_and_v2_share_the_same_http_client(self) -> None: + """v1 and v2 operations reuse the SAME httpx client instance.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + assert client.v1.user._client is client._client + assert client.v2.user._client is client._client + + +class TestAsyncClientV1V2Wiring: + """Tests for `AsyncClient.v1`/`AsyncClient.v2` and the top-level aliases.""" + + def test_v1_and_v2_namespaces_exist(self) -> None: + """`client.v1` and `client.v2` are the expected namespace types.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient(api_key="test-key") + + assert isinstance(client.v1, AsyncV1) + assert isinstance(client.v2, AsyncV2) + + def test_top_level_attributes_are_v1_aliases(self) -> None: + """Top-level attributes are the SAME objects as `client.v1.*`.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient(api_key="test-key") + + assert client.user is client.v1.user + assert client.todo is client.v1.todo + assert client.meeting is client.v1.meeting + assert client.goal is client.v1.goal + assert client.scorecard is client.v1.scorecard + assert client.issue is client.v1.issue + assert client.headline is client.v1.headline + + def test_default_graphql_url_derived_from_base_url(self) -> None: + """The default `graphql_url` is the base URL's scheme+host plus `/graphql/`.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient(api_key="test-key") + + assert client._graphql_url == "https://app.bloomgrowth.com/graphql/" + + def test_explicit_graphql_url_overrides_default(self) -> None: + """An explicit `graphql_url` is used as-is.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient( + api_key="test-key", graphql_url="https://custom.example.com/gql" + ) + + assert client._graphql_url == "https://custom.example.com/gql" + + def test_v1_and_v2_share_the_same_http_client(self) -> None: + """v1 and v2 operations reuse the SAME httpx client instance.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient(api_key="test-key") + + assert client.v1.user._client is client._client + assert client.v2.user._client is client._client diff --git a/tests/test_v2_goal.py b/tests/test_v2_goal.py new file mode 100644 index 0000000..dd8feb3 --- /dev/null +++ b/tests/test_v2_goal.py @@ -0,0 +1,646 @@ +"""Tests for the v2 (GraphQL) goal operations.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Goal, GoalStatus +from bloomy.v2.operations.goal import AsyncGoalOperations, GoalOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +MILESTONE_NODE = { + "id": 6440354, + "goalId": 5265048, + "title": "SDK v2 test milestone", + "dueDate": 1767225600, + "completed": False, + "status": None, + "dateCreated": 1790278069, +} + +GOAL_NODE = { + "id": 5265048, + "title": "SDK v2 test goal", + "status": "ON_TRACK", + "dueDate": 1798761600, + "archived": False, + "archivedTimestamp": None, + "dateCreated": 1790278069, + "notesId": "pad-1", + "notesText": "some notes\n", + "localHtml": None, + "collaborationEnabled": True, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, + "meetings": {"nodes": [{"id": 349524, "name": "v2 API"}]}, + "milestones": {"nodes": [MILESTONE_NODE]}, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +def _async_response(json_data: object) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = MagicMock() + return response + + +class TestGoalOperationsSync: + """Tests for the sync `GoalOperations`.""" + + def test_details(self) -> None: + """`details()` returns a transformed `Goal`, including milestones and notes.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"goal": GOAL_NODE}}) + + result = ops.details(5265048) + + assert isinstance(result, Goal) + assert result.id == 5265048 + assert result.owner is not None and result.owner.id == 1305290 + assert result.status == GoalStatus.ON_TRACK + assert result.notes == "some notes" + assert len(result.milestones) == 1 + assert result.milestones[0].id == 6440354 + assert len(result.meetings) == 1 + assert result.meetings[0].id == 349524 + # Transforms: unix-seconds timestamps become UTC-aware datetimes. + assert result.created_date.tzinfo is not None + assert result.due_date is not None and result.due_date.tzinfo is not None + + def test_details_with_null_assignee(self) -> None: + """A goal with a `null` `assignee` produces `owner=None`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + node = {**GOAL_NODE, "assignee": None} + client.post.return_value = _response({"data": {"goal": node}}) + + result = ops.details(5265048) + + assert result.owner is None + + def test_list_by_meeting_default_where(self) -> None: + """`list(meeting_id=...)` filters archived goals out by default.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"goals": {"nodes": [GOAL_NODE]}}}} + ) + + result = ops.list(meeting_id=349524) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == {"and": [{"archived": {"eq": False}}]} + + def test_list_by_user_uses_user_goals_connection(self) -> None: + """`list(user_id=...)` queries `user(id){ goals }`, not root `goals(userId)`. + + Root `goals(userId)` unconditionally excludes archived goals + regardless of `where` (verified live), so `user(id){ goals }` is + used instead to make `include_archived` work. + """ + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"user": {"goals": {"nodes": [GOAL_NODE]}}}} + ) + + result = ops.list(user_id=1305290, include_archived=True) + + assert len(result) == 1 + sent_query = client.post.call_args.kwargs["json"]["query"] + assert "goals(userId:" not in sent_query + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + assert variables["where"] is None + + def test_list_defaults_to_current_user(self) -> None: + """`list()` with neither id defaults to the current user.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"user": {"goals": {"nodes": []}}}}), + ] + + ops.list() + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + + def test_list_both_ids_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + ops.list(meeting_id=349524, user_id=1305290) + + def test_create_defaults(self) -> None: + """`create()` defaults the due date to 90 days out and attaches the meeting.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + result = ops.create(349524, "SDK v2 test goal", user_id=1305290) + + assert result.id == 5265048 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + input_ = create_variables["input"] + assert input_["title"] == "SDK v2 test goal" + assert input_["assignee"] == 1305290 + assert input_["status"] == "ON_TRACK" + assert input_["meetingsAndPlans"] == [ + {"meetingId": 349524, "addToDepartmentPlan": False} + ] + assert isinstance(input_["dueDate"], float) + assert "notesId" not in input_ + assert "milestones" not in input_ + + def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateGoal`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.create(349524, "SDK v2 test goal", user_id=1305290, notes="hello") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + assert create_variables["input"]["collaborationEnabled"] is True + + def test_create_with_milestones_dicts_and_tuples(self) -> None: + """`create(milestones=...)` accepts both dicts and tuples.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.create( + 349524, + "SDK v2 test goal", + user_id=1305290, + milestones=[ + {"title": "M1", "due_date": 1767225600}, + ("M2", 1767225600, True), + ], + ) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + milestones = create_variables["input"]["milestones"] + assert milestones == [ + {"title": "M1", "dueDate": 1767225600.0, "completed": False}, + {"title": "M2", "dueDate": 1767225600.0, "completed": True}, + ] + + def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.create(349524, "SDK v2 test goal") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["assignee"] == 1305290 + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(5265048) + + def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + result = ops.update(5265048, title="New title") + + assert result.id == 5265048 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"goalId": 5265048, "title": "New title"}} + + def test_update_status_and_due_date(self) -> None: + """`update(status=..., due_date=...)` sends both as GraphQL-shaped values.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.update(5265048, status=GoalStatus.OFF_TRACK, due_date=1799000000) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["status"] == "OFF_TRACK" + assert edit_input["dueDate"] == 1799000000.0 + + def test_archive_success(self) -> None: + """`archive()` sends `archived: true` and confirms the re-read is archived.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": {**GOAL_NODE, "archived": True}}}), + ] + + result = ops.archive(5265048) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"goalId": 5265048, "archived": True} + + def test_archive_raises_when_still_not_archived(self) -> None: + """`archive()` raises `GraphQLError` if the re-read is still not archived. + + `EditGoal{archived: true}` can report success without archiving + anything (verified live: an un-awaited detach call can swallow its + own exceptions server-side). + """ + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), # archived: False + ] + + with pytest.raises(GraphQLError, match="still not archived"): + ops.archive(5265048) + + def test_restore(self) -> None: + """`restore()` sends `archived: false`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.restore(5265048) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"goalId": 5265048, "archived": False} + + def test_update_user_id_and_notes(self) -> None: + """`update(user_id=..., notes=...)` sends `assignee` and calls `CreateNote`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-42"}}}), + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.update(5265048, user_id=999, notes="Updated notes") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "goalId": 5265048, + "assignee": 999, + "notesId": "pad-42", + "collaborationEnabled": True, + } + + def test_update_due_date_accepts_datetime_object(self) -> None: + """`update(due_date=...)` accepts a `datetime`, not only a unix timestamp.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + due_date = datetime(2026, 1, 1, tzinfo=UTC) + + ops.update(5265048, due_date=due_date) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"goalId": 5265048, "dueDate": due_date.timestamp()} + + def test_list_by_meeting_include_archived(self) -> None: + """`list(meeting_id=..., include_archived=True)` sends an unfiltered `where`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"goals": {"nodes": [GOAL_NODE]}}}} + ) + + ops.list(meeting_id=349524, include_archived=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] is None + + def test_list_by_meeting_with_null_meeting_returns_empty(self) -> None: + """A `null` `meeting` (e.g. an unknown meeting id) yields an empty list.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + result = ops.list(meeting_id=999999) + + assert result == [] + + def test_transform_goal_with_missing_milestones_and_meetings(self) -> None: + """A goal payload without `milestones`/`meetings` keys yields empty lists.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + node = { + key: value + for key, value in GOAL_NODE.items() + if key not in ("milestones", "meetings") + } + client.post.return_value = _response({"data": {"goal": node}}) + + result = ops.details(5265048) + + assert result.milestones == [] + assert result.meetings == [] + + def test_create_with_notes_failure_raises(self) -> None: + """A failed `CreateNote` call raises `GraphQLError` before `CreateGoal` runs.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "CreateNote": { + "success": False, + "message": "note failed", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="note failed"): + ops.create(349524, "SDK v2 test goal", user_id=1305290, notes="hello") + + assert client.post.call_count == 1 + + def test_details_raises_graphql_error_on_http_400_with_errors(self) -> None: + """A body with a top-level `errors` array raises `GraphQLError` on HTTP 400.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + response = Mock() + response.status_code = 400 + response.json.return_value = {"errors": [{"message": "Goal not found"}]} + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Goal not found") as exc_info: + ops.details(5265048) + + assert exc_info.value.status_code == 400 + response.raise_for_status.assert_not_called() + + def test_list_raises_graphql_error_on_200_with_errors(self) -> None: + """A 200 body with a top-level `errors` array still raises `GraphQLError`.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + response = Mock() + response.status_code = 200 + response.json.return_value = { + "data": None, + "errors": [{"message": "execution failed"}], + } + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="execution failed"): + ops.list(meeting_id=349524) + + +class TestGoalOperationsAsync: + """Tests for the async `AsyncGoalOperations`.""" + + @pytest.mark.asyncio + async def test_details(self) -> None: + """`details()` returns a transformed `Goal`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"goal": GOAL_NODE}}) + + result = await ops.details(5265048) + + assert result.id == 5265048 + assert result.notes == "some notes" + assert len(result.milestones) == 1 + + @pytest.mark.asyncio + async def test_list_both_ids_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + await ops.list(meeting_id=349524, user_id=1305290) + + @pytest.mark.asyncio + async def test_list_by_meeting(self) -> None: + """`list(meeting_id=...)` queries `meeting(id){ goals }`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"goals": {"nodes": [GOAL_NODE]}}}} + ) + + result = await ops.list(meeting_id=349524) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == {"and": [{"archived": {"eq": False}}]} + + @pytest.mark.asyncio + async def test_list_by_user_defaults_to_current_user(self) -> None: + """`list()` with neither id fetches and uses the authenticated user's id.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"user": {"goals": {"nodes": [GOAL_NODE]}}}}), + ] + + result = await ops.list() + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(5265048) + + @pytest.mark.asyncio + async def test_update_full_fields(self) -> None: + """`update()` with every field sends each and re-reads the goal.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-7"}}} + ), + _async_response({"data": {"EditGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), + ] + + result = await ops.update( + 5265048, + title="New title", + status=GoalStatus.OFF_TRACK, + due_date=1799000000, + user_id=999, + notes="Updated notes", + ) + + assert result.id == 5265048 + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "goalId": 5265048, + "title": "New title", + "status": "OFF_TRACK", + "dueDate": 1799000000.0, + "assignee": 999, + "notesId": "pad-7", + "collaborationEnabled": True, + } + + @pytest.mark.asyncio + async def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"CreateGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), + ] + + await ops.create(349524, "SDK v2 test goal") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["assignee"] == 1305290 + + @pytest.mark.asyncio + async def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateGoal`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-99"}}} + ), + _async_response({"data": {"CreateGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), + ] + + await ops.create(349524, "SDK v2 test goal", user_id=1305290, notes="hello") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + assert create_variables["input"]["collaborationEnabled"] is True + + @pytest.mark.asyncio + async def test_create_with_milestones(self) -> None: + """`create(milestones=...)` builds `Goal_MilestoneCreateModelInput` entries.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), + ] + + await ops.create( + 349524, + "SDK v2 test goal", + user_id=1305290, + milestones=[("M1", 1767225600)], + ) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables["input"]["milestones"] == [ + {"title": "M1", "dueDate": 1767225600.0, "completed": False} + ] + + @pytest.mark.asyncio + async def test_archive_success(self) -> None: + """`archive()` returns the re-read goal once it is confirmed archived.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": {**GOAL_NODE, "archived": True}}}), + ] + + result = await ops.archive(5265048) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"goalId": 5265048, "archived": True} + + @pytest.mark.asyncio + async def test_archive_raises_when_still_not_archived(self) -> None: + """`archive()` raises `GraphQLError` if the re-read is still not archived.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), # archived: False + ] + + with pytest.raises(GraphQLError, match="still not archived"): + await ops.archive(5265048) + + @pytest.mark.asyncio + async def test_restore(self) -> None: + """`restore()` sends `archived: false`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditGoal": {"id": 5265048}}}), + _async_response({"data": {"goal": GOAL_NODE}}), + ] + + await ops.restore(5265048) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"goalId": 5265048, "archived": False} diff --git a/tests/test_v2_headline.py b/tests/test_v2_headline.py new file mode 100644 index 0000000..1cc1ca3 --- /dev/null +++ b/tests/test_v2_headline.py @@ -0,0 +1,886 @@ +"""Tests for the v2 (GraphQL) headline operations.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Headline +from bloomy.v2.operations.headline import AsyncHeadlineOperations, HeadlineOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +HEADLINE_NODE = { + "id": 987654, + "title": "SDK v2 test headline", + "recurrenceId": 349524, + "notesId": "pad-1", + "notesText": "some notes\n", + "localHtml": None, + "collaborationEnabled": True, + "archived": False, + "archivedTimestamp": None, + "dateCreated": 1790275733, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, + "meeting": {"id": 349524, "name": "v2 API"}, +} + + +def _response(json_data: object, status_code: int = 200) -> Mock: + response = Mock() + response.status_code = status_code + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +def _async_response(json_data: object, status_code: int = 200) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.json.return_value = json_data + response.raise_for_status = MagicMock() + return response + + +class TestHeadlineOperationsSync: + """Tests for the sync `HeadlineOperations`.""" + + def test_details(self) -> None: + """`details()` returns a transformed `Headline`, including plain-text notes.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"headline": HEADLINE_NODE}}) + + result = ops.details(987654) + + assert isinstance(result, Headline) + assert result.id == 987654 + assert result.owner is not None and result.owner.id == 1305290 + assert result.meeting is not None and result.meeting.id == 349524 + assert result.notes == "some notes" + + def test_details_with_null_assignee(self) -> None: + """A headline with a `null` `assignee` produces `owner=None`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + node = {**HEADLINE_NODE, "assignee": None} + client.post.return_value = _response({"data": {"headline": node}}) + + result = ops.details(987654) + + assert result.owner is None + + def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + ops.list(meeting_id=349524, user_id=1305290) + + def test_list_by_meeting_open_only(self) -> None: + """`list(meeting_id=...)` queries `meeting.headlines` (open only) by default.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}} + ) + + result = ops.list(meeting_id=349524) + + assert len(result) == 1 + assert client.post.call_count == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"meetingId": 349524} + + def test_list_by_meeting_include_archived_merges(self) -> None: + """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + client.post.side_effect = [ + _response({"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}}), + _response( + {"data": {"meeting": {"archivedHeadlines": {"nodes": [archived_node]}}}} + ), + ] + + result = ops.list(meeting_id=349524, include_archived=True) + + assert client.post.call_count == 2 + assert {headline.id for headline in result} == {987654, 999} + second_query = client.post.call_args_list[1].kwargs["json"]["query"] + assert "archivedHeadlines" in second_query + + def test_list_by_user_filters_archived_client_side(self) -> None: + """`list(user_id=...)` drops archived headlines client-side by default. + + The root `headlines(userId)` connection includes archived headlines + with no server-side filter applied here, so `list()` filters them out + itself unless `include_archived=True`. + """ + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + client.post.return_value = _response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}}} + ) + + result = ops.list(user_id=1305290) + + assert len(result) == 1 + assert result[0].id == 987654 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290} + + def test_list_by_user_include_archived_keeps_all(self) -> None: + """`list(user_id=..., include_archived=True)` keeps archived headlines.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + client.post.return_value = _response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}}} + ) + + result = ops.list(user_id=1305290, include_archived=True) + + assert {headline.id for headline in result} == {987654, 999} + + def test_list_defaults_to_current_user(self) -> None: + """`list()` with neither id given fetches headlines for the current user.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"headlines": {"nodes": [HEADLINE_NODE]}}}), + ] + + result = ops.list() + + assert len(result) == 1 + variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert variables == {"userId": 1305290} + + def test_create_without_notes(self) -> None: + """`create()` without notes skips `CreateNote` and reads back details.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = ops.create(349524, "SDK v2 test headline", user_id=1305290) + + assert result.id == 987654 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "title": "SDK v2 test headline", + "assignee": 1305290, + "meetings": [349524], + } + } + + def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateHeadline`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.create(349524, "SDK v2 test headline", user_id=1305290, notes="hello") + + create_headline_variables = client.post.call_args_list[1].kwargs["json"][ + "variables" + ] + assert create_headline_variables["input"]["notesId"] == "pad-99" + assert create_headline_variables["input"]["collaborationEnabled"] is True + + def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.create(349524, "SDK v2 test headline") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["assignee"] == 1305290 + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(987654) + + def test_update_title(self) -> None: + """`update(title=...)` sends only the title field to `EditHeadline`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = ops.update(987654, title="New title") + + assert result.id == 987654 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"headlineId": 987654, "title": "New title"}} + + def test_update_user_id_sends_assignee(self) -> None: + """`update(user_id=...)` sends the `assignee` field, not `assigneeId`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.update(987654, user_id=42) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"headlineId": 987654, "assignee": 42} + + def test_update_raises_graphql_error_on_top_level_error(self) -> None: + """`update()` raises `GraphQLError` when the response carries `errors`. + + Unlike `EditIssue`, `EditHeadline` returns a plain `IdModel { id }` + with no `success`/`message` fields, so failures surface only through + the top-level GraphQL `errors` array. + """ + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "headline not found"}]} + ) + + with pytest.raises(GraphQLError, match="headline not found"): + ops.update(987654, title="New title") + + def test_archive(self) -> None: + """`archive()` sends `archived: true`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": {**HEADLINE_NODE, "archived": True}}}), + ] + + result = ops.archive(987654) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"headlineId": 987654, "archived": True} + + def test_restore(self) -> None: + """`restore()` sends `archived: false`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.restore(987654) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"headlineId": 987654, "archived": False} + + def test_update_with_notes_calls_create_note_first(self) -> None: + """`update(notes=...)` calls `CreateNote` before `EditHeadline`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-77"}}}), + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = ops.update(987654, notes="Updated notes") + + assert result.id == 987654 + assert client.post.call_count == 3 + note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert note_variables == {"text": "Updated notes"} + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "headlineId": 987654, + "notesId": "pad-77", + "collaborationEnabled": True, + } + + def test_update_all_fields_together(self) -> None: + """`update()` with every field set sends all of them at once.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-1"}}}), + _response({"data": {"EditHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.update(987654, title="New title", user_id=42, notes="notes") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "headlineId": 987654, + "title": "New title", + "assignee": 42, + "notesId": "pad-1", + "collaborationEnabled": True, + } + + def test_create_notes_failure_skips_create_headline(self) -> None: + """If `CreateNote` fails, `create()` raises before calling `CreateHeadline`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "CreateNote": { + "success": False, + "message": "pad service unavailable", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="pad service unavailable"): + ops.create(349524, "SDK v2 test headline", user_id=1305290, notes="hello") + + assert client.post.call_count == 1 + + def test_create_with_notes_sends_exact_input(self) -> None: + """`create(notes=...)` sends the exact `CreateHeadline` input shape.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateHeadline": {"id": 987654}}}), + _response({"data": {"headline": HEADLINE_NODE}}), + ] + + ops.create(349524, "SDK v2 test headline", user_id=1305290, notes="hello") + + note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert note_variables == {"text": "hello"} + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "title": "SDK v2 test headline", + "assignee": 1305290, + "meetings": [349524], + "notesId": "pad-99", + "collaborationEnabled": True, + } + } + + def test_details_with_null_meeting(self) -> None: + """A headline with a `null` `meeting` produces `meeting=None`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + node = {**HEADLINE_NODE, "meeting": None} + client.post.return_value = _response({"data": {"headline": node}}) + + result = ops.details(987654) + + assert result.meeting is None + + def test_details_transforms_timestamps_to_utc_aware_datetimes(self) -> None: + """`created_date`/`archived_date` become timezone-aware UTC datetimes.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + node = {**HEADLINE_NODE, "archived": True, "archivedTimestamp": 1790300000} + client.post.return_value = _response({"data": {"headline": node}}) + + result = ops.details(987654) + + assert result.created_date == datetime.fromtimestamp(1790275733, tz=UTC) + assert result.created_date.tzinfo is UTC + assert result.archived_date == datetime.fromtimestamp(1790300000, tz=UTC) + assert result.archived_date is not None and result.archived_date.tzinfo is UTC + + def test_details_archived_date_is_none_when_not_archived(self) -> None: + """`archived_date` is `None` when `archivedTimestamp` is `null`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"headline": HEADLINE_NODE}}) + + result = ops.details(987654) + + assert result.archived_date is None + + def test_details_notes_from_local_html_when_not_collaborative(self) -> None: + """Non-collaborative headlines take notes from `localHtml`, not `notesText`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + node = { + **HEADLINE_NODE, + "collaborationEnabled": False, + "notesText": "", + "localHtml": "

hello & world

", + } + client.post.return_value = _response({"data": {"headline": node}}) + + result = ops.details(987654) + + assert result.notes == "hello & world" + + def test_details_notes_none_when_empty(self) -> None: + """A headline with no description at all yields `notes=None`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + node = { + **HEADLINE_NODE, + "collaborationEnabled": False, + "notesText": "", + "localHtml": None, + } + client.post.return_value = _response({"data": {"headline": node}}) + + result = ops.details(987654) + + assert result.notes is None + + def test_details_raises_graphql_error_on_http_400(self) -> None: + """`details()` raises `GraphQLError` for an HTTP 400 validation error.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "headline id required"}]}, status_code=400 + ) + + with pytest.raises(GraphQLError, match="headline id required") as exc_info: + ops.details(987654) + + assert exc_info.value.status_code == 400 + + def test_list_raises_graphql_error_on_200_with_errors(self) -> None: + """`list()` raises `GraphQLError` for an execution error (200 + `errors`).""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "boom"}], "data": {"meeting": None}} + ) + + with pytest.raises(GraphQLError, match="boom"): + ops.list(meeting_id=349524) + + def test_list_by_meeting_returns_empty_when_meeting_is_null(self) -> None: + """A `null` `meeting` (no view permission) yields an empty list, not a crash.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + result = ops.list(meeting_id=349524) + + assert result == [] + + def test_list_by_user_missing_headlines_key_returns_empty_list(self) -> None: + """A response with no `headlines` key at all yields an empty list.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {}}) + + result = ops.list(user_id=1305290) + + assert result == [] + + def test_list_by_meeting_include_archived_when_archived_connection_is_null( + self, + ) -> None: + """`include_archived=True` handles a `null` `archivedHeadlines` connection.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}}), + _response({"data": {"meeting": {"archivedHeadlines": None}}}), + ] + + result = ops.list(meeting_id=349524, include_archived=True) + + assert len(result) == 1 + assert result[0].id == 987654 + + +class TestHeadlineOperationsAsync: + """Tests for the async `AsyncHeadlineOperations`.""" + + @pytest.mark.asyncio + async def test_details(self) -> None: + """`details()` returns a transformed `Headline`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"headline": HEADLINE_NODE}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.details(987654) + + assert result.id == 987654 + assert result.notes == "some notes" + + @pytest.mark.asyncio + async def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + await ops.list(meeting_id=349524, user_id=1305290) + + @pytest.mark.asyncio + async def test_list_by_user_filters_archived_client_side(self) -> None: + """`list(user_id=...)` drops archived headlines client-side by default.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}} + } + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.list(user_id=1305290) + + assert len(result) == 1 + assert result[0].id == 987654 + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(987654) + + @pytest.mark.asyncio + async def test_create_with_notes(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateHeadline`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + + note_response = MagicMock() + note_response.status_code = 200 + note_response.json.return_value = { + "data": {"CreateNote": {"success": True, "data": "pad-99"}} + } + note_response.raise_for_status = MagicMock() + + create_response = MagicMock() + create_response.status_code = 200 + create_response.json.return_value = {"data": {"CreateHeadline": {"id": 987654}}} + create_response.raise_for_status = MagicMock() + + details_response = MagicMock() + details_response.status_code = 200 + details_response.json.return_value = {"data": {"headline": HEADLINE_NODE}} + details_response.raise_for_status = MagicMock() + + client.post.side_effect = [note_response, create_response, details_response] + + result = await ops.create( + 349524, "SDK v2 test headline", user_id=1305290, notes="hello" + ) + + assert result.id == 987654 + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + + @pytest.mark.asyncio + async def test_archive_and_restore(self) -> None: + """Each single-field mutation sends only its own field.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + + def edit_ok() -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"EditHeadline": {"id": 987654}}} + response.raise_for_status = MagicMock() + return response + + def details() -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"headline": HEADLINE_NODE}} + response.raise_for_status = MagicMock() + return response + + client.post.side_effect = [edit_ok(), details(), edit_ok(), details()] + + await ops.archive(987654) + await ops.restore(987654) + + assert client.post.call_count == 4 + + @pytest.mark.asyncio + async def test_details_with_null_assignee(self) -> None: + """A headline with a `null` `assignee` produces `owner=None`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + node = {**HEADLINE_NODE, "assignee": None} + client.post.return_value = _async_response({"data": {"headline": node}}) + + result = await ops.details(987654) + + assert result.owner is None + + @pytest.mark.asyncio + async def test_details_transforms_timestamps_to_utc_aware_datetimes(self) -> None: + """`created_date`/`archived_date` become timezone-aware UTC datetimes.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + node = {**HEADLINE_NODE, "archived": True, "archivedTimestamp": 1790300000} + client.post.return_value = _async_response({"data": {"headline": node}}) + + result = await ops.details(987654) + + assert result.created_date == datetime.fromtimestamp(1790275733, tz=UTC) + assert result.created_date.tzinfo is UTC + assert result.archived_date == datetime.fromtimestamp(1790300000, tz=UTC) + + @pytest.mark.asyncio + async def test_details_raises_graphql_error_on_http_400(self) -> None: + """`details()` raises `GraphQLError` for an HTTP 400 validation error.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"errors": [{"message": "headline id required"}]}, status_code=400 + ) + + with pytest.raises(GraphQLError, match="headline id required") as exc_info: + await ops.details(987654) + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_list_by_meeting_open_only(self) -> None: + """`list(meeting_id=...)` queries `meeting.headlines` (open only) by default.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}} + ) + + result = await ops.list(meeting_id=349524) + + assert len(result) == 1 + assert client.post.call_count == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"meetingId": 349524} + + @pytest.mark.asyncio + async def test_list_by_meeting_include_archived_merges(self) -> None: + """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + client.post.side_effect = [ + _async_response( + {"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}} + ), + _async_response( + {"data": {"meeting": {"archivedHeadlines": {"nodes": [archived_node]}}}} + ), + ] + + result = await ops.list(meeting_id=349524, include_archived=True) + + assert client.post.call_count == 2 + assert {headline.id for headline in result} == {987654, 999} + second_query = client.post.call_args_list[1].kwargs["json"]["query"] + assert "archivedHeadlines" in second_query + + @pytest.mark.asyncio + async def test_list_by_meeting_returns_empty_when_meeting_is_null(self) -> None: + """A `null` `meeting` (no view permission) yields an empty list, not a crash.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"meeting": None}}) + + result = await ops.list(meeting_id=349524) + + assert result == [] + + @pytest.mark.asyncio + async def test_list_by_user_include_archived_keeps_all(self) -> None: + """`list(user_id=..., include_archived=True)` keeps archived headlines.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + client.post.return_value = _async_response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}}} + ) + + result = await ops.list(user_id=1305290, include_archived=True) + + assert {headline.id for headline in result} == {987654, 999} + + @pytest.mark.asyncio + async def test_list_defaults_to_current_user(self) -> None: + """`list()` with neither id given fetches headlines for the current user.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"headlines": {"nodes": [HEADLINE_NODE]}}}), + ] + + result = await ops.list() + + assert len(result) == 1 + variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert variables == {"userId": 1305290} + + @pytest.mark.asyncio + async def test_create_without_notes(self) -> None: + """`create()` without notes skips `CreateNote` and reads back details.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = await ops.create(349524, "SDK v2 test headline", user_id=1305290) + + assert result.id == 987654 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "title": "SDK v2 test headline", + "assignee": 1305290, + "meetings": [349524], + } + } + + @pytest.mark.asyncio + async def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"CreateHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = await ops.create(349524, "SDK v2 test headline") + + assert result.id == 987654 + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["assignee"] == 1305290 + + @pytest.mark.asyncio + async def test_create_notes_failure_skips_create_headline(self) -> None: + """If `CreateNote` fails, `create()` raises before calling `CreateHeadline`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + { + "data": { + "CreateNote": { + "success": False, + "message": "pad service unavailable", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="pad service unavailable"): + await ops.create( + 349524, "SDK v2 test headline", user_id=1305290, notes="hello" + ) + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_update_title(self) -> None: + """`update(title=...)` sends only the title field to `EditHeadline`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = await ops.update(987654, title="New title") + + assert result.id == 987654 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"headlineId": 987654, "title": "New title"}} + + @pytest.mark.asyncio + async def test_update_user_id_sends_assignee(self) -> None: + """`update(user_id=...)` sends the `assignee` field, not `assigneeId`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + await ops.update(987654, user_id=42) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"headlineId": 987654, "assignee": 42} + + @pytest.mark.asyncio + async def test_update_with_notes_calls_create_note_first(self) -> None: + """`update(notes=...)` calls `CreateNote` before `EditHeadline`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-77"}}} + ), + _async_response({"data": {"EditHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + result = await ops.update(987654, notes="Updated notes") + + assert result.id == 987654 + assert client.post.call_count == 3 + note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert note_variables == {"text": "Updated notes"} + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "headlineId": 987654, + "notesId": "pad-77", + "collaborationEnabled": True, + } + + @pytest.mark.asyncio + async def test_update_raises_graphql_error_on_top_level_error(self) -> None: + """`update()` raises `GraphQLError` when the response carries `errors`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"errors": [{"message": "headline not found"}]} + ) + + with pytest.raises(GraphQLError, match="headline not found"): + await ops.update(987654, title="New title") diff --git a/tests/test_v2_issue.py b/tests/test_v2_issue.py new file mode 100644 index 0000000..6e58bd0 --- /dev/null +++ b/tests/test_v2_issue.py @@ -0,0 +1,441 @@ +"""Tests for the v2 (GraphQL) issue operations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Issue +from bloomy.v2.operations.issue import AsyncIssueOperations, IssueOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +ISSUE_NODE = { + "id": 28471978, + "title": "SDK v2 test issue", + "recurrenceId": 349524, + "addToDepartmentPlan": False, + "notesId": "pad-1", + "notesText": "some notes\n", + "localHtml": None, + "collaborationEnabled": True, + "completed": False, + "completedTimestamp": None, + "archived": False, + "archivedTimestamp": None, + "dateCreated": 1790275733, + "priorityVoteRank": 999, + "numStarVotes": 0, + "issueNumber": 1, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, + "meeting": {"id": 349524, "name": "v2 API"}, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +class TestIssueOperationsSync: + """Tests for the sync `IssueOperations`.""" + + def test_details(self) -> None: + """`details()` returns a transformed `Issue`, including plain-text notes.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"issue": ISSUE_NODE}}) + + result = ops.details(28471978) + + assert isinstance(result, Issue) + assert result.id == 28471978 + assert result.owner is not None and result.owner.id == 1305290 + assert result.meeting is not None and result.meeting.id == 349524 + assert result.notes == "some notes" + + def test_details_with_null_assignee(self) -> None: + """An issue with a `null` `assignee` produces `owner=None`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + node = {**ISSUE_NODE, "assignee": None} + client.post.return_value = _response({"data": {"issue": node}}) + + result = ops.details(28471978) + + assert result.owner is None + + @pytest.mark.parametrize( + ("raw_archived", "long_term", "expected"), + [ + (True, True, False), # listed on the Long-Term tab + (True, False, True), # archived (incl. an archived long-term issue) + (False, False, False), + ], + ) + def test_archived_ignores_long_term_archived_flag( + self, raw_archived: bool, long_term: bool, expected: bool + ) -> None: + """Long-term issues are not reported as archived.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + node = { + **ISSUE_NODE, + "archived": raw_archived, + "addToDepartmentPlan": long_term, + } + client.post.return_value = _response({"data": {"issue": node}}) + + result = ops.details(28471978) + + assert result.archived is expected + assert result.long_term is long_term + + def test_list_short_term_default_where(self) -> None: + """`list()` filters short-term, open, non-sent-away issues by default.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"issues": {"nodes": [ISSUE_NODE]}}}} + ) + + result = ops.list(349524) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == { + "and": [ + {"addToDepartmentPlan": {"eq": False}}, + {"sentToIssueMeetingName": {"eq": None}}, + ] + } + + def test_list_long_term_uses_long_term_issues_connection(self) -> None: + """`list(long_term=True)` queries `longTermIssues`, not `issues`. + + `meeting.issues` drops archived issues server-side, and the API stores + long-term issues as archived, so `issues` never returns them. + """ + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"longTermIssues": {"nodes": [ISSUE_NODE]}}}} + ) + + result = ops.list(349524, long_term=True) + + assert len(result) == 1 + sent_query = client.post.call_args.kwargs["json"]["query"] + assert "longTermIssues" in sent_query + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == {"and": [{"addToDepartmentPlan": {"eq": True}}]} + + def test_list_include_solved_merges_and_dedupes(self) -> None: + """`include_solved=True` merges `recentlySolvedIssues`, deduping by id.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"meeting": {"issues": {"nodes": [ISSUE_NODE]}}}}), + _response( + { + "data": { + "meeting": { + "recentlySolvedIssues": { + "nodes": [{**ISSUE_NODE, "completed": True}] + } + } + } + } + ), + ] + + result = ops.list(349524, include_solved=True) + + assert len(result) == 1 + assert client.post.call_count == 2 + + def test_list_include_archived(self) -> None: + """`include_archived=True` merges `archivedIssues`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + archived_node = {**ISSUE_NODE, "id": 999, "archived": True} + client.post.side_effect = [ + _response({"data": {"meeting": {"issues": {"nodes": []}}}}), + _response( + {"data": {"meeting": {"archivedIssues": {"nodes": [archived_node]}}}} + ), + ] + + result = ops.list(349524, include_archived=True) + + assert len(result) == 1 + assert result[0].id == 999 + + def test_create_without_notes(self) -> None: + """`create()` without notes skips `CreateNote` and reads back details.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateIssue": {"id": 28471978}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + result = ops.create(349524, "SDK v2 test issue", user_id=1305290) + + assert result.id == 28471978 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "title": "SDK v2 test issue", + "ownerId": 1305290, + "recurrenceId": 349524, + "addToDepartmentPlan": False, + } + } + + def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateIssue`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateIssue": {"id": 28471978}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.create(349524, "SDK v2 test issue", user_id=1305290, notes="hello") + + create_issue_variables = client.post.call_args_list[1].kwargs["json"][ + "variables" + ] + assert create_issue_variables["input"]["notesId"] == "pad-99" + assert create_issue_variables["input"]["collaborationEnabled"] is True + + def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateIssue": {"id": 28471978}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.create(349524, "SDK v2 test issue") + + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["ownerId"] == 1305290 + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(28471978) + + def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + result = ops.update(28471978, title="New title") + + assert result.id == 28471978 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"id": 28471978, "title": "New title"}} + + def test_update_raises_graphql_error_on_failure(self) -> None: + """`update()` raises `GraphQLError` when `EditIssue` reports failure.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "EditIssue": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + ops.update(28471978, title="New title") + + def test_solve(self) -> None: + """`solve()` sends `completed: true` with a current timestamp.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": {**ISSUE_NODE, "completed": True}}}), + ] + + result = ops.solve(28471978) + + assert result.completed is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["id"] == 28471978 + assert edit_input["completed"] is True + assert isinstance(edit_input["completedTimestamp"], float) + + def test_reopen(self) -> None: + """`reopen()` sends `completed: false, completedTimestamp: null`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.reopen(28471978) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == { + "id": 28471978, + "completed": False, + "completedTimestamp": None, + } + + def test_archive(self) -> None: + """`archive()` sends `archived: true`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": {**ISSUE_NODE, "archived": True}}}), + ] + + result = ops.archive(28471978) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"id": 28471978, "archived": True} + + def test_restore(self) -> None: + """`restore()` sends `archived: false`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.restore(28471978) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"id": 28471978, "archived": False} + + +class TestIssueOperationsAsync: + """Tests for the async `AsyncIssueOperations`.""" + + @pytest.mark.asyncio + async def test_details(self) -> None: + """`details()` returns a transformed `Issue`.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"issue": ISSUE_NODE}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.details(28471978) + + assert result.id == 28471978 + assert result.notes == "some notes" + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(28471978) + + @pytest.mark.asyncio + async def test_create_with_notes(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateIssue`.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + + note_response = MagicMock() + note_response.status_code = 200 + note_response.json.return_value = { + "data": {"CreateNote": {"success": True, "data": "pad-99"}} + } + note_response.raise_for_status = MagicMock() + + create_response = MagicMock() + create_response.status_code = 200 + create_response.json.return_value = {"data": {"CreateIssue": {"id": 28471978}}} + create_response.raise_for_status = MagicMock() + + details_response = MagicMock() + details_response.status_code = 200 + details_response.json.return_value = {"data": {"issue": ISSUE_NODE}} + details_response.raise_for_status = MagicMock() + + client.post.side_effect = [note_response, create_response, details_response] + + result = await ops.create( + 349524, "SDK v2 test issue", user_id=1305290, notes="hello" + ) + + assert result.id == 28471978 + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + + @pytest.mark.asyncio + async def test_solve_reopen_archive_restore(self) -> None: + """Each single-field mutation sends only its own field.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + + def edit_ok() -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "data": {"EditIssue": {"success": True, "message": None}} + } + response.raise_for_status = MagicMock() + return response + + def details() -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"issue": ISSUE_NODE}} + response.raise_for_status = MagicMock() + return response + + client.post.side_effect = [ + edit_ok(), + details(), + edit_ok(), + details(), + edit_ok(), + details(), + edit_ok(), + details(), + ] + + await ops.solve(28471978) + await ops.reopen(28471978) + await ops.archive(28471978) + await ops.restore(28471978) + + assert client.post.call_count == 8 diff --git a/tests/test_v2_meeting.py b/tests/test_v2_meeting.py new file mode 100644 index 0000000..c760dcc --- /dev/null +++ b/tests/test_v2_meeting.py @@ -0,0 +1,177 @@ +"""Tests for the v2 (GraphQL) meeting operations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.v2.models import Meeting, MeetingListItem, User +from bloomy.v2.operations.meeting import AsyncMeetingOperations, MeetingOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +MEETING_LIST_NODE = { + "id": 349524, + "name": "v2 API", + "meetingType": "L10", + "createdTimestamp": 1790273635, + "archived": False, + "userIsAttendee": True, + "isCurrentUserAdmin": False, +} + +ATTENDEE_NODE = { + "id": 1305290, + "firstName": "Fran", + "lastName": "Orozco", + "fullName": "Fran Orozco", + "avatar": None, + "user": {"email": "fran@example.com"}, +} + +MEETING_DETAILS_DATA = { + "id": 349524, + "name": "v2 API", + "orgId": 1305289, + "meetingType": "L10", + "createdTimestamp": 1790273635, + "archived": False, + "attendees": {"nodes": [ATTENDEE_NODE]}, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +class TestMeetingOperationsSync: + """Tests for the sync `MeetingOperations`.""" + + def test_list_defaults_to_current_user(self) -> None: + """`list()` with no `user_id` fetches the authenticated user first.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response( + { + "data": { + "user": {"meetingsListLookup": {"nodes": [MEETING_LIST_NODE]}} + } + } + ), + ] + + result = ops.list() + + assert len(result) == 1 + assert isinstance(result[0], MeetingListItem) + assert result[0].id == 349524 + assert result[0].meeting_type == "L10" + + def test_list_explicit_user_id(self) -> None: + """`list(user_id=...)` skips the current-user lookup.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"user": {"meetingsListLookup": {"nodes": [MEETING_LIST_NODE]}}}} + ) + + result = ops.list(user_id=1305290) + + assert len(result) == 1 + client.post.assert_called_once() + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290} + + def test_details_returns_meeting_with_attendees(self) -> None: + """`details()` returns a `Meeting` with nested `User` attendees.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": MEETING_DETAILS_DATA}} + ) + + result = ops.details(349524) + + assert isinstance(result, Meeting) + assert result.id == 349524 + assert result.org_id == 1305289 + assert len(result.attendees) == 1 + assert isinstance(result.attendees[0], User) + assert result.attendees[0].email == "fran@example.com" + + def test_attendees_returns_users(self) -> None: + """`attendees()` returns a flat list of `User` models.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"attendees": {"nodes": [ATTENDEE_NODE]}}}} + ) + + result = ops.attendees(349524) + + assert len(result) == 1 + assert result[0].id == 1305290 + assert result[0].email == "fran@example.com" + + +class TestMeetingOperationsAsync: + """Tests for the async `AsyncMeetingOperations`.""" + + @pytest.mark.asyncio + async def test_list_explicit_user_id(self) -> None: + """`list(user_id=...)` skips the current-user lookup.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "data": {"user": {"meetingsListLookup": {"nodes": [MEETING_LIST_NODE]}}} + } + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.list(user_id=1305290) + + assert len(result) == 1 + assert result[0].id == 349524 + + @pytest.mark.asyncio + async def test_details_returns_meeting_with_attendees(self) -> None: + """`details()` returns a `Meeting` with nested `User` attendees.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"meeting": MEETING_DETAILS_DATA}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.details(349524) + + assert isinstance(result, Meeting) + assert len(result.attendees) == 1 + + @pytest.mark.asyncio + async def test_attendees_returns_users(self) -> None: + """`attendees()` returns a flat list of `User` models.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "data": {"meeting": {"attendees": {"nodes": [ATTENDEE_NODE]}}} + } + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.attendees(349524) + + assert len(result) == 1 + assert result[0].id == 1305290 diff --git a/tests/test_v2_metric.py b/tests/test_v2_metric.py new file mode 100644 index 0000000..f27bf08 --- /dev/null +++ b/tests/test_v2_metric.py @@ -0,0 +1,1257 @@ +"""Tests for the v2 (GraphQL) metric operations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Metric +from bloomy.v2.operations.metric import AsyncMetricOperations, MetricOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +METRIC_NODE = { + "measurableId": 2036155, + "title": "SDK v2 test metric", + "units": "NONE", + "rule": "GREATER_THAN", + "frequency": "WEEKLY", + "singleGoalValue": "100.00000", + "minGoalValue": "100.00000", + "maxGoalValue": None, + "archived": False, + "metricType": "METRIC", + "notesId": None, + "notesText": "", + "localHtml": None, + "collaborationEnabled": False, + "dateCreated": 1790278038, + "isExternallySynced": False, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, +} + +DIVIDER_NODE = { + "measurableId": 0, + "title": None, + "units": "NONE", + "rule": "GREATER_THAN", + "frequency": "WEEKLY", + "singleGoalValue": None, + "minGoalValue": None, + "maxGoalValue": None, + "archived": False, + "metricType": "DIVIDER", + "notesId": "0", + "notesText": "", + "localHtml": None, + "collaborationEnabled": False, + "dateCreated": 1790278038, + "isExternallySynced": False, + "assignee": None, +} + +SCORE_NODE = { + "id": 499964609, + "value": "123.00000", + "timestamp": 1789862400, + "notesText": "probe note", + "measurableId": 2036155, +} + +EMPTY_SCORE_NODE = { + "id": 499964591, + "value": "", + "timestamp": 1778976000, + "notesText": None, + "measurableId": 2036155, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +def _async_response(json_data: object) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = MagicMock() + return response + + +class TestMetricOperationsSync: + """Tests for the sync `MetricOperations`.""" + + def test_details(self) -> None: + """`details()` returns a transformed `Metric`, id from `measurableId`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"metric": METRIC_NODE}}) + + result = ops.details(2036155) + + assert isinstance(result, Metric) + assert result.id == 2036155 + assert result.owner is not None and result.owner.id == 1305290 + assert result.goal == 100.0 + assert result.notes is None + + def test_list_meeting_filters_divider_and_sets_where(self) -> None: + """`list(meeting_id=...)` filters DIVIDER rows and archived metrics.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE, DIVIDER_NODE]}}}} + ) + + result = ops.list(meeting_id=349524) + + assert len(result) == 1 + assert result[0].id == 2036155 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == { + "and": [ + {"archived": {"eq": False}}, + {"metricType": {"eq": "METRIC"}}, + ] + } + + def test_list_with_frequency_adds_condition(self) -> None: + """`list(frequency=...)` adds a `frequency` condition to `where`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"metrics": {"nodes": []}}}} + ) + + ops.list(meeting_id=349524, frequency="DAILY") + + variables = client.post.call_args.kwargs["json"]["variables"] + assert {"frequency": {"eq": "DAILY"}} in variables["where"]["and"] + + def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + ops.list(meeting_id=349524, user_id=1305290) + + def test_list_defaults_to_current_user(self) -> None: + """`list()` with neither id fetches the authenticated user first.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"user": {"metrics": {"nodes": [METRIC_NODE]}}}}), + ] + + result = ops.list() + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + + def test_create_defaults(self) -> None: + """`create()` sends the expected default input and reads back details.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + result = ops.create(349524, "SDK v2 test metric", user_id=1305290, goal=100) + + assert result.id == 2036155 + create_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_vars["input"] == { + "title": "SDK v2 test metric", + "assignee": 1305290, + "units": "NONE", + "rule": "GREATER_THAN", + "frequency": "WEEKLY", + "averageOverrideType": "NONE", + "meetings": [349524], + "customGoals": [], + "singleGoalValue": "100", + } + + def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.create(349524, "SDK v2 test metric") + + create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_vars["input"]["assignee"] == 1305290 + + def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateMetric`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.create(349524, "SDK v2 test metric", user_id=1305290, notes="hello") + + create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_vars["input"]["notesId"] == "pad-99" + assert create_vars["input"]["collaborationEnabled"] is True + + def test_create_between_uses_min_max(self) -> None: + """`create(rule=BETWEEN, min_goal=..., max_goal=...)` sends the range goal.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.create( + 349524, + "SDK v2 test metric", + user_id=1305290, + rule="BETWEEN", + min_goal=10, + max_goal=20, + ) + + create_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_vars["input"]["minGoalValue"] == "10" + assert create_vars["input"]["maxGoalValue"] == "20" + assert "singleGoalValue" not in create_vars["input"] + + def test_create_goal_with_between_raises(self) -> None: + """`create(rule=BETWEEN, goal=...)` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + ops.create( + 349524, "SDK v2 test metric", user_id=1305290, rule="BETWEEN", goal=10 + ) + + def test_create_min_goal_without_between_raises(self) -> None: + """`create(min_goal=..., rule != BETWEEN)` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + ops.create(349524, "SDK v2 test metric", user_id=1305290, min_goal=10) + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(2036155) + + def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + result = ops.update(2036155, title="New title") + + assert result.id == 2036155 + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"metricId": 2036155, "title": "New title"} + + def test_update_goal_conflict_raises(self) -> None: + """`update(rule=BETWEEN, goal=...)` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + ops.update(2036155, rule="BETWEEN", goal=10) + + def test_archive(self) -> None: + """`archive()` sends `archived: true`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetric": {"id": 2036155}}}), + _response({"data": {"metric": {**METRIC_NODE, "archived": True}}}), + ] + + result = ops.archive(2036155) + + assert result.archived is True + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"metricId": 2036155, "archived": True} + + def test_scores_filters_empty_by_default(self) -> None: + """`scores()` skips empty placeholder rows unless `include_empty=True`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": [SCORE_NODE, EMPTY_SCORE_NODE]}}} + ) + + result = ops.scores(2036155) + + assert len(result) == 1 + assert result[0].value == 123.0 + + def test_scores_include_empty(self) -> None: + """`scores(include_empty=True)` keeps empty placeholder rows.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": [SCORE_NODE, EMPTY_SCORE_NODE]}}} + ) + + result = ops.scores(2036155, include_empty=True) + + assert len(result) == 2 + assert result[1].value is None + + def test_scores_start_end_builds_where(self) -> None: + """`scores(start=..., end=...)` builds a timestamp range `where`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": []}}} + ) + + ops.scores(2036155, start=1000, end=2000) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == { + "and": [{"timestamp": {"gte": 1000.0}}, {"timestamp": {"lte": 2000.0}}] + } + + def test_set_score_success(self) -> None: + """`set_score()` creates and re-reads the score.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMetricScore": {"id": 499964609}}}), + _response({"data": {"metric": {"scoresNonPaginated": [SCORE_NODE]}}}), + ] + + result = ops.set_score(2036155, 123, 1790278038) + + assert result.id == 499964609 + assert result.value == 123.0 + create_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_vars["input"] == { + "metricId": 2036155, + "value": "123", + "timestamp": 1790278038.0, + } + + def test_set_score_unparseable_value_raises(self) -> None: + """`set_score()` raises `GraphQLError` when the value is unparseable.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateMetricScore": None}}) + + with pytest.raises(GraphQLError, match="could not be parsed"): + ops.set_score(2036155, "not-a-number", 1790278038) + + def test_set_score_daily_duplicate_falls_back_to_edit(self) -> None: + """DAILY "already exists" falls back to finding and editing the score.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + daily_node = {**SCORE_NODE, "id": 1, "value": "", "timestamp": 1790208001} + client.post.side_effect = [ + _response( + { + "errors": [ + { + "message": "Unexpected Execution Error", + "extensions": { + "message": ( + "A score for this measurable and date " + "already exists." + ) + }, + } + ], + "data": {"CreateMetricScore": None}, + } + ), + _response({"data": {"metric": {"scoresNonPaginated": [daily_node]}}}), + _response({"data": {"EditMetricScore": {"id": 1}}}), + _response( + { + "data": { + "metric": { + "scoresNonPaginated": [{**daily_node, "value": "42.00000"}] + } + } + } + ), + ] + + result = ops.set_score(2036158, 42, 1790208001) + + assert result.id == 1 + assert result.value == 42.0 + assert client.post.call_count == 4 + edit_vars = client.post.call_args_list[2].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 1, "value": "42"} + + def test_set_score_other_graphql_error_propagates(self) -> None: + """A `GraphQLError` unrelated to duplicates is re-raised as-is.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "PermissionsException: nope"}]} + ) + + with pytest.raises(GraphQLError, match="nope"): + ops.set_score(2036155, 1, 1790278038) + + def test_update_score_value_and_notes(self) -> None: + """`update_score()` sends both `value` and `notesText`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetricScore": {"id": 499964609}}}), + _response( + { + "data": { + "metric": { + "scoresNonPaginated": [ + {**SCORE_NODE, "value": "125.00000", "notesText": "hi"} + ] + } + } + } + ), + ] + + result = ops.update_score(2036155, 499964609, value=125, notes="hi") + + assert result.value == 125.0 + assert result.notes == "hi" + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == { + "id": 499964609, + "value": "125", + "notesText": "hi", + } + + def test_update_score_requires_a_field(self) -> None: + """`update_score()` with neither `value` nor `notes` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one of"): + ops.update_score(2036155, 499964609) + + def test_update_score_not_found_after_write_raises(self) -> None: + """A re-read that comes back empty raises `GraphQLError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetricScore": {"id": 1}}}), + _response({"data": {"metric": {"scoresNonPaginated": []}}}), + ] + + with pytest.raises(GraphQLError, match="not found"): + ops.update_score(2036155, 999, value=1) + + def test_clear_score(self) -> None: + """`clear_score()` sends `value: null` and returns `value=None`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetricScore": {"id": 499964609}}}), + _response( + { + "data": { + "metric": {"scoresNonPaginated": [{**SCORE_NODE, "value": ""}]} + } + } + ), + ] + + result = ops.clear_score(2036155, 499964609) + + assert result.value is None + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 499964609, "value": None} + + def test_details_notes_and_dates(self) -> None: + """`details()` extracts notes and returns a UTC-aware `created_date`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + node = { + **METRIC_NODE, + "notesId": "pad-1", + "notesText": "Metric notes", + "collaborationEnabled": True, + } + client.post.return_value = _response({"data": {"metric": node}}) + + result = ops.details(2036155) + + assert result.notes == "Metric notes" + assert result.notes_id == "pad-1" + assert result.created_date.tzinfo is not None + + def test_list_meeting_missing_returns_empty(self) -> None: + """`list(meeting_id=...)` returns `[]` when the meeting itself is `null`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + result = ops.list(meeting_id=349524) + + assert result == [] + + def test_list_user_missing_returns_empty(self) -> None: + """`list(user_id=...)` returns `[]` when the user itself is `null`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"user": None}}) + + result = ops.list(user_id=1305290) + + assert result == [] + + def test_details_raises_graphql_error_on_http_400(self) -> None: + """A response with a top-level `errors` array raises `GraphQLError` on 400.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + response = Mock() + response.status_code = 400 + response.json.return_value = {"errors": [{"message": "Metric not found"}]} + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Metric not found") as exc_info: + ops.details(999999) + + assert exc_info.value.status_code == 400 + response.raise_for_status.assert_not_called() + + def test_list_raises_graphql_error_on_200_with_errors(self) -> None: + """A 200 response with a top-level `errors` array also raises `GraphQLError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + response = Mock() + response.status_code = 200 + response.json.return_value = { + "data": None, + "errors": [{"message": "execution failed"}], + } + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="execution failed"): + ops.list(meeting_id=349524) + + def test_create_note_failure_raises(self) -> None: + """`create(notes=...)` raises `GraphQLError` when `CreateNote` fails.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "CreateNote": { + "success": False, + "message": "note failed", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="note failed"): + ops.create(349524, "SDK v2 test metric", user_id=1305290, notes="hello") + + assert client.post.call_count == 1 + + def test_update_user_units_rule_goal(self) -> None: + """`update()` sends `assignee`/`units`/`rule`/`singleGoalValue` when given.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.update(2036155, user_id=42, units="DOLLAR", rule="LESS_THAN", goal=50) + + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == { + "metricId": 2036155, + "assignee": 42, + "units": "DOLLAR", + "rule": "LESS_THAN", + "singleGoalValue": "50", + } + + def test_update_with_notes_calls_create_note_first(self) -> None: + """`update(notes=...)` calls `CreateNote` before `EditMetric`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-77"}}}), + _response({"data": {"EditMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.update(2036155, notes="Updated notes") + + edit_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_vars["input"]["notesId"] == "pad-77" + assert edit_vars["input"]["collaborationEnabled"] is True + + def test_update_goal_and_min_goal_without_rule_conflict_raises(self) -> None: + """`update(goal=..., min_goal=...)` without `rule` raises `ValueError`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="cannot be combined"): + ops.update(2036155, goal=10, min_goal=5) + + def test_scores_no_range_sends_null_where(self) -> None: + """`scores()` without `start`/`end` sends `where: null`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": [SCORE_NODE]}}} + ) + + result = ops.scores(2036155) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] is None + assert result[0].metric_id == 2036155 + assert result[0].week_date is not None + assert result[0].week_date.tzinfo is not None + + def test_set_score_daily_duplicate_no_existing_score_reraises(self) -> None: + """If no matching score is found for the day, the original error re-raises.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response( + { + "errors": [ + { + "message": "Unexpected Execution Error", + "extensions": { + "message": ( + "A score for this measurable and date " + "already exists." + ) + }, + } + ], + "data": {"CreateMetricScore": None}, + } + ), + _response({"data": {"metric": {"scoresNonPaginated": []}}}), + ] + + with pytest.raises(GraphQLError, match="Unexpected Execution Error"): + ops.set_score(2036158, 42, 1790208001) + + assert client.post.call_count == 2 + + +class TestMetricOperationsAsync: + """Tests for the async `AsyncMetricOperations`.""" + + @pytest.mark.asyncio + async def test_details(self) -> None: + """`details()` returns a transformed `Metric`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"metric": METRIC_NODE}}) + + result = await ops.details(2036155) + + assert result.id == 2036155 + assert isinstance(result, Metric) + + @pytest.mark.asyncio + async def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + await ops.list(meeting_id=349524, user_id=1305290) + + @pytest.mark.asyncio + async def test_list_defaults_to_current_user(self) -> None: + """`list()` with neither id fetches the authenticated user first.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"user": {"metrics": {"nodes": [METRIC_NODE]}}}}), + ] + + result = await ops.list() + + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_create_goal_with_between_raises(self) -> None: + """`create(rule=BETWEEN, goal=...)` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + await ops.create( + 349524, "SDK v2 test metric", user_id=1305290, rule="BETWEEN", goal=10 + ) + + @pytest.mark.asyncio + async def test_create_with_notes(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateMetric`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-99"}}} + ), + _async_response({"data": {"CreateMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + result = await ops.create( + 349524, "SDK v2 test metric", user_id=1305290, notes="hello" + ) + + assert result.id == 2036155 + create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_vars["input"]["notesId"] == "pad-99" + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(2036155) + + @pytest.mark.asyncio + async def test_archive(self) -> None: + """`archive()` sends `archived: true`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": {**METRIC_NODE, "archived": True}}}), + ] + + result = await ops.archive(2036155) + + assert result.archived is True + + @pytest.mark.asyncio + async def test_scores_filters_empty_by_default(self) -> None: + """`scores()` skips empty placeholder rows by default.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"metric": {"scoresNonPaginated": [SCORE_NODE, EMPTY_SCORE_NODE]}}} + ) + + result = await ops.scores(2036155) + + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_set_score_success(self) -> None: + """`set_score()` creates and re-reads the score.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateMetricScore": {"id": 499964609}}}), + _async_response({"data": {"metric": {"scoresNonPaginated": [SCORE_NODE]}}}), + ] + + result = await ops.set_score(2036155, 123, 1790278038) + + assert result.value == 123.0 + + @pytest.mark.asyncio + async def test_set_score_unparseable_value_raises(self) -> None: + """`set_score()` raises `GraphQLError` when the value is unparseable.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"CreateMetricScore": None}} + ) + + with pytest.raises(GraphQLError, match="could not be parsed"): + await ops.set_score(2036155, "not-a-number", 1790278038) + + @pytest.mark.asyncio + async def test_set_score_daily_duplicate_falls_back_to_edit(self) -> None: + """DAILY "already exists" falls back to finding and editing the score.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + daily_node = {**SCORE_NODE, "id": 1, "value": "", "timestamp": 1790208001} + client.post.side_effect = [ + _async_response( + { + "errors": [ + { + "message": "Unexpected Execution Error", + "extensions": { + "message": ( + "A score for this measurable and date " + "already exists." + ) + }, + } + ], + "data": {"CreateMetricScore": None}, + } + ), + _async_response({"data": {"metric": {"scoresNonPaginated": [daily_node]}}}), + _async_response({"data": {"EditMetricScore": {"id": 1}}}), + _async_response( + { + "data": { + "metric": { + "scoresNonPaginated": [{**daily_node, "value": "42.00000"}] + } + } + } + ), + ] + + result = await ops.set_score(2036158, 42, 1790208001) + + assert result.value == 42.0 + assert client.post.call_count == 4 + + @pytest.mark.asyncio + async def test_update_score_requires_a_field(self) -> None: + """`update_score()` with neither `value` nor `notes` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one of"): + await ops.update_score(2036155, 499964609) + + @pytest.mark.asyncio + async def test_clear_score(self) -> None: + """`clear_score()` sends `value: null` and returns `value=None`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetricScore": {"id": 499964609}}}), + _async_response( + { + "data": { + "metric": {"scoresNonPaginated": [{**SCORE_NODE, "value": ""}]} + } + } + ), + ] + + result = await ops.clear_score(2036155, 499964609) + + assert result.value is None + + @pytest.mark.asyncio + async def test_list_meeting_filters_divider_and_sets_where(self) -> None: + """`list(meeting_id=...)` queries `meeting(id){ metrics }`, filters dividers.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE, DIVIDER_NODE]}}}} + ) + + result = await ops.list(meeting_id=349524) + + assert len(result) == 1 + assert result[0].id == 2036155 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == { + "and": [ + {"archived": {"eq": False}}, + {"metricType": {"eq": "METRIC"}}, + ] + } + + @pytest.mark.asyncio + async def test_list_with_frequency_adds_condition(self) -> None: + """`list(frequency=...)` adds a `frequency` condition to `where`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"metrics": {"nodes": []}}}} + ) + + await ops.list(meeting_id=349524, frequency="DAILY") + + variables = client.post.call_args.kwargs["json"]["variables"] + assert {"frequency": {"eq": "DAILY"}} in variables["where"]["and"] + + @pytest.mark.asyncio + async def test_list_meeting_missing_returns_empty(self) -> None: + """`list(meeting_id=...)` returns `[]` when the meeting itself is `null`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"meeting": None}}) + + result = await ops.list(meeting_id=349524) + + assert result == [] + + @pytest.mark.asyncio + async def test_create_defaults(self) -> None: + """`create()` sends the expected default input and reads back details.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + result = await ops.create( + 349524, "SDK v2 test metric", user_id=1305290, goal=100 + ) + + assert result.id == 2036155 + create_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_vars["input"] == { + "title": "SDK v2 test metric", + "assignee": 1305290, + "units": "NONE", + "rule": "GREATER_THAN", + "frequency": "WEEKLY", + "averageOverrideType": "NONE", + "meetings": [349524], + "customGoals": [], + "singleGoalValue": "100", + } + + @pytest.mark.asyncio + async def test_create_defaults_user_id_to_current_user(self) -> None: + """`create()` without `user_id` fetches the authenticated user first.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"CreateMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + await ops.create(349524, "SDK v2 test metric") + + create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_vars["input"]["assignee"] == 1305290 + + @pytest.mark.asyncio + async def test_create_between_uses_min_max(self) -> None: + """`create(rule=BETWEEN, min_goal=..., max_goal=...)` sends the range goal.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + await ops.create( + 349524, + "SDK v2 test metric", + user_id=1305290, + rule="BETWEEN", + min_goal=10, + max_goal=20, + ) + + create_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_vars["input"]["minGoalValue"] == "10" + assert create_vars["input"]["maxGoalValue"] == "20" + assert "singleGoalValue" not in create_vars["input"] + + @pytest.mark.asyncio + async def test_create_min_goal_without_between_raises(self) -> None: + """`create(min_goal=..., rule != BETWEEN)` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + await ops.create(349524, "SDK v2 test metric", user_id=1305290, min_goal=10) + + @pytest.mark.asyncio + async def test_create_note_failure_raises(self) -> None: + """`create(notes=...)` raises `GraphQLError` when `CreateNote` fails.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + { + "data": { + "CreateNote": { + "success": False, + "message": "note failed", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="note failed"): + await ops.create( + 349524, "SDK v2 test metric", user_id=1305290, notes="hello" + ) + + @pytest.mark.asyncio + async def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + result = await ops.update(2036155, title="New title") + + assert result.id == 2036155 + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"metricId": 2036155, "title": "New title"} + + @pytest.mark.asyncio + async def test_update_user_units_rule_goal(self) -> None: + """`update()` sends `assignee`/`units`/`rule`/`singleGoalValue` when given.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + await ops.update(2036155, user_id=42, units="DOLLAR", rule="LESS_THAN", goal=50) + + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == { + "metricId": 2036155, + "assignee": 42, + "units": "DOLLAR", + "rule": "LESS_THAN", + "singleGoalValue": "50", + } + + @pytest.mark.asyncio + async def test_update_with_notes_calls_create_note_first(self) -> None: + """`update(notes=...)` calls `CreateNote` before `EditMetric`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-77"}}} + ), + _async_response({"data": {"EditMetric": {"id": 2036155}}}), + _async_response({"data": {"metric": METRIC_NODE}}), + ] + + await ops.update(2036155, notes="Updated notes") + + edit_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_vars["input"]["notesId"] == "pad-77" + assert edit_vars["input"]["collaborationEnabled"] is True + + @pytest.mark.asyncio + async def test_update_goal_conflict_raises(self) -> None: + """`update(rule=BETWEEN, goal=...)` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="BETWEEN"): + await ops.update(2036155, rule="BETWEEN", goal=10) + + @pytest.mark.asyncio + async def test_update_goal_and_min_goal_without_rule_conflict_raises( + self, + ) -> None: + """`update(goal=..., min_goal=...)` without `rule` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="cannot be combined"): + await ops.update(2036155, goal=10, min_goal=5) + + @pytest.mark.asyncio + async def test_scores_include_empty(self) -> None: + """`scores(include_empty=True)` keeps empty placeholder rows.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"metric": {"scoresNonPaginated": [SCORE_NODE, EMPTY_SCORE_NODE]}}} + ) + + result = await ops.scores(2036155, include_empty=True) + + assert len(result) == 2 + assert result[1].value is None + + @pytest.mark.asyncio + async def test_scores_start_end_builds_where(self) -> None: + """`scores(start=..., end=...)` builds a timestamp range `where`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"metric": {"scoresNonPaginated": []}}} + ) + + await ops.scores(2036155, start=1000, end=2000) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == { + "and": [{"timestamp": {"gte": 1000.0}}, {"timestamp": {"lte": 2000.0}}] + } + + @pytest.mark.asyncio + async def test_set_score_daily_duplicate_no_existing_score_reraises( + self, + ) -> None: + """If no matching score is found for the day, the original error re-raises.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + { + "errors": [ + { + "message": "Unexpected Execution Error", + "extensions": { + "message": ( + "A score for this measurable and date " + "already exists." + ) + }, + } + ], + "data": {"CreateMetricScore": None}, + } + ), + _async_response({"data": {"metric": {"scoresNonPaginated": []}}}), + ] + + with pytest.raises(GraphQLError, match="Unexpected Execution Error"): + await ops.set_score(2036158, 42, 1790208001) + + assert client.post.call_count == 2 + + @pytest.mark.asyncio + async def test_set_score_other_graphql_error_propagates(self) -> None: + """A `GraphQLError` unrelated to duplicates is re-raised as-is.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"errors": [{"message": "PermissionsException: nope"}]} + ) + + with pytest.raises(GraphQLError, match="nope"): + await ops.set_score(2036155, 1, 1790278038) + + @pytest.mark.asyncio + async def test_update_score_value_and_notes(self) -> None: + """`update_score()` sends both `value` and `notesText`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetricScore": {"id": 499964609}}}), + _async_response( + { + "data": { + "metric": { + "scoresNonPaginated": [ + {**SCORE_NODE, "value": "125.00000", "notesText": "hi"} + ] + } + } + } + ), + ] + + result = await ops.update_score(2036155, 499964609, value=125, notes="hi") + + assert result.value == 125.0 + assert result.notes == "hi" + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == { + "id": 499964609, + "value": "125", + "notesText": "hi", + } + + @pytest.mark.asyncio + async def test_update_score_not_found_after_write_raises(self) -> None: + """A re-read that comes back empty raises `GraphQLError`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetricScore": {"id": 1}}}), + _async_response({"data": {"metric": {"scoresNonPaginated": []}}}), + ] + + with pytest.raises(GraphQLError, match="not found"): + await ops.update_score(2036155, 999, value=1) + + @pytest.mark.asyncio + async def test_details_raises_graphql_error_on_http_400(self) -> None: + """A response with a top-level `errors` array raises `GraphQLError` on 400.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 400 + response.json.return_value = {"errors": [{"message": "Metric not found"}]} + response.raise_for_status = MagicMock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Metric not found") as exc_info: + await ops.details(999999) + + assert exc_info.value.status_code == 400 + response.raise_for_status.assert_not_called() + + @pytest.mark.asyncio + async def test_details_notes_and_dates(self) -> None: + """`details()` extracts notes and returns a UTC-aware `created_date`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + node = { + **METRIC_NODE, + "notesId": "pad-1", + "notesText": "Metric notes", + "collaborationEnabled": True, + } + client.post.return_value = _async_response({"data": {"metric": node}}) + + result = await ops.details(2036155) + + assert result.notes == "Metric notes" + assert result.notes_id == "pad-1" + assert result.created_date.tzinfo is not None diff --git a/tests/test_v2_milestone.py b/tests/test_v2_milestone.py new file mode 100644 index 0000000..431ff9a --- /dev/null +++ b/tests/test_v2_milestone.py @@ -0,0 +1,433 @@ +"""Tests for the v2 (GraphQL) milestone operations.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Milestone +from bloomy.v2.operations.milestone import AsyncMilestoneOperations, MilestoneOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +MILESTONE_NODE = { + "id": 6440354, + "goalId": 5265048, + "title": "SDK v2 test milestone", + "dueDate": 1767225600, + "completed": False, + "status": None, + "dateCreated": 1790278069, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +def _async_response(json_data: object) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = MagicMock() + return response + + +class TestMilestoneOperationsSync: + """Tests for the sync `MilestoneOperations`.""" + + def test_list(self) -> None: + """`list()` returns milestones for a goal, via `goal(id){ milestones }`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ) + + result = ops.list(5265048) + + assert len(result) == 1 + assert isinstance(result[0], Milestone) + assert result[0].id == 6440354 + assert result[0].goal_id == 5265048 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"goalId": 5265048} + # Transform: unix-seconds timestamp becomes a UTC-aware datetime. + assert result[0].due_date.tzinfo is not None + + def test_list_with_null_goal_returns_empty(self) -> None: + """A `null` `goal` (e.g. an unknown goal id) yields an empty list.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"goal": None}}) + + result = ops.list(999999) + + assert result == [] + + def test_list_raises_graphql_error_on_http_400_with_errors(self) -> None: + """A body with a top-level `errors` array raises `GraphQLError` on HTTP 400.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + response = Mock() + response.status_code = 400 + response.json.return_value = {"errors": [{"message": "Goal not found"}]} + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Goal not found") as exc_info: + ops.list(5265048) + + assert exc_info.value.status_code == 400 + response.raise_for_status.assert_not_called() + + def test_create(self) -> None: + """`create()` sends `rockId` and re-reads the milestone via its goal.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMilestone": {"id": 6440354}}}), + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), + ] + + result = ops.create(5265048, "SDK v2 test milestone", 1767225600) + + assert result.id == 6440354 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "rockId": 5265048, + "title": "SDK v2 test milestone", + "dueDate": 1767225600.0, + "completed": False, + } + } + + def test_create_due_date_accepts_datetime_object(self) -> None: + """`create(due_date=...)` accepts a `datetime`, not only a unix timestamp.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMilestone": {"id": 6440354}}}), + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), + ] + due_date = datetime(2026, 1, 1, tzinfo=UTC) + + ops.create(5265048, "SDK v2 test milestone", due_date) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables["input"]["dueDate"] == due_date.timestamp() + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(6440354, 5265048) + + def test_update_due_date_and_completed(self) -> None: + """`update(due_date=..., completed=...)` sends both without `title`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMilestone": {"id": 6440354}}}), + _response( + { + "data": { + "goal": { + "milestones": { + "nodes": [ + { + **MILESTONE_NODE, + "dueDate": 1767312000, + "completed": True, + } + ] + } + } + } + } + ), + ] + + result = ops.update(6440354, 5265048, due_date=1767312000, completed=True) + + assert result.completed is True + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "milestoneId": 6440354, + "dueDate": 1767312000.0, + "completed": True, + } + } + + def test_update(self) -> None: + """`update()` sends only the provided fields and re-reads via `goal_id`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMilestone": {"id": 6440354}}}), + _response( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "title": "Updated"}] + } + } + } + } + ), + ] + + result = ops.update(6440354, 5265048, title="Updated") + + assert result.title == "Updated" + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"milestoneId": 6440354, "title": "Updated"}} + get_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert get_variables == {"goalId": 5265048, "milestoneId": 6440354} + + def test_complete(self) -> None: + """`complete()` sends `completed: true`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMilestone": {"id": 6440354}}}), + _response( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "completed": True}] + } + } + } + } + ), + ] + + result = ops.complete(6440354, 5265048) + + assert result.completed is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"milestoneId": 6440354, "completed": True} + + def test_get_raises_when_not_found(self) -> None: + """A re-read that returns no nodes raises `GraphQLError`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMilestone": {"id": 6440354}}}), + _response({"data": {"goal": {"milestones": {"nodes": []}}}}), + ] + + with pytest.raises(GraphQLError, match="not found"): + ops.complete(6440354, 5265048) + + def test_delete(self) -> None: + """`delete()` calls `DeleteMilestone` and checks its success result.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"DeleteMilestone": {"success": True, "message": None}}} + ) + + ops.delete(6440354) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"milestoneId": 6440354} + + def test_delete_raises_on_failure(self) -> None: + """`delete()` raises `GraphQLError` when `DeleteMilestone` reports failure.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "DeleteMilestone": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + ops.delete(6440354) + + +class TestMilestoneOperationsAsync: + """Tests for the async `AsyncMilestoneOperations`.""" + + @pytest.mark.asyncio + async def test_list(self) -> None: + """`list()` returns milestones for a goal.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ) + + result = await ops.list(5265048) + + assert len(result) == 1 + assert result[0].id == 6440354 + + @pytest.mark.asyncio + async def test_create(self) -> None: + """`create()` sends `rockId` and re-reads the milestone via its goal.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateMilestone": {"id": 6440354}}}), + _async_response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ), + ] + + result = await ops.create(5265048, "SDK v2 test milestone", 1767225600) + + assert result.id == 6440354 + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables == { + "input": { + "rockId": 5265048, + "title": "SDK v2 test milestone", + "dueDate": 1767225600.0, + "completed": False, + } + } + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(6440354, 5265048) + + @pytest.mark.asyncio + async def test_update(self) -> None: + """`update()` sends only the provided fields and re-reads via `goal_id`.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMilestone": {"id": 6440354}}}), + _async_response( + { + "data": { + "goal": { + "milestones": { + "nodes": [ + { + **MILESTONE_NODE, + "title": "Updated", + "dueDate": 1767312000, + "completed": True, + } + ] + } + } + } + } + ), + ] + + result = await ops.update( + 6440354, 5265048, title="Updated", due_date=1767312000, completed=True + ) + + assert result.title == "Updated" + assert result.completed is True + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "milestoneId": 6440354, + "title": "Updated", + "dueDate": 1767312000.0, + "completed": True, + } + } + + @pytest.mark.asyncio + async def test_get_raises_when_not_found(self) -> None: + """A re-read that returns no nodes raises `GraphQLError`.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMilestone": {"id": 6440354}}}), + _async_response({"data": {"goal": {"milestones": {"nodes": []}}}}), + ] + + with pytest.raises(GraphQLError, match="not found"): + await ops.complete(6440354, 5265048) + + @pytest.mark.asyncio + async def test_complete(self) -> None: + """`complete()` sends `completed: true` and re-reads via `goal_id`.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMilestone": {"id": 6440354}}}), + _async_response( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "completed": True}] + } + } + } + } + ), + ] + + result = await ops.complete(6440354, 5265048) + + assert result.completed is True + + @pytest.mark.asyncio + async def test_delete(self) -> None: + """`delete()` calls `DeleteMilestone` and checks its success result.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"DeleteMilestone": {"success": True, "message": None}}} + ) + + await ops.delete(6440354) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"milestoneId": 6440354} + + @pytest.mark.asyncio + async def test_delete_raises_on_failure(self) -> None: + """`delete()` raises `GraphQLError` when `DeleteMilestone` reports failure.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + { + "data": { + "DeleteMilestone": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + await ops.delete(6440354) diff --git a/tests/test_v2_todo.py b/tests/test_v2_todo.py new file mode 100644 index 0000000..fc894d9 --- /dev/null +++ b/tests/test_v2_todo.py @@ -0,0 +1,1015 @@ +"""Tests for the v2 (GraphQL) to-do operations.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime, time, timedelta +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +from bloomy.v2.models import Todo +from bloomy.v2.operations.todo import AsyncTodoOperations, TodoOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +TODO_NODE = { + "id": 555001, + "title": "SDK v2 test to-do", + "dueDate": 1790275733, + "completed": False, + "completedTimestamp": None, + "archived": False, + "archivedTimestamp": None, + "dateCreated": 1789275733, + "notesId": "pad-1", + "notesText": "some notes\n", + "localHtml": None, + "collaborationEnabled": True, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, + "meeting": {"id": 349524, "name": "v2 API"}, +} + + +def _response(json_data: object, status_code: int = 200) -> Mock: + response = Mock() + response.status_code = status_code + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +def _async_response(json_data: object, status_code: int = 200) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.json.return_value = json_data + response.raise_for_status = MagicMock() + return response + + +class TestTodoOperationsSync: + """Tests for the sync `TodoOperations`.""" + + def test_details(self) -> None: + """`details()` returns a transformed `Todo`, including plain-text notes.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"todo": TODO_NODE}}) + + result = ops.details(555001) + + assert isinstance(result, Todo) + assert result.id == 555001 + assert result.owner is not None and result.owner.id == 1305290 + assert result.meeting is not None and result.meeting.id == 349524 + assert result.notes == "some notes" + + def test_details_with_null_assignee(self) -> None: + """A to-do with a `null` `assignee` produces `owner=None`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + node = {**TODO_NODE, "assignee": None} + client.post.return_value = _response({"data": {"todo": node}}) + + result = ops.details(555001) + + assert result.owner is None + + def test_details_with_null_meeting(self) -> None: + """A personal to-do (`meeting: null`) produces `meeting=None`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + node = {**TODO_NODE, "meeting": None} + client.post.return_value = _response({"data": {"todo": node}}) + + result = ops.details(555001) + + assert result.meeting is None + + def test_details_datetime_fields_are_utc_aware(self) -> None: + """Timestamp fields convert into timezone-aware UTC datetimes.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + node = { + **TODO_NODE, + "completedTimestamp": 1790300000, + "archivedTimestamp": 1790400000, + } + client.post.return_value = _response({"data": {"todo": node}}) + + result = ops.details(555001) + + for value, expected_ts in ( + (result.due_date, 1790275733), + (result.completed_date, 1790300000), + (result.archived_date, 1790400000), + (result.created_date, 1789275733), + ): + assert value is not None + assert value.tzinfo == UTC + assert value == datetime.fromtimestamp(expected_ts, tz=UTC) + + def test_details_raises_graphql_error_on_http_400(self) -> None: + """A validation error (HTTP 400 + `errors`) raises `GraphQLError`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "to-do not found"}]}, status_code=400 + ) + + with pytest.raises(GraphQLError, match="to-do not found") as exc_info: + ops.details(555001) + assert exc_info.value.status_code == 400 + + def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + ops.list(meeting_id=349524, user_id=1305290) + + def test_list_by_meeting_default_where(self) -> None: + """`list(meeting_id=...)` excludes completed and archived to-dos by default.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + ) + + result = ops.list(meeting_id=349524) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == { + "and": [{"completed": {"eq": False}}, {"archived": {"eq": False}}] + } + + def test_list_by_meeting_include_completed_and_archived(self) -> None: + """`include_completed=True, include_archived=True` sends no `where` filter.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + ) + + ops.list(meeting_id=349524, include_completed=True, include_archived=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] is None + + def test_list_by_meeting_include_completed_only(self) -> None: + """`include_completed=True` alone still filters out archived to-dos.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + ) + + ops.list(meeting_id=349524, include_completed=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == {"and": [{"archived": {"eq": False}}]} + + def test_list_by_meeting_missing_meeting_returns_empty(self) -> None: + """A `null` `meeting` (e.g. bad id) produces an empty list, not an error.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + result = ops.list(meeting_id=999999) + + assert result == [] + + def test_list_raises_graphql_error_on_200_with_errors(self) -> None: + """An execution error (HTTP 200 + `errors` alongside `data`) still raises.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"errors": [{"message": "boom"}], "data": {"meeting": None}} + ) + + with pytest.raises(GraphQLError, match="boom") as exc_info: + ops.list(meeting_id=349524) + assert exc_info.value.status_code == 200 + + def test_list_by_user_defaults_to_current_user(self) -> None: + """`list()` with no arguments defaults to the current user's to-dos.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"todos": {"nodes": [TODO_NODE]}}}), + ] + + result = ops.list() + + assert len(result) == 1 + list_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert list_variables["userId"] == 1305290 + + def test_list_by_user_explicit_id(self) -> None: + """`list(user_id=...)` queries the root `todos(userId)` connection.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"todos": {"nodes": [TODO_NODE]}}} + ) + + result = ops.list(user_id=1305290) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + sent_query = client.post.call_args.kwargs["json"]["query"] + assert "todos(userId" in sent_query + + def test_list_by_user_include_archived_where_still_sent(self) -> None: + """`list(user_id=..., include_archived=True)` still builds a `where`. + + The API ignores the `archived` clause server-side for the + user-scoped connection (see `list()`'s docstring), but the client + builds `where` the same way regardless of scope: only the + `completed` clause survives here. + """ + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"todos": {"nodes": [TODO_NODE]}}} + ) + + ops.list(user_id=1305290, include_archived=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == {"and": [{"completed": {"eq": False}}]} + + def test_list_by_user_missing_todos_returns_empty(self) -> None: + """A missing `todos` connection produces an empty list, not an error.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {}}) + + result = ops.list(user_id=1305290) + + assert result == [] + + def test_create_without_notes_defaults_due_date_and_user(self) -> None: + """`create()` without `due_date`/`notes` defaults both, skips `CreateNote`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateTodo": {"id": 555001}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + result = ops.create("SDK v2 test to-do", meeting_id=349524) + + assert result.id == 555001 + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + input_ = create_variables["input"] + assert input_["title"] == "SDK v2 test to-do" + assert input_["assigneeId"] == 1305290 + assert input_["meetingRecurrenceId"] == 349524 + assert "notesId" not in input_ + assert "collaborationEnabled" not in input_ + + due_date = datetime.fromtimestamp(input_["dueDate"], tz=UTC) + expected_date = (datetime.now(tz=UTC) + timedelta(days=7)).date() + assert due_date.date() == expected_date + assert due_date.time() == time(0, 0) + + def test_create_personal_todo_sends_null_meeting_id(self) -> None: + """`create()` without `meeting_id` sends `meetingRecurrenceId: null`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateTodo": {"id": 555001}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.create("Personal to-do", user_id=1305290) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables["input"]["meetingRecurrenceId"] is None + + def test_create_with_explicit_due_date(self) -> None: + """`create(due_date=...)` converts the given date to a unix timestamp.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateTodo": {"id": 555001}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.create( + "SDK v2 test to-do", + meeting_id=349524, + user_id=1305290, + due_date=date(2026, 10, 1), + ) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + expected = datetime(2026, 10, 1, tzinfo=UTC).timestamp() + assert create_variables["input"]["dueDate"] == expected + + def test_create_with_explicit_datetime_due_date_preserves_time(self) -> None: + """`create(due_date=)` preserves the time-of-day.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateTodo": {"id": 555001}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + due = datetime(2026, 10, 1, 15, 30, tzinfo=UTC) + + ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, due_date=due + ) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables["input"]["dueDate"] == due.timestamp() + + def test_create_with_notes_calls_create_note_first(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateTodo`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), + _response({"data": {"CreateTodo": {"id": 555001}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" + ) + + note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert note_variables == {"text": "hello"} + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + assert create_variables["input"]["collaborationEnabled"] is True + + def test_create_raises_graphql_error_when_create_note_fails(self) -> None: + """`create(notes=...)` propagates a `CreateNote` failure before `CreateTodo`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "CreateNote": { + "success": False, + "message": "pad service unavailable", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="pad service unavailable"): + ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" + ) + client.post.assert_called_once() + + def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + ops.update(555001) + client.post.assert_not_called() + + def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + result = ops.update(555001, title="New title") + + assert result.id == 555001 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"todoId": 555001, "title": "New title"}} + + def test_update_due_date_only(self) -> None: + """`update(due_date=...)` sends only the converted due date.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.update(555001, due_date=date(2026, 10, 1)) + + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + expected = datetime(2026, 10, 1, tzinfo=UTC).timestamp() + assert edit_variables == {"input": {"todoId": 555001, "dueDate": expected}} + + def test_update_user_id_only(self) -> None: + """`update(user_id=...)` sends only the new `assigneeId`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.update(555001, user_id=42) + + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"todoId": 555001, "assigneeId": 42}} + + def test_update_notes_only(self) -> None: + """`update(notes=...)` calls `CreateNote` then sends `notesId`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-77"}}}), + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.update(555001, notes="updated notes") + + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "todoId": 555001, + "notesId": "pad-77", + "collaborationEnabled": True, + } + } + + def test_update_all_fields_together(self) -> None: + """`update()` with every field set sends all of them at once.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-77"}}}), + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.update( + 555001, + title="New title", + due_date=date(2026, 10, 1), + user_id=42, + notes="updated notes", + ) + + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + expected_due = datetime(2026, 10, 1, tzinfo=UTC).timestamp() + assert edit_variables == { + "input": { + "todoId": 555001, + "title": "New title", + "dueDate": expected_due, + "assigneeId": 42, + "notesId": "pad-77", + "collaborationEnabled": True, + } + } + + def test_update_raises_graphql_error_on_failure(self) -> None: + """`update()` raises `GraphQLError` when `EditTodo` reports failure.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "EditTodo": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + ops.update(555001, title="New title") + + def test_complete(self) -> None: + """`complete()` sends a current `completedTimestamp`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": {**TODO_NODE, "completed": True}}}), + ] + + result = ops.complete(555001) + + assert result.completed is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["todoId"] == 555001 + assert isinstance(edit_input["completedTimestamp"], float) + + def test_complete_raises_graphql_error_on_failure(self) -> None: + """`complete()` raises `GraphQLError` when `EditTodo` reports failure.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + { + "data": { + "EditTodo": { + "success": False, + "message": None, + "errorDetails": [{"message": "to-do is archived"}], + } + } + } + ) + + with pytest.raises(GraphQLError, match="to-do is archived") as exc_info: + ops.complete(555001) + assert exc_info.value.errors == [{"message": "to-do is archived"}] + + def test_reopen(self) -> None: + """`reopen()` sends explicit `completedTimestamp: null`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.reopen(555001) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"todoId": 555001, "completedTimestamp": None} + + def test_archive(self) -> None: + """`archive()` sends a current `archivedTimestamp`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": {**TODO_NODE, "archived": True}}}), + ] + + result = ops.archive(555001) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["todoId"] == 555001 + assert isinstance(edit_input["archivedTimestamp"], float) + + def test_restore(self) -> None: + """`restore()` sends explicit `archivedTimestamp: null`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditTodo": {"success": True, "message": None}}}), + _response({"data": {"todo": TODO_NODE}}), + ] + + ops.restore(555001) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"todoId": 555001, "archivedTimestamp": None} + + def test_restore_raises_graphql_error_on_missing_result(self) -> None: + """`restore()` raises `GraphQLError` when `EditTodo` is missing entirely.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {}}) + + with pytest.raises(GraphQLError, match="restore todo failed"): + ops.restore(555001) + + +class TestTodoOperationsAsync: + """Tests for the async `AsyncTodoOperations`.""" + + @pytest.mark.asyncio + async def test_details(self) -> None: + """`details()` returns a transformed `Todo`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"todo": TODO_NODE}}) + + result = await ops.details(555001) + + assert result.id == 555001 + assert result.owner is not None and result.owner.id == 1305290 + assert result.meeting is not None and result.meeting.id == 349524 + assert result.notes == "some notes" + + @pytest.mark.asyncio + async def test_details_with_null_assignee(self) -> None: + """A to-do with a `null` `assignee` produces `owner=None`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + node = {**TODO_NODE, "assignee": None} + client.post.return_value = _async_response({"data": {"todo": node}}) + + result = await ops.details(555001) + + assert result.owner is None + + @pytest.mark.asyncio + async def test_details_with_null_meeting(self) -> None: + """A personal to-do (`meeting: null`) produces `meeting=None`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + node = {**TODO_NODE, "meeting": None} + client.post.return_value = _async_response({"data": {"todo": node}}) + + result = await ops.details(555001) + + assert result.meeting is None + + @pytest.mark.asyncio + async def test_details_datetime_fields_are_utc_aware(self) -> None: + """Timestamp fields convert into timezone-aware UTC datetimes.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + node = { + **TODO_NODE, + "completedTimestamp": 1790300000, + "archivedTimestamp": 1790400000, + } + client.post.return_value = _async_response({"data": {"todo": node}}) + + result = await ops.details(555001) + + for value, expected_ts in ( + (result.due_date, 1790275733), + (result.completed_date, 1790300000), + (result.archived_date, 1790400000), + (result.created_date, 1789275733), + ): + assert value is not None + assert value.tzinfo == UTC + assert value == datetime.fromtimestamp(expected_ts, tz=UTC) + + @pytest.mark.asyncio + async def test_details_raises_graphql_error_on_http_400(self) -> None: + """A validation error (HTTP 400 + `errors`) raises `GraphQLError`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"errors": [{"message": "to-do not found"}]}, status_code=400 + ) + + with pytest.raises(GraphQLError, match="to-do not found") as exc_info: + await ops.details(555001) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_list_both_meeting_and_user_raises(self) -> None: + """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="not both"): + await ops.list(meeting_id=349524, user_id=1305290) + client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_list_by_meeting_default_where(self) -> None: + """`list(meeting_id=...)` excludes completed and archived to-dos by default.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + ) + + result = await ops.list(meeting_id=349524) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["meetingId"] == 349524 + assert variables["where"] == { + "and": [{"completed": {"eq": False}}, {"archived": {"eq": False}}] + } + + @pytest.mark.asyncio + async def test_list_by_meeting_include_completed_and_archived(self) -> None: + """`include_completed=True, include_archived=True` sends no `where` filter.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + ) + + await ops.list(meeting_id=349524, include_completed=True, include_archived=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] is None + + @pytest.mark.asyncio + async def test_list_raises_graphql_error_on_200_with_errors(self) -> None: + """An execution error (HTTP 200 + `errors` alongside `data`) still raises.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"errors": [{"message": "boom"}], "data": {"meeting": None}} + ) + + with pytest.raises(GraphQLError, match="boom") as exc_info: + await ops.list(meeting_id=349524) + assert exc_info.value.status_code == 200 + + @pytest.mark.asyncio + async def test_list_by_user_defaults_to_current_user(self) -> None: + """`list()` with no arguments defaults to the current user's to-dos.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"todos": {"nodes": [TODO_NODE]}}}), + ] + + result = await ops.list() + + assert len(result) == 1 + list_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert list_variables["userId"] == 1305290 + + @pytest.mark.asyncio + async def test_list_by_user_explicit_id(self) -> None: + """`list(user_id=...)` queries the root `todos(userId)` connection.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"todos": {"nodes": [TODO_NODE]}}} + ) + + result = await ops.list(user_id=1305290) + + assert len(result) == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 1305290 + sent_query = client.post.call_args.kwargs["json"]["query"] + assert "todos(userId" in sent_query + + @pytest.mark.asyncio + async def test_update_no_fields_raises(self) -> None: + """`update()` with no fields raises `ValueError`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(555001) + client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_create_without_notes_defaults_due_date_and_user(self) -> None: + """`create()` without `due_date`/`notes` defaults both, skips `CreateNote`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _async_response({"data": {"CreateTodo": {"id": 555001}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + result = await ops.create("SDK v2 test to-do", meeting_id=349524) + + assert result.id == 555001 + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + input_ = create_variables["input"] + assert input_["title"] == "SDK v2 test to-do" + assert input_["assigneeId"] == 1305290 + assert input_["meetingRecurrenceId"] == 349524 + assert "notesId" not in input_ + + due_date = datetime.fromtimestamp(input_["dueDate"], tz=UTC) + expected_date = (datetime.now(tz=UTC) + timedelta(days=7)).date() + assert due_date.date() == expected_date + assert due_date.time() == time(0, 0) + + @pytest.mark.asyncio + async def test_create_personal_todo_sends_null_meeting_id(self) -> None: + """`create()` without `meeting_id` sends `meetingRecurrenceId: null`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateTodo": {"id": 555001}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.create("Personal to-do", user_id=1305290) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert create_variables["input"]["meetingRecurrenceId"] is None + + @pytest.mark.asyncio + async def test_create_with_explicit_due_date(self) -> None: + """`create(due_date=...)` converts the given date to a unix timestamp.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateTodo": {"id": 555001}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.create( + "SDK v2 test to-do", + meeting_id=349524, + user_id=1305290, + due_date=date(2026, 10, 1), + ) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + expected = datetime(2026, 10, 1, tzinfo=UTC).timestamp() + assert create_variables["input"]["dueDate"] == expected + + @pytest.mark.asyncio + async def test_create_with_notes(self) -> None: + """`create(notes=...)` calls `CreateNote` before `CreateTodo`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-99"}}} + ), + _async_response({"data": {"CreateTodo": {"id": 555001}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + result = await ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" + ) + + assert result.id == 555001 + note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert note_variables == {"text": "hello"} + create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_variables["input"]["notesId"] == "pad-99" + assert create_variables["input"]["collaborationEnabled"] is True + + @pytest.mark.asyncio + async def test_create_raises_graphql_error_when_create_note_fails(self) -> None: + """`create(notes=...)` propagates a `CreateNote` failure.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + { + "data": { + "CreateNote": { + "success": False, + "message": "pad service unavailable", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="pad service unavailable"): + await ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" + ) + client.post.assert_called_once() + + @pytest.mark.asyncio + async def test_update_title(self) -> None: + """`update(title=...)` sends only the title field.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + result = await ops.update(555001, title="New title") + + assert result.id == 555001 + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_variables == {"input": {"todoId": 555001, "title": "New title"}} + + @pytest.mark.asyncio + async def test_update_due_date_and_user_id(self) -> None: + """`update(due_date=..., user_id=...)` sends both converted fields.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.update(555001, due_date=date(2026, 10, 1), user_id=42) + + edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + expected = datetime(2026, 10, 1, tzinfo=UTC).timestamp() + assert edit_variables == { + "input": {"todoId": 555001, "dueDate": expected, "assigneeId": 42} + } + + @pytest.mark.asyncio + async def test_update_notes_only(self) -> None: + """`update(notes=...)` calls `CreateNote` then sends `notesId`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response( + {"data": {"CreateNote": {"success": True, "data": "pad-77"}}} + ), + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.update(555001, notes="updated notes") + + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "todoId": 555001, + "notesId": "pad-77", + "collaborationEnabled": True, + } + } + + @pytest.mark.asyncio + async def test_update_raises_graphql_error_on_failure(self) -> None: + """`update()` raises `GraphQLError` when `EditTodo` reports failure.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + { + "data": { + "EditTodo": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + await ops.update(555001, title="New title") + + @pytest.mark.asyncio + async def test_complete(self) -> None: + """`complete()` sends a current `completedTimestamp`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": {**TODO_NODE, "completed": True}}}), + ] + + result = await ops.complete(555001) + + assert result.completed is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["todoId"] == 555001 + assert isinstance(edit_input["completedTimestamp"], float) + + @pytest.mark.asyncio + async def test_reopen(self) -> None: + """`reopen()` sends explicit `completedTimestamp: null`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.reopen(555001) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"todoId": 555001, "completedTimestamp": None} + + @pytest.mark.asyncio + async def test_archive(self) -> None: + """`archive()` sends a current `archivedTimestamp`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": {**TODO_NODE, "archived": True}}}), + ] + + result = await ops.archive(555001) + + assert result.archived is True + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input["todoId"] == 555001 + assert isinstance(edit_input["archivedTimestamp"], float) + + @pytest.mark.asyncio + async def test_restore(self) -> None: + """`restore()` sends explicit `archivedTimestamp: null`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditTodo": {"success": True, "message": None}}}), + _async_response({"data": {"todo": TODO_NODE}}), + ] + + await ops.restore(555001) + + edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert edit_input == {"todoId": 555001, "archivedTimestamp": None} diff --git a/tests/test_v2_user.py b/tests/test_v2_user.py new file mode 100644 index 0000000..1da4a16 --- /dev/null +++ b/tests/test_v2_user.py @@ -0,0 +1,124 @@ +"""Tests for the v2 (GraphQL) user operations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.v2.models import User +from bloomy.v2.operations.user import AsyncUserOperations, UserOperations + +GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" + +USER_NODE = { + "id": 1305290, + "firstName": "Fran", + "lastName": "Orozco", + "fullName": "Fran Orozco", + "email": "fran@example.com", + "avatar": None, +} + + +def _response(json_data: object) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = Mock() + return response + + +class TestUserOperationsSync: + """Tests for the sync `UserOperations`.""" + + def test_details_defaults_to_current_user(self) -> None: + """`details()` with no `user_id` fetches the authenticated user.""" + client = Mock() + ops = UserOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"user": USER_NODE}}), + ] + + result = ops.details() + + assert isinstance(result, User) + assert result.id == 1305290 + assert result.full_name == "Fran Orozco" + assert result.email == "fran@example.com" + + second_call_variables = client.post.call_args_list[1].kwargs["json"][ + "variables" + ] + assert second_call_variables == {"id": 1305290} + + def test_details_explicit_user_id(self) -> None: + """`details(user_id=...)` skips the current-user lookup.""" + client = Mock() + ops = UserOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"user": USER_NODE}}) + + result = ops.details(user_id=1305290) + + assert result.id == 1305290 + client.post.assert_called_once() + + def test_list_returns_all_users(self) -> None: + """`list()` returns every user from the `users` connection.""" + client = Mock() + ops = UserOperations(client, GRAPHQL_URL) + other = {**USER_NODE, "id": 2, "fullName": "Scout Bloom"} + client.post.return_value = _response( + {"data": {"users": {"nodes": [USER_NODE, other]}}} + ) + + result = ops.list() + + assert len(result) == 2 + assert result[0].id == 1305290 + assert result[1].full_name == "Scout Bloom" + + +class TestUserOperationsAsync: + """Tests for the async `AsyncUserOperations`.""" + + @pytest.mark.asyncio + async def test_details_defaults_to_current_user(self) -> None: + """`details()` with no `user_id` fetches the authenticated user.""" + client = AsyncMock() + ops = AsyncUserOperations(client, GRAPHQL_URL) + response_id = MagicMock() + response_id.status_code = 200 + response_id.json.return_value = { + "data": {"getAuthenticatedUserId": {"id": 1305290}} + } + response_id.raise_for_status = MagicMock() + + response_user = MagicMock() + response_user.status_code = 200 + response_user.json.return_value = {"data": {"user": USER_NODE}} + response_user.raise_for_status = MagicMock() + + client.post.side_effect = [response_id, response_user] + + result = await ops.details() + + assert isinstance(result, User) + assert result.id == 1305290 + + @pytest.mark.asyncio + async def test_list_returns_all_users(self) -> None: + """`list()` returns every user from the `users` connection.""" + client = AsyncMock() + ops = AsyncUserOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"users": {"nodes": [USER_NODE]}}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.list() + + assert len(result) == 1 + assert result[0].id == 1305290 From 5cffe6972282ee6479bf0f386bf83660ef1fea49 Mon Sep 17 00:00:00 2001 From: Franccesco Orozco Date: Thu, 24 Sep 2026 14:25:22 -0600 Subject: [PATCH 2/4] fix: address code review findings in the v2 API - Raise GraphQLError when an entity is missing or a create returns no id - Verify a milestone belongs to its goal before editing; goal_id is keyword-only - Fetch merged issue/headline lists in one request, deduped and ordered by creation date - Keep zero-valued metric scores in scores() - Use the UTC date for the default goal due date - Document the v2 package layout in CLAUDE.md Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 12 +- docs/api/v2/milestone.md | 27 ++-- docs/guide/v2-graphql-api.md | 6 +- src/bloomy/v2/base.py | 91 +++++++++++ src/bloomy/v2/operations/goal.py | 25 ++- src/bloomy/v2/operations/headline.py | 74 +++++---- src/bloomy/v2/operations/issue.py | 122 +++++++-------- src/bloomy/v2/operations/meeting.py | 8 +- src/bloomy/v2/operations/metric.py | 18 ++- src/bloomy/v2/operations/milestone.py | 68 ++++++--- src/bloomy/v2/operations/todo.py | 10 +- src/bloomy/v2/operations/user.py | 4 +- tests/test_integration_v2_goal_milestone.py | 10 +- tests/test_v2_base.py | 94 ++++++++++++ tests/test_v2_goal.py | 69 ++++++++- tests/test_v2_headline.py | 148 ++++++++++++++---- tests/test_v2_issue.py | 161 +++++++++++++++++--- tests/test_v2_meeting.py | 24 +++ tests/test_v2_metric.py | 77 ++++++++++ tests/test_v2_milestone.py | 161 +++++++++++++++++--- tests/test_v2_todo.py | 42 +++++ tests/test_v2_user.py | 24 +++ 22 files changed, 1042 insertions(+), 233 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30cb0fd..9a2593d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ The Bloomy Python SDK is organized as a client-based architecture where all API - Initializes httpx client with authentication headers - Creates instances of all operation classes - Supports context manager protocol + - Exposes two namespaces sharing the same httpx client and API key: `client.v1` (the REST API; top-level attributes like `client.user`/`client.meeting` are aliases onto `client.v1.*`, kept for backward compatibility) and `client.v2` (the GraphQL API at `graphql_url`, defaulting to the scheme/host of `base_url` plus `/graphql/`) — see "v2 (GraphQL) Operations Pattern" below 2. **Configuration (`src/bloomy/configuration.py`)**: - Manages API key from multiple sources (direct, env var, config file) @@ -68,14 +69,21 @@ The Bloomy Python SDK is organized as a client-based architecture where all API - Generic bulk operations logic is provided in base classes (`_validate_bulk_item`, `_process_bulk_sync`, `_process_bulk_async`) - Operations are accessed via client attributes: `client.user`, `client.meeting`, etc. -4. **Models (`src/bloomy/models.py`)**: +4. **v2 (GraphQL) Operations Pattern (`src/bloomy/v2/`)**: + - `client.v2` is the GraphQL namespace, separate from `client.v1`'s REST namespace above; it covers the same entities (users, meetings, issues, headlines, todos, goals, milestones, metrics) through GraphQL documents instead of REST calls + - Transport and shared helpers (request/response handling, error checking, timestamp conversion, notes handling) live in `src/bloomy/v2/base.py`; models live in `src/bloomy/v2/models.py` + - Unlike v1, each entity's GraphQL documents, response transforms, and sync/async operations classes live together in one module under `src/bloomy/v2/operations/` (e.g. `operations/issue.py` holds `IssueOperationsMixin`, `IssueOperations`, and `AsyncIssueOperations` side by side), instead of v1's separate `operations/`, `operations/async_/`, and `operations/mixins/` directories + - Each entity module's mixin (`OperationsMixin`) holds the GraphQL query/mutation strings and `_transform_*` methods; both the sync and async operations classes inherit from it alongside their respective base class (`GraphQLOperations`/`AsyncGraphQLOperations`) + - Operations are accessed via `client.v2.user`, `client.v2.issue`, etc. + +5. **Models (`src/bloomy/models.py`)**: - Pydantic models for type-safe API responses - All models inherit from `BloomyBaseModel` with common config - Field aliases map PascalCase API responses to snake_case Python attributes - Reusable annotated types: `OptionalDatetime` and `OptionalFloat` using Pydantic's `BeforeValidator` - Some models are type aliases for backward compatibility (e.g., `HeadlineListItem = HeadlineDetails`) -5. **API Endpoints**: +6. **API Endpoints**: - Base URL: `https://app.bloomgrowth.com/api/v1` - Authentication: Bearer token in Authorization header - All responses are JSON diff --git a/docs/api/v2/milestone.md b/docs/api/v2/milestone.md index c33b2cb..e3b0161 100644 --- a/docs/api/v2/milestone.md +++ b/docs/api/v2/milestone.md @@ -51,13 +51,16 @@ The async version `AsyncMilestoneOperations` provides the same methods as above, # List a goal's milestones milestones = client.v2.milestone.list(goal_id=5265048) - # Update it (goal_id is required to re-read it afterward) + # Update it (goal_id is keyword-only: verified before anything is + # written, and needed again to re-read the milestone afterward) updated = client.v2.milestone.update( - milestone.id, milestone.goal_id, title="Draft spec v2" + milestone.id, goal_id=milestone.goal_id, title="Draft spec v2" ) # Mark it complete - completed = client.v2.milestone.complete(milestone.id, milestone.goal_id) + completed = client.v2.milestone.complete( + milestone.id, goal_id=milestone.goal_id + ) # Delete it (no goal_id needed, and there is no restore) client.v2.milestone.delete(milestone.id) @@ -80,7 +83,9 @@ The async version `AsyncMilestoneOperations` provides the same methods as above, ) # Mark it complete - completed = await client.v2.milestone.complete(milestone.id, milestone.goal_id) + completed = await client.v2.milestone.complete( + milestone.id, goal_id=milestone.goal_id + ) # Delete it await client.v2.milestone.delete(milestone.id) @@ -94,17 +99,19 @@ The async version `AsyncMilestoneOperations` provides the same methods as above, |--------|-------------|------------|---------| | `list()` | List a goal's milestones, ordered by due date | `goal_id` | `list[Milestone]` | | `create()` | Create a milestone on a goal | `goal_id`, `title`, `due_date`, `completed` | `Milestone` | -| `update()` | Update a milestone | `milestone_id`, `goal_id`, `title`, `due_date`, `completed` | `Milestone` | -| `complete()` | Mark a milestone as completed | `milestone_id`, `goal_id` | `Milestone` | +| `update()` | Update a milestone | `milestone_id`, `goal_id` (keyword-only), `title`, `due_date`, `completed` | `Milestone` | +| `complete()` | Mark a milestone as completed | `milestone_id`, `goal_id` (keyword-only) | `Milestone` | | `delete()` | Delete a milestone | `milestone_id` | `None` | !!! note "Why `update()` and `complete()` need `goal_id`" There is no root GraphQL query to read a single milestone by id — a milestone can only be read through its parent goal - (`goal(id){ milestones }`). `update()` and `complete()` need to re-read - the milestone after editing it, so both require `goal_id` in addition to - `milestone_id`. `delete()` does not, since deleting one does not require - reading it back. + (`goal(id){ milestones }`). `update()` and `complete()` need `goal_id` + (keyword-only) in addition to `milestone_id`: both verify that + `milestone_id` actually belongs to `goal_id` **before** sending any edit, + so a wrong `goal_id` fails without writing anything, and then need + `goal_id` again to re-read the milestone afterward. `delete()` needs + neither, since `DeleteMilestone` does not return a `Milestone`. !!! note "No archive/restore — only delete" Milestones do not archive; `delete()` permanently removes one diff --git a/docs/guide/v2-graphql-api.md b/docs/guide/v2-graphql-api.md index 4ac2867..c9491ee 100644 --- a/docs/guide/v2-graphql-api.md +++ b/docs/guide/v2-graphql-api.md @@ -192,8 +192,8 @@ return-type documentation lives on each entity's API reference page. |---|---|---| | `list()` | `list(goal_id) -> list[Milestone]` | List a goal's milestones. | | `create()` | `create(goal_id, title, due_date, completed=False) -> Milestone` | Create a milestone on a goal. | -| `update()` | `update(milestone_id, goal_id, *, title=None, due_date=None, completed=None) -> Milestone` | Update a milestone. Requires `goal_id` — see [below](#archive-vs-delete-semantics-per-entity). | -| `complete()` | `complete(milestone_id, goal_id) -> Milestone` | Mark a milestone as completed. Requires `goal_id`. | +| `update()` | `update(milestone_id, *, goal_id, title=None, due_date=None, completed=None) -> Milestone` | Update a milestone. Requires `goal_id` (keyword-only) — see [below](#archive-vs-delete-semantics-per-entity). | +| `complete()` | `complete(milestone_id, *, goal_id) -> Milestone` | Mark a milestone as completed. Requires `goal_id` (keyword-only). | | `delete()` | `delete(milestone_id) -> None` | Delete a milestone. No `goal_id` needed. | ### `client.v2.metric` @@ -255,7 +255,7 @@ archive/restore. | Headline | `archive()` / `restore()` | No complete/solve concept. | | Todo | `complete()` / `reopen()`, `archive()` / `restore()` | Independent flags: a to-do can be completed and archived at the same time. | | Goal | `archive()` / `restore()` | `archive()` re-reads the goal after the mutation and raises `GraphQLError` if it is still not archived, working around a server-side bug where the edit can report success without archiving anything. `restore()` re-attaches meeting links detached by a recent archive. | -| Milestone | `delete()` | No archive/restore — `delete()` permanently removes it (soft-deleted server-side; it never reappears). There is no root query to read a milestone directly, which is why `update()` and `complete()` require both `milestone_id` and `goal_id`. | +| Milestone | `delete()` | No archive/restore — `delete()` permanently removes it (soft-deleted server-side; it never reappears). There is no root query to read a milestone directly, which is why `update()` and `complete()` require both `milestone_id` and a keyword-only `goal_id`, which they verify actually owns `milestone_id` *before* writing anything. | | Metric | `archive()` | **No `restore()`.** The GraphQL API does not support un-archiving a metric — editing `archived: false` on an already-archived metric re-archives it rather than restoring it. Archiving a metric is effectively permanent through this SDK. | ## Metric scores and week semantics diff --git a/src/bloomy/v2/base.py b/src/bloomy/v2/base.py index 4d704f2..56a1389 100644 --- a/src/bloomy/v2/base.py +++ b/src/bloomy/v2/base.py @@ -23,6 +23,11 @@ - Mutations shaped `{success message errorDetails{message}}` (the `GraphQLResponseOfBoolean`/`OfString`/`Base` family) raise `GraphQLError` when `success` is `false`, via `_check_mutation_result`. + - A by-id query returning `null` for the requested entity (unknown or + not visible to the caller), and a `Create*` mutation returning a + `null` result or an `id` of `0`, both raise `GraphQLError` via + `_require_entity`/`_require_created_id` rather than failing deep + inside a model/transform call. """ from __future__ import annotations @@ -96,6 +101,33 @@ def extract_notes(data: dict[str, Any]) -> str | None: return text or None +def merge_nodes( + *node_lists: list[dict[str, Any]], date_field: str = "dateCreated" +) -> list[dict[str, Any]]: + """Merge raw GraphQL node lists, deduping by id and sorting by creation date. + + Used to combine the results of a single query that selects several + connections conditionally with `@include(if: ...)` (e.g. `issues` plus + `recentlySolvedIssues`/`archivedIssues`) into one ordered list, since each + connection is independently ordered but the combined result is not. + + Args: + *node_lists: Raw node lists to merge, in any order. + date_field: The raw field name holding each node's creation + timestamp, used as the primary sort key. + + Returns: + The merged nodes with each id appearing once (first occurrence + wins), sorted by `(date_field, id)`. + + """ + seen: dict[int, dict[str, Any]] = {} + for nodes in node_lists: + for node in nodes: + seen.setdefault(node["id"], node) + return sorted(seen.values(), key=lambda node: (node[date_field], node["id"])) + + def dig_nodes(data: dict[str, Any] | None, *keys: str) -> list[dict[str, Any]]: """Walk nested dict keys (each defaulting to `{}`) and read a `nodes` list. @@ -215,6 +247,65 @@ def _check_mutation_result( ) raise GraphQLError(message, errors=error_details) + # -- entity presence ------------------------------------------------------- + + @staticmethod + def _require_entity( + data: dict[str, Any], field: str, entity_id: int, label: str + ) -> dict[str, Any]: + """Get a required nested entity object from a GraphQL response, or raise. + + A by-id query (e.g. `issue(id: $id)`) returns `null` for that field + instead of a top-level GraphQL error when the id is unknown or not + visible to the caller, so every `details()`-style read needs this + check before handing the raw object to a model/transform. + + Args: + data: The raw GraphQL `data` object. + field: The key in `data` holding the entity object (e.g. + `"issue"`). + entity_id: The id that was requested, used in the error message. + label: A human-readable label for the entity (e.g. `"Issue"`). + + Returns: + The entity object. + + Raises: + GraphQLError: If `data[field]` is missing or `null`. + + """ + entity = data.get(field) + if not entity: + raise GraphQLError(f"{label} {entity_id} not found") + return entity + + @staticmethod + def _require_created_id(result: dict[str, Any] | None, *, label: str) -> int: + """Get a newly created entity's id from a `Create*` mutation result, or raise. + + Some v2 `Create*` mutations (e.g. `CreateGoal`) return `IdModel { id }` + and report a failure by returning a `null` result, or an `id` of `0`, + rather than a top-level GraphQL error -- re-reading id `0` would + otherwise surface as a confusing "not found" from the follow-up read. + + Args: + result: The mutation's result object (e.g. `data["CreateGoal"]`). + label: A human-readable label for the entity being created, used + in the error message (e.g. `"goal"`). + + Returns: + The newly created entity's id. + + Raises: + GraphQLError: If `result` is missing, or its `id` is missing or + `0`. + + """ + entity_id = (result or {}).get("id") + if not entity_id: + raise GraphQLError(f"create {label} failed: no id returned") + return int(entity_id) + # -- timestamps ----------------------------------------------------------- @staticmethod diff --git a/src/bloomy/v2/operations/goal.py b/src/bloomy/v2/operations/goal.py index 31c6e98..2c7498f 100644 --- a/src/bloomy/v2/operations/goal.py +++ b/src/bloomy/v2/operations/goal.py @@ -3,7 +3,7 @@ from __future__ import annotations import builtins -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import Any from ...exceptions import GraphQLError @@ -122,6 +122,15 @@ def _goal_where(self, *, include_archived: bool) -> dict[str, Any] | None: return None return {"and": [{"archived": {"eq": False}}]} + def _default_due_date(self) -> date: + """Compute the default due date: 90 days from today, 00:00 UTC. + + Returns: + A `date` 90 days from today (UTC). + + """ + return datetime.now(tz=UTC).date() + timedelta(days=90) + def _parse_milestone_item( self, item: dict[str, Any] | tuple[Any, ...] ) -> tuple[Any, Any, bool]: @@ -239,7 +248,7 @@ def details(self, goal_id: int) -> Goal: """ data = self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) - return self._transform_goal(data["goal"]) + return self._transform_goal(self._require_entity(data, "goal", goal_id, "Goal")) def create( self, @@ -279,7 +288,7 @@ def create( if user_id is None: user_id = self.user_id if due_date is None: - due_date = date.today() + timedelta(days=90) + due_date = self._default_due_date() input_: dict[str, Any] = { "title": title, @@ -306,7 +315,8 @@ def create( ] data = self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) - return self.details(data["CreateGoal"]["id"]) + goal_id = self._require_created_id(data.get("CreateGoal"), label="goal") + return self.details(goal_id) def update( self, @@ -477,7 +487,7 @@ async def details(self, goal_id: int) -> Goal: """ data = await self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) - return self._transform_goal(data["goal"]) + return self._transform_goal(self._require_entity(data, "goal", goal_id, "Goal")) async def create( self, @@ -511,7 +521,7 @@ async def create( if user_id is None: user_id = await self.get_user_id() if due_date is None: - due_date = date.today() + timedelta(days=90) + due_date = self._default_due_date() input_: dict[str, Any] = { "title": title, @@ -538,7 +548,8 @@ async def create( ] data = await self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) - return await self.details(data["CreateGoal"]["id"]) + goal_id = self._require_created_id(data.get("CreateGoal"), label="goal") + return await self.details(goal_id) async def update( self, diff --git a/src/bloomy/v2/operations/headline.py b/src/bloomy/v2/operations/headline.py index abae47c..1684938 100644 --- a/src/bloomy/v2/operations/headline.py +++ b/src/bloomy/v2/operations/headline.py @@ -5,7 +5,13 @@ import builtins from typing import Any -from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes +from ..base import ( + AsyncGraphQLOperations, + GraphQLOperations, + dig_nodes, + extract_notes, + merge_nodes, +) from ..models import Headline _HEADLINE_FIELDS = """ @@ -36,23 +42,21 @@ class HeadlineOperationsMixin: """ # `meeting.headlines` returns only OPEN headlines (`CloseTime == null`); - # `meeting.archivedHeadlines` is a separate connection for archived ones. + # `meeting.archivedHeadlines` is a separate connection for archived ones, + # selected conditionally in this one document via `@include` so `list()` + # never issues more than one request. Verified live against production + # (`archivedHeadlines` is omitted from the response entirely when + # `$includeArchived` is `false`). _HEADLINE_MEETING_LIST_QUERY = f""" - query($meetingId: Long!) {{ + query($meetingId: Long!, $includeArchived: Boolean!) {{ meeting(id: $meetingId) {{ headlines(order: [{{ dateCreated: ASC }}]) {{ nodes {{ {_HEADLINE_FIELDS} }} }} - }} - }} - """ - - _HEADLINE_MEETING_ARCHIVED_QUERY = f""" - query($meetingId: Long!) {{ - meeting(id: $meetingId) {{ - archivedHeadlines(order: [{{ dateCreated: ASC }}]) {{ + archivedHeadlines(order: [{{ dateCreated: ASC }}]) + @include(if: $includeArchived) {{ nodes {{ {_HEADLINE_FIELDS} }} @@ -124,7 +128,9 @@ def details(self, headline_id: int) -> Headline: """ data = self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) - return self._transform_headline(data["headline"]) + return self._transform_headline( + self._require_entity(data, "headline", headline_id, "Headline") + ) def list( self, @@ -161,15 +167,14 @@ def list( if meeting_id is not None: data = self._execute( - self._HEADLINE_MEETING_LIST_QUERY, {"meetingId": meeting_id} + self._HEADLINE_MEETING_LIST_QUERY, + {"meetingId": meeting_id, "includeArchived": include_archived}, + ) + merged = merge_nodes( + dig_nodes(data, "meeting", "headlines"), + dig_nodes(data, "meeting", "archivedHeadlines"), ) - nodes = dig_nodes(data, "meeting", "headlines") - if include_archived: - archived_data = self._execute( - self._HEADLINE_MEETING_ARCHIVED_QUERY, {"meetingId": meeting_id} - ) - nodes = nodes + dig_nodes(archived_data, "meeting", "archivedHeadlines") - return [self._transform_headline(node) for node in nodes] + return [self._transform_headline(node) for node in merged] if user_id is None: user_id = self.user_id @@ -219,7 +224,10 @@ def create( input_["collaborationEnabled"] = True data = self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) - return self.details(data["CreateHeadline"]["id"]) + headline_id = self._require_created_id( + data.get("CreateHeadline"), label="headline" + ) + return self.details(headline_id) def update( self, @@ -312,7 +320,9 @@ async def details(self, headline_id: int) -> Headline: """ data = await self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) - return self._transform_headline(data["headline"]) + return self._transform_headline( + self._require_entity(data, "headline", headline_id, "Headline") + ) async def list( self, @@ -343,15 +353,14 @@ async def list( if meeting_id is not None: data = await self._execute( - self._HEADLINE_MEETING_LIST_QUERY, {"meetingId": meeting_id} + self._HEADLINE_MEETING_LIST_QUERY, + {"meetingId": meeting_id, "includeArchived": include_archived}, ) - nodes = dig_nodes(data, "meeting", "headlines") - if include_archived: - archived_data = await self._execute( - self._HEADLINE_MEETING_ARCHIVED_QUERY, {"meetingId": meeting_id} - ) - nodes = nodes + dig_nodes(archived_data, "meeting", "archivedHeadlines") - return [self._transform_headline(node) for node in nodes] + merged = merge_nodes( + dig_nodes(data, "meeting", "headlines"), + dig_nodes(data, "meeting", "archivedHeadlines"), + ) + return [self._transform_headline(node) for node in merged] if user_id is None: user_id = await self.get_user_id() @@ -395,7 +404,10 @@ async def create( input_["collaborationEnabled"] = True data = await self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) - return await self.details(data["CreateHeadline"]["id"]) + headline_id = self._require_created_id( + data.get("CreateHeadline"), label="headline" + ) + return await self.details(headline_id) async def update( self, diff --git a/src/bloomy/v2/operations/issue.py b/src/bloomy/v2/operations/issue.py index fd2427b..55f44e5 100644 --- a/src/bloomy/v2/operations/issue.py +++ b/src/bloomy/v2/operations/issue.py @@ -11,6 +11,7 @@ GraphQLOperations, dig_nodes, extract_notes, + merge_nodes, ) from ..models import Issue @@ -49,38 +50,32 @@ class IssueOperationsMixin: # `meeting.issues` excludes solved and archived issues unconditionally # (it filters `CloseTime == null` server-side); `recentlySolvedIssues` - # and `archivedIssues` are separate connections for those. + # and `archivedIssues` are separate connections for those, selected + # conditionally in this one document via `@include` so `list()` never + # issues more than one request. Verified live against production + # (`recentlySolvedIssues`/`archivedIssues` are omitted from the response + # entirely when their `@include(if: ...)` variable is `false`). _ISSUE_LIST_QUERY = f""" - query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ + query( + $meetingId: Long! + $where: IssueQueryModelFilterInput + $includeSolved: Boolean! + $includeArchived: Boolean! + ) {{ meeting(id: $meetingId) {{ issues(where: $where, order: [{{ dateCreated: ASC }}]) {{ nodes {{ {_ISSUE_FIELDS} }} }} - }} - }} - """ - - # The API stores every long-term (department plan) issue with - # `archived: true`, so `meeting.issues` (which drops archived issues - # server-side) never returns them; this dedicated connection does. - _ISSUE_LONG_TERM_QUERY = f""" - query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ - meeting(id: $meetingId) {{ - longTermIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + recentlySolvedIssues(where: $where, order: [{{ dateCreated: ASC }}]) + @include(if: $includeSolved) {{ nodes {{ {_ISSUE_FIELDS} }} }} - }} - }} - """ - - _ISSUE_SOLVED_QUERY = f""" - query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ - meeting(id: $meetingId) {{ - recentlySolvedIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + archivedIssues(where: $where, order: [{{ dateCreated: ASC }}]) + @include(if: $includeArchived) {{ nodes {{ {_ISSUE_FIELDS} }} @@ -89,10 +84,13 @@ class IssueOperationsMixin: }} """ - _ISSUE_ARCHIVED_QUERY = f""" + # The API stores every long-term (department plan) issue with + # `archived: true`, so `meeting.issues` (which drops archived issues + # server-side) never returns them; this dedicated connection does. + _ISSUE_LONG_TERM_QUERY = f""" query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ meeting(id: $meetingId) {{ - archivedIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + longTermIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ nodes {{ {_ISSUE_FIELDS} }} @@ -171,7 +169,9 @@ def details(self, issue_id: int) -> Issue: """ data = self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) - return self._transform_issue(data["issue"]) + return self._transform_issue( + self._require_entity(data, "issue", issue_id, "Issue") + ) def list( self, @@ -211,26 +211,20 @@ def list( return [self._transform_issue(node) for node in nodes] data = self._execute( - self._ISSUE_LIST_QUERY, {"meetingId": meeting_id, "where": where} + self._ISSUE_LIST_QUERY, + { + "meetingId": meeting_id, + "where": where, + "includeSolved": include_solved, + "includeArchived": include_archived, + }, ) - nodes = dig_nodes(data, "meeting", "issues") - - if include_solved: - solved_data = self._execute( - self._ISSUE_SOLVED_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes += dig_nodes(solved_data, "meeting", "recentlySolvedIssues") - - if include_archived: - archived_data = self._execute( - self._ISSUE_ARCHIVED_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes += dig_nodes(archived_data, "meeting", "archivedIssues") - - seen: dict[int, dict[str, Any]] = {} - for node in nodes: - seen.setdefault(node["id"], node) - return [self._transform_issue(node) for node in seen.values()] + merged = merge_nodes( + dig_nodes(data, "meeting", "issues"), + dig_nodes(data, "meeting", "recentlySolvedIssues"), + dig_nodes(data, "meeting", "archivedIssues"), + ) + return [self._transform_issue(node) for node in merged] def create( self, @@ -275,7 +269,8 @@ def create( input_["collaborationEnabled"] = True data = self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) - return self.details(data["CreateIssue"]["id"]) + issue_id = self._require_created_id(data.get("CreateIssue"), label="issue") + return self.details(issue_id) def update( self, @@ -442,7 +437,9 @@ async def details(self, issue_id: int) -> Issue: """ data = await self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) - return self._transform_issue(data["issue"]) + return self._transform_issue( + self._require_entity(data, "issue", issue_id, "Issue") + ) async def list( self, @@ -476,26 +473,20 @@ async def list( return [self._transform_issue(node) for node in nodes] data = await self._execute( - self._ISSUE_LIST_QUERY, {"meetingId": meeting_id, "where": where} + self._ISSUE_LIST_QUERY, + { + "meetingId": meeting_id, + "where": where, + "includeSolved": include_solved, + "includeArchived": include_archived, + }, ) - nodes = dig_nodes(data, "meeting", "issues") - - if include_solved: - solved_data = await self._execute( - self._ISSUE_SOLVED_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes += dig_nodes(solved_data, "meeting", "recentlySolvedIssues") - - if include_archived: - archived_data = await self._execute( - self._ISSUE_ARCHIVED_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes += dig_nodes(archived_data, "meeting", "archivedIssues") - - seen: dict[int, dict[str, Any]] = {} - for node in nodes: - seen.setdefault(node["id"], node) - return [self._transform_issue(node) for node in seen.values()] + merged = merge_nodes( + dig_nodes(data, "meeting", "issues"), + dig_nodes(data, "meeting", "recentlySolvedIssues"), + dig_nodes(data, "meeting", "archivedIssues"), + ) + return [self._transform_issue(node) for node in merged] async def create( self, @@ -534,7 +525,8 @@ async def create( input_["collaborationEnabled"] = True data = await self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) - return await self.details(data["CreateIssue"]["id"]) + issue_id = self._require_created_id(data.get("CreateIssue"), label="issue") + return await self.details(issue_id) async def update( self, diff --git a/src/bloomy/v2/operations/meeting.py b/src/bloomy/v2/operations/meeting.py index 82c708d..67e519d 100644 --- a/src/bloomy/v2/operations/meeting.py +++ b/src/bloomy/v2/operations/meeting.py @@ -161,7 +161,9 @@ def details(self, meeting_id: int) -> Meeting: """ data = self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) - return self._transform_meeting(data["meeting"]) + return self._transform_meeting( + self._require_entity(data, "meeting", meeting_id, "Meeting") + ) def attendees(self, meeting_id: int) -> builtins.list[User]: """List the attendees of a meeting. @@ -209,7 +211,9 @@ async def details(self, meeting_id: int) -> Meeting: """ data = await self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) - return self._transform_meeting(data["meeting"]) + return self._transform_meeting( + self._require_entity(data, "meeting", meeting_id, "Meeting") + ) async def attendees(self, meeting_id: int) -> builtins.list[User]: """List the attendees of a meeting. diff --git a/src/bloomy/v2/operations/metric.py b/src/bloomy/v2/operations/metric.py index 8059f8f..7a75b19 100644 --- a/src/bloomy/v2/operations/metric.py +++ b/src/bloomy/v2/operations/metric.py @@ -332,7 +332,9 @@ def details(self, metric_id: int) -> Metric: """ data = self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) - return self._transform_metric(data["metric"]) + return self._transform_metric( + self._require_entity(data, "metric", metric_id, "Metric") + ) def list( self, @@ -464,7 +466,8 @@ def create( input_["collaborationEnabled"] = True data = self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) - return self.details(data["CreateMetric"]["id"]) + metric_id = self._require_created_id(data.get("CreateMetric"), label="metric") + return self.details(metric_id) def update( self, @@ -596,7 +599,7 @@ def scores( ) nodes = self._scores_from(data) if not include_empty: - nodes = [node for node in nodes if node.get("value")] + nodes = [node for node in nodes if node.get("value") not in (None, "")] return [self._transform_score(node) for node in nodes] def set_score( @@ -777,7 +780,9 @@ async def details(self, metric_id: int) -> Metric: """ data = await self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) - return self._transform_metric(data["metric"]) + return self._transform_metric( + self._require_entity(data, "metric", metric_id, "Metric") + ) async def list( self, @@ -897,7 +902,8 @@ async def create( input_["collaborationEnabled"] = True data = await self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) - return await self.details(data["CreateMetric"]["id"]) + metric_id = self._require_created_id(data.get("CreateMetric"), label="metric") + return await self.details(metric_id) async def update( self, @@ -1017,7 +1023,7 @@ async def scores( ) nodes = self._scores_from(data) if not include_empty: - nodes = [node for node in nodes if node.get("value")] + nodes = [node for node in nodes if node.get("value") not in (None, "")] return [self._transform_score(node) for node in nodes] async def set_score( diff --git a/src/bloomy/v2/operations/milestone.py b/src/bloomy/v2/operations/milestone.py index a7b4d53..c2cb41b 100644 --- a/src/bloomy/v2/operations/milestone.py +++ b/src/bloomy/v2/operations/milestone.py @@ -104,10 +104,12 @@ class MilestoneOperations(GraphQLOperations, MilestoneOperationsMixin): Note: The GraphQL API has no root `milestone(id)` query — a milestone can only be read through its parent goal (`goal(id){ milestones }`). So - `update()` and `complete()`, which need to re-read the milestone - after editing it, require `goal_id` in addition to `milestone_id`. - `delete()` does not, since `DeleteMilestone` does not return a - `Milestone`. + `update()` and `complete()` require `goal_id` (keyword-only) in + addition to `milestone_id`: both verify that `milestone_id` actually + belongs to `goal_id` *before* sending any edit (so a wrong `goal_id` + fails without writing anything), and need `goal_id` again to re-read + the milestone afterwards. `delete()` needs neither the verification + nor `goal_id`, since `DeleteMilestone` does not return a `Milestone`. """ @@ -164,13 +166,16 @@ def create( "completed": completed, } data = self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) - return self._get(goal_id, data["CreateMilestone"]["id"]) + milestone_id = self._require_created_id( + data.get("CreateMilestone"), label="milestone" + ) + return self._get(goal_id, milestone_id) def update( self, milestone_id: int, - goal_id: int, *, + goal_id: int, title: str | None = None, due_date: datetime | date | float | int | None = None, completed: bool | None = None, @@ -179,8 +184,10 @@ def update( Args: milestone_id: The ID of the milestone to update. - goal_id: The ID of the milestone's parent goal, needed to re-read - it afterwards (see the class `Note`). + goal_id: The ID of the milestone's parent goal. Keyword-only: + verified to actually own `milestone_id` (see the class + `Note`) *before* anything is written, and needed again to + re-read the milestone afterwards. title: New title for the milestone. due_date: New due date, as a `datetime`, `date`, or unix timestamp (seconds). @@ -194,7 +201,7 @@ def update( Example: ```python - client.v2.milestone.update(1, 5265048, title="Draft spec v2") + client.v2.milestone.update(1, goal_id=5265048, title="Draft spec v2") # Returns: Milestone(id=1, title='Draft spec v2', ...) ``` @@ -202,6 +209,12 @@ def update( if title is None and due_date is None and completed is None: raise ValueError("At least one field must be provided for update") + # Verify `milestone_id` actually belongs to `goal_id` before writing + # anything: without this, a wrong `goal_id` would still send + # `EditMilestone` and only report "not found" on the re-read after + # the write already happened. + self._get(goal_id, milestone_id) + input_: dict[str, Any] = {"milestoneId": milestone_id} if title is not None: input_["title"] = title @@ -213,18 +226,22 @@ def update( self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) return self._get(goal_id, milestone_id) - def complete(self, milestone_id: int, goal_id: int) -> Milestone: + def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: """Mark a milestone as completed. Args: milestone_id: The ID of the milestone to complete. - goal_id: The ID of the milestone's parent goal, needed to re-read - it afterwards (see the class `Note`). + goal_id: The ID of the milestone's parent goal. Keyword-only: + verified to actually own `milestone_id` *before* anything is + written (see `update()`), and needed again to re-read the + milestone afterwards. Returns: The updated `Milestone`. """ + self._get(goal_id, milestone_id) + self._execute( self._MILESTONE_EDIT_MUTATION, {"input": {"milestoneId": milestone_id, "completed": True}}, @@ -322,13 +339,16 @@ async def create( "completed": completed, } data = await self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) - return await self._get(goal_id, data["CreateMilestone"]["id"]) + milestone_id = self._require_created_id( + data.get("CreateMilestone"), label="milestone" + ) + return await self._get(goal_id, milestone_id) async def update( self, milestone_id: int, - goal_id: int, *, + goal_id: int, title: str | None = None, due_date: datetime | date | float | int | None = None, completed: bool | None = None, @@ -337,8 +357,10 @@ async def update( Args: milestone_id: The ID of the milestone to update. - goal_id: The ID of the milestone's parent goal, needed to re-read - it afterwards (see the class `Note`). + goal_id: The ID of the milestone's parent goal. Keyword-only: + verified to actually own `milestone_id` (see the class + `Note`) *before* anything is written, and needed again to + re-read the milestone afterwards. title: New title for the milestone. due_date: New due date, as a `datetime`, `date`, or unix timestamp (seconds). @@ -354,6 +376,10 @@ async def update( if title is None and due_date is None and completed is None: raise ValueError("At least one field must be provided for update") + # Verify `milestone_id` actually belongs to `goal_id` before writing + # anything (see `MilestoneOperations.update`). + await self._get(goal_id, milestone_id) + input_: dict[str, Any] = {"milestoneId": milestone_id} if title is not None: input_["title"] = title @@ -365,18 +391,22 @@ async def update( await self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) return await self._get(goal_id, milestone_id) - async def complete(self, milestone_id: int, goal_id: int) -> Milestone: + async def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: """Mark a milestone as completed. Args: milestone_id: The ID of the milestone to complete. - goal_id: The ID of the milestone's parent goal, needed to re-read - it afterwards (see the class `Note`). + goal_id: The ID of the milestone's parent goal. Keyword-only: + verified to actually own `milestone_id` *before* anything is + written (see `update()`), and needed again to re-read the + milestone afterwards. Returns: The updated `Milestone`. """ + await self._get(goal_id, milestone_id) + await self._execute( self._MILESTONE_EDIT_MUTATION, {"input": {"milestoneId": milestone_id, "completed": True}}, diff --git a/src/bloomy/v2/operations/todo.py b/src/bloomy/v2/operations/todo.py index acdb38a..4aa8c6b 100644 --- a/src/bloomy/v2/operations/todo.py +++ b/src/bloomy/v2/operations/todo.py @@ -148,7 +148,7 @@ def details(self, todo_id: int) -> Todo: """ data = self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) - return self._transform_todo(data["todo"]) + return self._transform_todo(self._require_entity(data, "todo", todo_id, "Todo")) def list( self, @@ -255,7 +255,8 @@ def create( input_["collaborationEnabled"] = True data = self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) - return self.details(data["CreateTodo"]["id"]) + todo_id = self._require_created_id(data.get("CreateTodo"), label="todo") + return self.details(todo_id) def update( self, @@ -408,7 +409,7 @@ async def details(self, todo_id: int) -> Todo: """ data = await self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) - return self._transform_todo(data["todo"]) + return self._transform_todo(self._require_entity(data, "todo", todo_id, "Todo")) async def list( self, @@ -503,7 +504,8 @@ async def create( input_["collaborationEnabled"] = True data = await self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) - return await self.details(data["CreateTodo"]["id"]) + todo_id = self._require_created_id(data.get("CreateTodo"), label="todo") + return await self.details(todo_id) async def update( self, diff --git a/src/bloomy/v2/operations/user.py b/src/bloomy/v2/operations/user.py index cd4c000..7628426 100644 --- a/src/bloomy/v2/operations/user.py +++ b/src/bloomy/v2/operations/user.py @@ -65,7 +65,7 @@ def details(self, user_id: int | None = None) -> User: user_id = self.user_id data = self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) - return self._transform_user(data["user"]) + return self._transform_user(self._require_entity(data, "user", user_id, "User")) def list(self) -> builtins.list[User]: """List every user in the organization. @@ -101,7 +101,7 @@ async def details(self, user_id: int | None = None) -> User: user_id = await self.get_user_id() data = await self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) - return self._transform_user(data["user"]) + return self._transform_user(self._require_entity(data, "user", user_id, "User")) async def list(self) -> builtins.list[User]: """List every user in the organization. diff --git a/tests/test_integration_v2_goal_milestone.py b/tests/test_integration_v2_goal_milestone.py index c5bfb5f..6baf2c1 100644 --- a/tests/test_integration_v2_goal_milestone.py +++ b/tests/test_integration_v2_goal_milestone.py @@ -160,12 +160,12 @@ def test_full_lifecycle(self, client: Client) -> None: assert new_milestone.completed is False renamed = client.v2.milestone.update( - new_milestone.id, created.id, title="Second milestone (renamed)" + new_milestone.id, goal_id=created.id, title="Second milestone (renamed)" ) assert renamed.title == "Second milestone (renamed)" completed_milestone = client.v2.milestone.complete( - new_milestone.id, created.id + new_milestone.id, goal_id=created.id ) assert completed_milestone.completed is True @@ -222,7 +222,7 @@ def test_update_requires_a_field(self, client: Client) -> None: def test_milestone_update_requires_a_field(self, client: Client) -> None: """Milestone `update()` with no fields raises `ValueError`.""" with pytest.raises(ValueError, match="At least one field"): - client.v2.milestone.update(1, 1) + client.v2.milestone.update(1, goal_id=1) class TestGoalMilestoneLifecycleAsync: @@ -257,7 +257,7 @@ async def test_full_lifecycle(self, async_client: AsyncClient) -> None: assert milestone.goal_id == created.id completed_milestone = await async_client.v2.milestone.complete( - milestone.id, created.id + milestone.id, goal_id=created.id ) assert completed_milestone.completed is True @@ -285,4 +285,4 @@ async def test_milestone_update_requires_a_field( ) -> None: """Milestone `update()` with no fields raises `ValueError`.""" with pytest.raises(ValueError, match="At least one field"): - await async_client.v2.milestone.update(1, 1) + await async_client.v2.milestone.update(1, goal_id=1) diff --git a/tests/test_v2_base.py b/tests/test_v2_base.py index 2c3aa5a..3d0b7a9 100644 --- a/tests/test_v2_base.py +++ b/tests/test_v2_base.py @@ -13,6 +13,7 @@ AsyncGraphQLOperations, GraphQLOperations, extract_notes, + merge_nodes, prepare_note_text, ) @@ -117,6 +118,99 @@ def test_json_without_errors_still_calls_raise_for_status(self) -> None: ops._execute("query {}") +class TestMergeNodes: + """Tests for `merge_nodes`.""" + + def test_dedupes_by_id_keeping_first_occurrence(self) -> None: + """A node appearing in multiple lists is kept only once, first wins.""" + first = {"id": 1, "dateCreated": 100, "title": "first"} + duplicate = {"id": 1, "dateCreated": 100, "title": "duplicate"} + + result = merge_nodes([first], [duplicate]) + + assert result == [first] + + def test_sorts_by_date_created_then_id(self) -> None: + """Merged nodes are sorted by `(dateCreated, id)`, not input order.""" + newer = {"id": 2, "dateCreated": 200} + older = {"id": 1, "dateCreated": 100} + tie_a = {"id": 10, "dateCreated": 150} + tie_b = {"id": 5, "dateCreated": 150} + + result = merge_nodes([newer, older], [tie_a, tie_b]) + + assert [node["id"] for node in result] == [1, 5, 10, 2] + + def test_empty_lists_return_empty(self) -> None: + """Merging only empty lists returns an empty list.""" + assert merge_nodes([], []) == [] + + def test_custom_date_field(self) -> None: + """A custom `date_field` is used for sorting instead of `dateCreated`.""" + a = {"id": 1, "timestamp": 200} + b = {"id": 2, "timestamp": 100} + + result = merge_nodes([a, b], date_field="timestamp") + + assert [node["id"] for node in result] == [2, 1] + + +class TestRequireEntity: + """Tests for `_require_entity`.""" + + def test_returns_entity_when_present(self) -> None: + """A present, non-null entity is returned unchanged.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + result = ops._require_entity({"issue": {"id": 1}}, "issue", 1, "Issue") + + assert result == {"id": 1} + + def test_raises_when_field_missing(self) -> None: + """A response with no `field` key at all raises `GraphQLError`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="Issue 999 not found"): + ops._require_entity({}, "issue", 999, "Issue") + + def test_raises_when_field_is_null(self) -> None: + """A `null` entity (unknown or invisible id) raises `GraphQLError`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="Meeting 999999999 not found"): + ops._require_entity({"meeting": None}, "meeting", 999999999, "Meeting") + + +class TestRequireCreatedId: + """Tests for `_require_created_id`.""" + + def test_returns_id_when_present(self) -> None: + """A non-zero `id` is returned as an `int`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + assert ops._require_created_id({"id": 5265048}, label="goal") == 5265048 + + def test_raises_when_result_is_none(self) -> None: + """A `None` mutation result (e.g. a `null` `CreateGoal`) raises.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="create goal failed"): + ops._require_created_id(None, label="goal") + + def test_raises_when_id_is_zero(self) -> None: + """An `id` of `0` (e.g. a failed `CreateGoal` returning `IdModel(0)`) raises.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="create goal failed"): + ops._require_created_id({"id": 0}, label="goal") + + class TestCheckMutationResult: """Tests for `_check_mutation_result` (shared by `_run_mutation`).""" diff --git a/tests/test_v2_goal.py b/tests/test_v2_goal.py index dd8feb3..2d587e9 100644 --- a/tests/test_v2_goal.py +++ b/tests/test_v2_goal.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, Mock import pytest @@ -92,6 +92,15 @@ def test_details_with_null_assignee(self) -> None: assert result.owner is None + def test_details_raises_when_goal_is_null(self) -> None: + """`details()` raises `GraphQLError` when `goal` is `null` (unknown id).""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"goal": None}}) + + with pytest.raises(GraphQLError, match="Goal 999999999 not found"): + ops.details(999999999) + def test_list_by_meeting_default_where(self) -> None: """`list(meeting_id=...)` filters archived goals out by default.""" client = Mock() @@ -175,6 +184,42 @@ def test_create_defaults(self) -> None: assert "notesId" not in input_ assert "milestones" not in input_ + def test_create_default_due_date_is_90_days_from_today_utc(self) -> None: + """The default due date is 90 days from today, at 00:00 UTC. + + Not the local calendar date: `date.today()` can differ from the UTC + date near a day boundary. + """ + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.create(349524, "SDK v2 test goal", user_id=1305290) + + create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + due_date = datetime.fromtimestamp(create_variables["input"]["dueDate"], tz=UTC) + expected_date = (datetime.now(tz=UTC) + timedelta(days=90)).date() + assert due_date.date() == expected_date + assert due_date.time() == datetime.min.time() + + def test_create_raises_when_create_goal_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateGoal` returns id `0`. + + A failed `CreateGoal` returns `IdModel(0)` rather than a top-level + GraphQL error. + """ + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateGoal": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create goal failed"): + ops.create(349524, "SDK v2 test goal", user_id=1305290) + + assert client.post.call_count == 1 + def test_create_with_notes_calls_create_note_first(self) -> None: """`create(notes=...)` calls `CreateNote` before `CreateGoal`.""" client = Mock() @@ -460,6 +505,16 @@ async def test_details(self) -> None: assert result.notes == "some notes" assert len(result.milestones) == 1 + @pytest.mark.asyncio + async def test_details_raises_when_goal_is_null(self) -> None: + """`details()` raises `GraphQLError` when `goal` is `null`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"goal": None}}) + + with pytest.raises(GraphQLError, match="Goal 999999999 not found"): + await ops.details(999999999) + @pytest.mark.asyncio async def test_list_both_ids_raises(self) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" @@ -560,6 +615,18 @@ async def test_create_defaults_user_id_to_current_user(self) -> None: create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_variables["input"]["assignee"] == 1305290 + @pytest.mark.asyncio + async def test_create_raises_when_create_goal_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateGoal` returns id `0`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"CreateGoal": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create goal failed"): + await ops.create(349524, "SDK v2 test goal", user_id=1305290) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_create_with_notes_calls_create_note_first(self) -> None: """`create(notes=...)` calls `CreateNote` before `CreateGoal`.""" diff --git a/tests/test_v2_headline.py b/tests/test_v2_headline.py index 1cc1ca3..05d22dc 100644 --- a/tests/test_v2_headline.py +++ b/tests/test_v2_headline.py @@ -73,6 +73,15 @@ def test_details_with_null_assignee(self) -> None: assert result.owner is None + def test_details_raises_when_headline_is_null(self) -> None: + """`details()` raises `GraphQLError` when `headline` is `null` (unknown id).""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"headline": None}}) + + with pytest.raises(GraphQLError, match="Headline 999999999 not found"): + ops.details(999999999) + def test_list_both_meeting_and_user_raises(self) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" client = Mock() @@ -94,26 +103,56 @@ def test_list_by_meeting_open_only(self) -> None: assert len(result) == 1 assert client.post.call_count == 1 variables = client.post.call_args.kwargs["json"]["variables"] - assert variables == {"meetingId": 349524} + assert variables == {"meetingId": 349524, "includeArchived": False} + + def test_list_by_meeting_include_archived_merges_in_one_request(self) -> None: + """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`. - def test_list_by_meeting_include_archived_merges(self) -> None: - """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`.""" + In a single request: the combined query selects `archivedHeadlines` + conditionally via `@include(if: $includeArchived)`. + """ client = Mock() ops = HeadlineOperations(client, GRAPHQL_URL) archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} - client.post.side_effect = [ - _response({"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}}), - _response( - {"data": {"meeting": {"archivedHeadlines": {"nodes": [archived_node]}}}} - ), - ] + client.post.return_value = _response( + { + "data": { + "meeting": { + "headlines": {"nodes": [HEADLINE_NODE]}, + "archivedHeadlines": {"nodes": [archived_node]}, + } + } + } + ) result = ops.list(meeting_id=349524, include_archived=True) - assert client.post.call_count == 2 + assert client.post.call_count == 1 assert {headline.id for headline in result} == {987654, 999} - second_query = client.post.call_args_list[1].kwargs["json"]["query"] - assert "archivedHeadlines" in second_query + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"meetingId": 349524, "includeArchived": True} + + def test_list_by_meeting_dedupes_and_orders_by_creation_date(self) -> None: + """A node returned by both connections is deduped and re-sorted by date.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + newer = {**HEADLINE_NODE, "id": 3, "dateCreated": 300} + older = {**HEADLINE_NODE, "id": 1, "dateCreated": 100} + duplicate_of_older = {**HEADLINE_NODE, "id": 1, "dateCreated": 100} + client.post.return_value = _response( + { + "data": { + "meeting": { + "headlines": {"nodes": [newer, older]}, + "archivedHeadlines": {"nodes": [duplicate_of_older]}, + } + } + } + ) + + result = ops.list(meeting_id=349524, include_archived=True) + + assert [headline.id for headline in result] == [1, 3] def test_list_by_user_filters_archived_client_side(self) -> None: """`list(user_id=...)` drops archived headlines client-side by default. @@ -218,6 +257,17 @@ def test_create_defaults_user_id_to_current_user(self) -> None: create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_variables["input"]["assignee"] == 1305290 + def test_create_raises_when_create_headline_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateHeadline` returns id `0`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateHeadline": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create headline failed"): + ops.create(349524, "SDK v2 test headline", user_id=1305290) + + assert client.post.call_count == 1 + def test_update_no_fields_raises(self) -> None: """`update()` with no fields raises `ValueError`.""" client = Mock() @@ -507,10 +557,16 @@ def test_list_by_meeting_include_archived_when_archived_connection_is_null( """`include_archived=True` handles a `null` `archivedHeadlines` connection.""" client = Mock() ops = HeadlineOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}}), - _response({"data": {"meeting": {"archivedHeadlines": None}}}), - ] + client.post.return_value = _response( + { + "data": { + "meeting": { + "headlines": {"nodes": [HEADLINE_NODE]}, + "archivedHeadlines": None, + } + } + } + ) result = ops.list(meeting_id=349524, include_archived=True) @@ -646,6 +702,30 @@ async def test_details_with_null_assignee(self) -> None: assert result.owner is None + @pytest.mark.asyncio + async def test_details_raises_when_headline_is_null(self) -> None: + """`details()` raises `GraphQLError` when `headline` is `null`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"headline": None}}) + + with pytest.raises(GraphQLError, match="Headline 999999999 not found"): + await ops.details(999999999) + + @pytest.mark.asyncio + async def test_create_raises_when_create_headline_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateHeadline` returns id `0`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"CreateHeadline": {"id": 0}}} + ) + + with pytest.raises(GraphQLError, match="create headline failed"): + await ops.create(349524, "SDK v2 test headline", user_id=1305290) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_details_transforms_timestamps_to_utc_aware_datetimes(self) -> None: """`created_date`/`archived_date` become timezone-aware UTC datetimes.""" @@ -688,29 +768,37 @@ async def test_list_by_meeting_open_only(self) -> None: assert len(result) == 1 assert client.post.call_count == 1 variables = client.post.call_args.kwargs["json"]["variables"] - assert variables == {"meetingId": 349524} + assert variables == {"meetingId": 349524, "includeArchived": False} @pytest.mark.asyncio - async def test_list_by_meeting_include_archived_merges(self) -> None: - """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`.""" + async def test_list_by_meeting_include_archived_merges_in_one_request( + self, + ) -> None: + """`list(meeting_id=..., include_archived=True)` merges `archivedHeadlines`. + + In a single request: the combined query selects `archivedHeadlines` + conditionally via `@include(if: $includeArchived)`. + """ client = AsyncMock() ops = AsyncHeadlineOperations(client, GRAPHQL_URL) archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} - client.post.side_effect = [ - _async_response( - {"data": {"meeting": {"headlines": {"nodes": [HEADLINE_NODE]}}}} - ), - _async_response( - {"data": {"meeting": {"archivedHeadlines": {"nodes": [archived_node]}}}} - ), - ] + client.post.return_value = _async_response( + { + "data": { + "meeting": { + "headlines": {"nodes": [HEADLINE_NODE]}, + "archivedHeadlines": {"nodes": [archived_node]}, + } + } + } + ) result = await ops.list(meeting_id=349524, include_archived=True) - assert client.post.call_count == 2 + assert client.post.call_count == 1 assert {headline.id for headline in result} == {987654, 999} - second_query = client.post.call_args_list[1].kwargs["json"]["query"] - assert "archivedHeadlines" in second_query + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"meetingId": 349524, "includeArchived": True} @pytest.mark.asyncio async def test_list_by_meeting_returns_empty_when_meeting_is_null(self) -> None: diff --git a/tests/test_v2_issue.py b/tests/test_v2_issue.py index 6e58bd0..dca066b 100644 --- a/tests/test_v2_issue.py +++ b/tests/test_v2_issue.py @@ -70,6 +70,15 @@ def test_details_with_null_assignee(self) -> None: assert result.owner is None + def test_details_raises_when_issue_is_null(self) -> None: + """`details()` raises `GraphQLError` when `issue` is `null` (unknown id).""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"issue": None}}) + + with pytest.raises(GraphQLError, match="Issue 999999999 not found"): + ops.details(999999999) + @pytest.mark.parametrize( ("raw_archived", "long_term", "expected"), [ @@ -107,8 +116,11 @@ def test_list_short_term_default_where(self) -> None: result = ops.list(349524) assert len(result) == 1 + assert client.post.call_count == 1 variables = client.post.call_args.kwargs["json"]["variables"] assert variables["meetingId"] == 349524 + assert variables["includeSolved"] is False + assert variables["includeArchived"] is False assert variables["where"] == { "and": [ {"addToDepartmentPlan": {"eq": False}}, @@ -136,47 +148,75 @@ def test_list_long_term_uses_long_term_issues_connection(self) -> None: variables = client.post.call_args.kwargs["json"]["variables"] assert variables["where"] == {"and": [{"addToDepartmentPlan": {"eq": True}}]} - def test_list_include_solved_merges_and_dedupes(self) -> None: - """`include_solved=True` merges `recentlySolvedIssues`, deduping by id.""" + def test_list_include_solved_merges_in_one_request(self) -> None: + """`include_solved=True` merges `recentlySolvedIssues` in a single request.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"meeting": {"issues": {"nodes": [ISSUE_NODE]}}}}), - _response( - { - "data": { - "meeting": { - "recentlySolvedIssues": { - "nodes": [{**ISSUE_NODE, "completed": True}] - } - } + solved_node = {**ISSUE_NODE, "id": 999, "completed": True} + client.post.return_value = _response( + { + "data": { + "meeting": { + "issues": {"nodes": [ISSUE_NODE]}, + "recentlySolvedIssues": {"nodes": [solved_node]}, } } - ), - ] + } + ) result = ops.list(349524, include_solved=True) - assert len(result) == 1 - assert client.post.call_count == 2 + assert client.post.call_count == 1 + assert {issue.id for issue in result} == {ISSUE_NODE["id"], 999} + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["includeSolved"] is True + assert variables["includeArchived"] is False - def test_list_include_archived(self) -> None: - """`include_archived=True` merges `archivedIssues`.""" + def test_list_include_archived_merges_in_one_request(self) -> None: + """`include_archived=True` merges `archivedIssues` in a single request.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) archived_node = {**ISSUE_NODE, "id": 999, "archived": True} - client.post.side_effect = [ - _response({"data": {"meeting": {"issues": {"nodes": []}}}}), - _response( - {"data": {"meeting": {"archivedIssues": {"nodes": [archived_node]}}}} - ), - ] + client.post.return_value = _response( + { + "data": { + "meeting": { + "issues": {"nodes": []}, + "archivedIssues": {"nodes": [archived_node]}, + } + } + } + ) result = ops.list(349524, include_archived=True) + assert client.post.call_count == 1 assert len(result) == 1 assert result[0].id == 999 + def test_list_dedupes_and_orders_by_creation_date(self) -> None: + """A node returned by more than one connection is deduped and re-sorted.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + newer = {**ISSUE_NODE, "id": 3, "dateCreated": 300} + older = {**ISSUE_NODE, "id": 1, "dateCreated": 100} + duplicate_of_older = {**ISSUE_NODE, "id": 1, "dateCreated": 100} + client.post.return_value = _response( + { + "data": { + "meeting": { + "issues": {"nodes": [newer, older]}, + "recentlySolvedIssues": {"nodes": [duplicate_of_older]}, + "archivedIssues": {"nodes": []}, + } + } + } + ) + + result = ops.list(349524, include_solved=True, include_archived=True) + + assert [issue.id for issue in result] == [1, 3] + def test_create_without_notes(self) -> None: """`create()` without notes skips `CreateNote` and reads back details.""" client = Mock() @@ -232,6 +272,21 @@ def test_create_defaults_user_id_to_current_user(self) -> None: create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_variables["input"]["ownerId"] == 1305290 + def test_create_raises_when_create_issue_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateIssue` returns id `0`. + + Rather than re-reading `details(0)`, which would confusingly report + "Issue 0 not found". + """ + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateIssue": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create issue failed"): + ops.create(349524, "SDK v2 test issue", user_id=1305290) + + assert client.post.call_count == 1 + def test_update_no_fields_raises(self) -> None: """`update()` with no fields raises `ValueError`.""" client = Mock() @@ -358,6 +413,64 @@ async def test_details(self) -> None: assert result.id == 28471978 assert result.notes == "some notes" + @pytest.mark.asyncio + async def test_details_raises_when_issue_is_null(self) -> None: + """`details()` raises `GraphQLError` when `issue` is `null` (unknown id).""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"issue": None}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Issue 999999999 not found"): + await ops.details(999999999) + + @pytest.mark.asyncio + async def test_create_raises_when_create_issue_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateIssue` returns id `0`.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"CreateIssue": {"id": 0}}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="create issue failed"): + await ops.create(349524, "SDK v2 test issue", user_id=1305290) + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_list_include_solved_and_archived_merges_in_one_request( + self, + ) -> None: + """`list(include_solved=True, include_archived=True)` sends one request.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + solved_node = {**ISSUE_NODE, "id": 2, "completed": True} + archived_node = {**ISSUE_NODE, "id": 3, "archived": True} + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "data": { + "meeting": { + "issues": {"nodes": [ISSUE_NODE]}, + "recentlySolvedIssues": {"nodes": [solved_node]}, + "archivedIssues": {"nodes": [archived_node]}, + } + } + } + response.raise_for_status = MagicMock() + client.post.return_value = response + + result = await ops.list(349524, include_solved=True, include_archived=True) + + assert client.post.call_count == 1 + assert {issue.id for issue in result} == {ISSUE_NODE["id"], 2, 3} + @pytest.mark.asyncio async def test_update_no_fields_raises(self) -> None: """`update()` with no fields raises `ValueError`.""" diff --git a/tests/test_v2_meeting.py b/tests/test_v2_meeting.py index c760dcc..ccf10b8 100644 --- a/tests/test_v2_meeting.py +++ b/tests/test_v2_meeting.py @@ -6,6 +6,7 @@ import pytest +from bloomy.exceptions import GraphQLError from bloomy.v2.models import Meeting, MeetingListItem, User from bloomy.v2.operations.meeting import AsyncMeetingOperations, MeetingOperations @@ -106,6 +107,15 @@ def test_details_returns_meeting_with_attendees(self) -> None: assert isinstance(result.attendees[0], User) assert result.attendees[0].email == "fran@example.com" + def test_details_raises_when_meeting_is_null(self) -> None: + """`details()` raises `GraphQLError` when `meeting` is `null` (unknown id).""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + with pytest.raises(GraphQLError, match="Meeting 999999999 not found"): + ops.details(999999999) + def test_attendees_returns_users(self) -> None: """`attendees()` returns a flat list of `User` models.""" client = Mock() @@ -158,6 +168,20 @@ async def test_details_returns_meeting_with_attendees(self) -> None: assert isinstance(result, Meeting) assert len(result.attendees) == 1 + @pytest.mark.asyncio + async def test_details_raises_when_meeting_is_null(self) -> None: + """`details()` raises `GraphQLError` when `meeting` is `null`.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"meeting": None}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="Meeting 999999999 not found"): + await ops.details(999999999) + @pytest.mark.asyncio async def test_attendees_returns_users(self) -> None: """`attendees()` returns a flat list of `User` models.""" diff --git a/tests/test_v2_metric.py b/tests/test_v2_metric.py index f27bf08..86d417d 100644 --- a/tests/test_v2_metric.py +++ b/tests/test_v2_metric.py @@ -102,6 +102,15 @@ def test_details(self) -> None: assert result.goal == 100.0 assert result.notes is None + def test_details_raises_when_metric_is_null(self) -> None: + """`details()` raises `GraphQLError` when `metric` is `null` (unknown id).""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"metric": None}}) + + with pytest.raises(GraphQLError, match="Metric 999999999 not found"): + ops.details(999999999) + def test_list_meeting_filters_divider_and_sets_where(self) -> None: """`list(meeting_id=...)` filters DIVIDER rows and archived metrics.""" client = Mock() @@ -199,6 +208,17 @@ def test_create_defaults_user_id_to_current_user(self) -> None: create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_vars["input"]["assignee"] == 1305290 + def test_create_raises_when_create_metric_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateMetric` returns id `0`.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateMetric": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create metric failed"): + ops.create(349524, "SDK v2 test metric", user_id=1305290, goal=100) + + assert client.post.call_count == 1 + def test_create_with_notes_calls_create_note_first(self) -> None: """`create(notes=...)` calls `CreateNote` before `CreateMetric`.""" client = Mock() @@ -315,6 +335,24 @@ def test_scores_filters_empty_by_default(self) -> None: assert len(result) == 1 assert result[0].value == 123.0 + def test_scores_keeps_zero_value(self) -> None: + """A score whose raw `value` is `0` is not dropped as an empty placeholder. + + A truthiness check (`if node.get("value")`) would incorrectly treat a + legitimate `0` score the same as an empty placeholder (`""`/`None`). + """ + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + zero_node = {**SCORE_NODE, "id": 1, "value": 0} + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": [zero_node, EMPTY_SCORE_NODE]}}} + ) + + result = ops.scores(2036155) + + assert len(result) == 1 + assert result[0].value == 0.0 + def test_scores_include_empty(self) -> None: """`scores(include_empty=True)` keeps empty placeholder rows.""" client = Mock() @@ -693,6 +731,16 @@ async def test_details(self) -> None: assert result.id == 2036155 assert isinstance(result, Metric) + @pytest.mark.asyncio + async def test_details_raises_when_metric_is_null(self) -> None: + """`details()` raises `GraphQLError` when `metric` is `null`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"metric": None}}) + + with pytest.raises(GraphQLError, match="Metric 999999999 not found"): + await ops.details(999999999) + @pytest.mark.asyncio async def test_list_both_meeting_and_user_raises(self) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" @@ -784,6 +832,21 @@ async def test_scores_filters_empty_by_default(self) -> None: assert len(result) == 1 + @pytest.mark.asyncio + async def test_scores_keeps_zero_value(self) -> None: + """A score whose raw `value` is `0` is not dropped as an empty placeholder.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + zero_node = {**SCORE_NODE, "id": 1, "value": 0} + client.post.return_value = _async_response( + {"data": {"metric": {"scoresNonPaginated": [zero_node, EMPTY_SCORE_NODE]}}} + ) + + result = await ops.scores(2036155) + + assert len(result) == 1 + assert result[0].value == 0.0 + @pytest.mark.asyncio async def test_set_score_success(self) -> None: """`set_score()` creates and re-reads the score.""" @@ -971,6 +1034,20 @@ async def test_create_defaults_user_id_to_current_user(self) -> None: create_vars = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_vars["input"]["assignee"] == 1305290 + @pytest.mark.asyncio + async def test_create_raises_when_create_metric_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateMetric` returns id `0`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"CreateMetric": {"id": 0}}} + ) + + with pytest.raises(GraphQLError, match="create metric failed"): + await ops.create(349524, "SDK v2 test metric", user_id=1305290, goal=100) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_create_between_uses_min_max(self) -> None: """`create(rule=BETWEEN, min_goal=..., max_goal=...)` sends the range goal.""" diff --git a/tests/test_v2_milestone.py b/tests/test_v2_milestone.py index 431ff9a..e85731e 100644 --- a/tests/test_v2_milestone.py +++ b/tests/test_v2_milestone.py @@ -125,19 +125,34 @@ def test_create_due_date_accepts_datetime_object(self) -> None: create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert create_variables["input"]["dueDate"] == due_date.timestamp() + def test_create_raises_when_create_milestone_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateMilestone` returns id `0`.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateMilestone": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create milestone failed"): + ops.create(5265048, "SDK v2 test milestone", 1767225600) + + assert client.post.call_count == 1 + def test_update_no_fields_raises(self) -> None: - """`update()` with no fields raises `ValueError`.""" + """`update()` with no fields raises `ValueError`, before any request.""" client = Mock() ops = MilestoneOperations(client, GRAPHQL_URL) with pytest.raises(ValueError, match="At least one field"): - ops.update(6440354, 5265048) + ops.update(6440354, goal_id=5265048) + + assert client.post.call_count == 0 def test_update_due_date_and_completed(self) -> None: """`update(due_date=..., completed=...)` sends both without `title`.""" client = Mock() ops = MilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + # Membership verification, before anything is written. + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), _response({"data": {"EditMilestone": {"id": 6440354}}}), _response( { @@ -158,10 +173,13 @@ def test_update_due_date_and_completed(self) -> None: ), ] - result = ops.update(6440354, 5265048, due_date=1767312000, completed=True) + result = ops.update( + 6440354, goal_id=5265048, due_date=1767312000, completed=True + ) assert result.completed is True - edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert client.post.call_count == 3 + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert edit_variables == { "input": { "milestoneId": 6440354, @@ -171,10 +189,11 @@ def test_update_due_date_and_completed(self) -> None: } def test_update(self) -> None: - """`update()` sends only the provided fields and re-reads via `goal_id`.""" + """`update()` verifies membership, sends only the given fields, and re-reads.""" client = Mock() ops = MilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), _response({"data": {"EditMilestone": {"id": 6440354}}}), _response( { @@ -189,19 +208,39 @@ def test_update(self) -> None: ), ] - result = ops.update(6440354, 5265048, title="Updated") + result = ops.update(6440354, goal_id=5265048, title="Updated") assert result.title == "Updated" - edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + verify_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert verify_variables == {"goalId": 5265048, "milestoneId": 6440354} + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert edit_variables == {"input": {"milestoneId": 6440354, "title": "Updated"}} - get_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + get_variables = client.post.call_args_list[2].kwargs["json"]["variables"] assert get_variables == {"goalId": 5265048, "milestoneId": 6440354} + def test_update_wrong_goal_id_raises_without_writing(self) -> None: + """A `goal_id` that does not own `milestone_id` raises before any write. + + The membership check runs before `EditMilestone`, so a wrong + `goal_id` never sends the edit at all. + """ + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"goal": {"milestones": {"nodes": []}}}} + ) + + with pytest.raises(GraphQLError, match="not found"): + ops.update(6440354, goal_id=999999, title="Updated") + + assert client.post.call_count == 1 + def test_complete(self) -> None: - """`complete()` sends `completed: true`.""" + """`complete()` verifies membership, then sends `completed: true`.""" client = Mock() ops = MilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), _response({"data": {"EditMilestone": {"id": 6440354}}}), _response( { @@ -216,23 +255,42 @@ def test_complete(self) -> None: ), ] - result = ops.complete(6440354, 5265048) + result = ops.complete(6440354, goal_id=5265048) assert result.completed is True - edit_input = client.post.call_args_list[0].kwargs["json"]["variables"]["input"] + assert client.post.call_count == 3 + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] assert edit_input == {"milestoneId": 6440354, "completed": True} + def test_complete_wrong_goal_id_raises_without_writing(self) -> None: + """A `goal_id` that does not own `milestone_id` raises before any write.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"goal": {"milestones": {"nodes": []}}}} + ) + + with pytest.raises(GraphQLError, match="not found"): + ops.complete(6440354, goal_id=999999) + + assert client.post.call_count == 1 + def test_get_raises_when_not_found(self) -> None: - """A re-read that returns no nodes raises `GraphQLError`.""" + """If the milestone is gone by the final re-read, it raises. + + Edge case where the membership check passes but the milestone is + removed before the final re-read (e.g. a concurrent delete). + """ client = Mock() ops = MilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), _response({"data": {"EditMilestone": {"id": 6440354}}}), _response({"data": {"goal": {"milestones": {"nodes": []}}}}), ] with pytest.raises(GraphQLError, match="not found"): - ops.complete(6440354, 5265048) + ops.complete(6440354, goal_id=5265048) def test_delete(self) -> None: """`delete()` calls `DeleteMilestone` and checks its success result.""" @@ -309,21 +367,40 @@ async def test_create(self) -> None: } } + @pytest.mark.asyncio + async def test_create_raises_when_create_milestone_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateMilestone` returns id `0`.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"CreateMilestone": {"id": 0}}} + ) + + with pytest.raises(GraphQLError, match="create milestone failed"): + await ops.create(5265048, "SDK v2 test milestone", 1767225600) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_update_no_fields_raises(self) -> None: - """`update()` with no fields raises `ValueError`.""" + """`update()` with no fields raises `ValueError`, before any request.""" client = AsyncMock() ops = AsyncMilestoneOperations(client, GRAPHQL_URL) with pytest.raises(ValueError, match="At least one field"): - await ops.update(6440354, 5265048) + await ops.update(6440354, goal_id=5265048) + + assert client.post.call_count == 0 @pytest.mark.asyncio async def test_update(self) -> None: - """`update()` sends only the provided fields and re-reads via `goal_id`.""" + """`update()` verifies membership, sends only the given fields, and re-reads.""" client = AsyncMock() ops = AsyncMilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _async_response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ), _async_response({"data": {"EditMilestone": {"id": 6440354}}}), _async_response( { @@ -346,12 +423,17 @@ async def test_update(self) -> None: ] result = await ops.update( - 6440354, 5265048, title="Updated", due_date=1767312000, completed=True + 6440354, + goal_id=5265048, + title="Updated", + due_date=1767312000, + completed=True, ) assert result.title == "Updated" assert result.completed is True - edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert client.post.call_count == 3 + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert edit_variables == { "input": { "milestoneId": 6440354, @@ -361,25 +443,45 @@ async def test_update(self) -> None: } } + @pytest.mark.asyncio + async def test_update_wrong_goal_id_raises_without_writing(self) -> None: + """A `goal_id` that does not own `milestone_id` raises before any write.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"goal": {"milestones": {"nodes": []}}}} + ) + + with pytest.raises(GraphQLError, match="not found"): + await ops.update(6440354, goal_id=999999, title="Updated") + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_get_raises_when_not_found(self) -> None: - """A re-read that returns no nodes raises `GraphQLError`.""" + """If the milestone is gone by the final re-read, it raises.""" client = AsyncMock() ops = AsyncMilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _async_response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ), _async_response({"data": {"EditMilestone": {"id": 6440354}}}), _async_response({"data": {"goal": {"milestones": {"nodes": []}}}}), ] with pytest.raises(GraphQLError, match="not found"): - await ops.complete(6440354, 5265048) + await ops.complete(6440354, goal_id=5265048) @pytest.mark.asyncio async def test_complete(self) -> None: - """`complete()` sends `completed: true` and re-reads via `goal_id`.""" + """`complete()` verifies membership, sends `completed: true`, and re-reads.""" client = AsyncMock() ops = AsyncMilestoneOperations(client, GRAPHQL_URL) client.post.side_effect = [ + _async_response( + {"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}} + ), _async_response({"data": {"EditMilestone": {"id": 6440354}}}), _async_response( { @@ -394,9 +496,24 @@ async def test_complete(self) -> None: ), ] - result = await ops.complete(6440354, 5265048) + result = await ops.complete(6440354, goal_id=5265048) assert result.completed is True + assert client.post.call_count == 3 + + @pytest.mark.asyncio + async def test_complete_wrong_goal_id_raises_without_writing(self) -> None: + """A `goal_id` that does not own `milestone_id` raises before any write.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"goal": {"milestones": {"nodes": []}}}} + ) + + with pytest.raises(GraphQLError, match="not found"): + await ops.complete(6440354, goal_id=999999) + + assert client.post.call_count == 1 @pytest.mark.asyncio async def test_delete(self) -> None: diff --git a/tests/test_v2_todo.py b/tests/test_v2_todo.py index fc894d9..0660d12 100644 --- a/tests/test_v2_todo.py +++ b/tests/test_v2_todo.py @@ -121,6 +121,15 @@ def test_details_raises_graphql_error_on_http_400(self) -> None: ops.details(555001) assert exc_info.value.status_code == 400 + def test_details_raises_when_todo_is_null(self) -> None: + """`details()` raises `GraphQLError` when `todo` is `null` (unknown id).""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"todo": None}}) + + with pytest.raises(GraphQLError, match="Todo 999999999 not found"): + ops.details(999999999) + def test_list_both_meeting_and_user_raises(self) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" client = Mock() @@ -294,6 +303,17 @@ def test_create_personal_todo_sends_null_meeting_id(self) -> None: create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert create_variables["input"]["meetingRecurrenceId"] is None + def test_create_raises_when_create_todo_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateTodo` returns id `0`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateTodo": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create todo failed"): + ops.create("SDK v2 test to-do", user_id=1305290) + + assert client.post.call_count == 1 + def test_create_with_explicit_due_date(self) -> None: """`create(due_date=...)` converts the given date to a unix timestamp.""" client = Mock() @@ -628,6 +648,16 @@ async def test_details_with_null_meeting(self) -> None: assert result.meeting is None + @pytest.mark.asyncio + async def test_details_raises_when_todo_is_null(self) -> None: + """`details()` raises `GraphQLError` when `todo` is `null`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"todo": None}}) + + with pytest.raises(GraphQLError, match="Todo 999999999 not found"): + await ops.details(999999999) + @pytest.mark.asyncio async def test_details_datetime_fields_are_utc_aware(self) -> None: """Timestamp fields convert into timezone-aware UTC datetimes.""" @@ -804,6 +834,18 @@ async def test_create_personal_todo_sends_null_meeting_id(self) -> None: create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert create_variables["input"]["meetingRecurrenceId"] is None + @pytest.mark.asyncio + async def test_create_raises_when_create_todo_id_is_zero(self) -> None: + """`create()` raises `GraphQLError` when `CreateTodo` returns id `0`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"CreateTodo": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create todo failed"): + await ops.create("SDK v2 test to-do", user_id=1305290) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_create_with_explicit_due_date(self) -> None: """`create(due_date=...)` converts the given date to a unix timestamp.""" diff --git a/tests/test_v2_user.py b/tests/test_v2_user.py index 1da4a16..266c82f 100644 --- a/tests/test_v2_user.py +++ b/tests/test_v2_user.py @@ -6,6 +6,7 @@ import pytest +from bloomy.exceptions import GraphQLError from bloomy.v2.models import User from bloomy.v2.operations.user import AsyncUserOperations, UserOperations @@ -64,6 +65,15 @@ def test_details_explicit_user_id(self) -> None: assert result.id == 1305290 client.post.assert_called_once() + def test_details_raises_when_user_is_null(self) -> None: + """`details()` raises `GraphQLError` when `user` is `null` (unknown id).""" + client = Mock() + ops = UserOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"user": None}}) + + with pytest.raises(GraphQLError, match="User 999999999 not found"): + ops.details(user_id=999999999) + def test_list_returns_all_users(self) -> None: """`list()` returns every user from the `users` connection.""" client = Mock() @@ -107,6 +117,20 @@ async def test_details_defaults_to_current_user(self) -> None: assert isinstance(result, User) assert result.id == 1305290 + @pytest.mark.asyncio + async def test_details_raises_when_user_is_null(self) -> None: + """`details()` raises `GraphQLError` when `user` is `null`.""" + client = AsyncMock() + ops = AsyncUserOperations(client, GRAPHQL_URL) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"data": {"user": None}} + response.raise_for_status = MagicMock() + client.post.return_value = response + + with pytest.raises(GraphQLError, match="User 999999999 not found"): + await ops.details(user_id=999999999) + @pytest.mark.asyncio async def test_list_returns_all_users(self) -> None: """`list()` returns every user from the `users` connection.""" From d64e6b178af06e85052f9cd98742c0fca4c78f1a Mon Sep 17 00:00:00 2001 From: Franccesco Orozco Date: Thu, 24 Sep 2026 14:50:52 -0600 Subject: [PATCH 3/4] chore: run project subagents on opus Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/agents/api-feature-developer.md | 2 +- .claude/agents/code-quality-reviewer.md | 2 +- .claude/agents/mkdocs-documentation-writer.md | 2 +- .claude/agents/sdk-test-engineer.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/agents/api-feature-developer.md b/.claude/agents/api-feature-developer.md index 4455258..cba814b 100644 --- a/.claude/agents/api-feature-developer.md +++ b/.claude/agents/api-feature-developer.md @@ -2,7 +2,7 @@ name: api-feature-developer description: SDK API feature development specialist. Use PROACTIVELY when implementing new API endpoints, SDK operations, or adding new resource types. MUST BE USED for all new feature implementation work. tools: Read, Edit, Write, Bash, Grep, Glob -model: sonnet +model: opus --- You are an expert Python SDK developer specializing in building the Bloomy Growth API SDK. You have deep knowledge of the codebase patterns and conventions. diff --git a/.claude/agents/code-quality-reviewer.md b/.claude/agents/code-quality-reviewer.md index 7f5792f..1add8d6 100644 --- a/.claude/agents/code-quality-reviewer.md +++ b/.claude/agents/code-quality-reviewer.md @@ -2,7 +2,7 @@ name: code-quality-reviewer description: Code quality and review specialist. Use PROACTIVELY after code changes to run quality gates and review for issues. MUST BE USED before creating PRs or after significant changes. tools: Read, Bash, Grep, Glob, Edit -model: sonnet +model: opus --- You are a senior code reviewer ensuring the Bloomy Python SDK maintains high standards of quality, type safety, and consistency. diff --git a/.claude/agents/mkdocs-documentation-writer.md b/.claude/agents/mkdocs-documentation-writer.md index 5f5ce9c..1148ccb 100644 --- a/.claude/agents/mkdocs-documentation-writer.md +++ b/.claude/agents/mkdocs-documentation-writer.md @@ -2,7 +2,7 @@ name: mkdocs-documentation-writer description: Documentation specialist for MkDocs. Use PROACTIVELY when writing guides, updating README, creating API documentation, or documenting new features. MUST BE USED for all documentation tasks. tools: Read, Write, Edit, Glob, Bash -model: sonnet +model: opus --- You are an expert technical writer specializing in Python SDK documentation using MkDocs with Material theme. diff --git a/.claude/agents/sdk-test-engineer.md b/.claude/agents/sdk-test-engineer.md index 9f755a1..e75745f 100644 --- a/.claude/agents/sdk-test-engineer.md +++ b/.claude/agents/sdk-test-engineer.md @@ -2,7 +2,7 @@ name: sdk-test-engineer description: Testing and debugging specialist for the SDK. Use PROACTIVELY to write tests, run test suites, fix failures, and improve coverage. MUST BE USED for all testing work. tools: Read, Write, Edit, Bash, Grep, Glob -model: sonnet +model: opus --- You are a test automation expert specializing in Python SDK testing with pytest. From d103f2ba036c79ec6dfeb524c6406dfdf4ef575b Mon Sep 17 00:00:00 2001 From: Franccesco Orozco Date: Thu, 24 Sep 2026 15:35:54 -0600 Subject: [PATCH 4/4] refactor: simplify the v2 package and cut redundant requests Shared request building and result checks live in the base and mixins (_mutate, _one, _notes_input, input builders), models validate raw GraphQL nodes directly, and one user-id cache is shared per client. Merged issue lists, to-do lists by meeting, and user headline lists filter on the server in one request; issue and to-do creates use the mutation result instead of re-reading. v2 date parameters also accept ISO 8601 strings. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 + CLAUDE.md | 7 +- docs/guide/v2-graphql-api.md | 28 +- src/bloomy/async_client.py | 12 +- src/bloomy/client.py | 6 +- src/bloomy/v2/__init__.py | 59 +- src/bloomy/v2/base.py | 502 ++++++------ src/bloomy/v2/models.py | 182 +++-- src/bloomy/v2/operations/goal.py | 475 ++++++------ src/bloomy/v2/operations/headline.py | 291 +++---- src/bloomy/v2/operations/issue.py | 432 +++++------ src/bloomy/v2/operations/meeting.py | 114 +-- src/bloomy/v2/operations/metric.py | 820 ++++++++++---------- src/bloomy/v2/operations/milestone.py | 290 ++++--- src/bloomy/v2/operations/todo.py | 430 +++++----- src/bloomy/v2/operations/user.py | 32 +- tests/test_integration_v2_goal_milestone.py | 4 +- tests/test_integration_v2_headline.py | 12 +- tests/test_integration_v2_metric.py | 4 +- tests/test_integration_v2_todo.py | 49 +- tests/test_v2_base.py | 363 +++++++-- tests/test_v2_client_wiring.py | 57 +- tests/test_v2_goal.py | 103 ++- tests/test_v2_headline.py | 153 +++- tests/test_v2_issue.py | 281 +++++-- tests/test_v2_meeting.py | 83 ++ tests/test_v2_metric.py | 330 +++++++- tests/test_v2_milestone.py | 26 +- tests/test_v2_models.py | 196 +++++ tests/test_v2_todo.py | 216 ++++-- tests/test_v2_user.py | 20 + 31 files changed, 3399 insertions(+), 2188 deletions(-) create mode 100644 tests/test_v2_models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c2994..2ad28d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `client.v1` and `client.v2` API paths on `Client` and `AsyncClient`. `client.v1` holds the existing REST v1 operations; the top-level attributes (`client.user`, `client.issue`, ...) remain aliases of the same objects. +- `client.v2`: operations backed by Bloom Growth's GraphQL API (the API the web app uses), with sync and async classes for users, meetings, issues, headlines, to-dos, goals, milestones, and metrics with scores. It uses the same API key as v1. +- v2 descriptions: `notes` on create/update is stored as a note pad (as the web app does) and read back as plain text. +- v2 metric scores: `scores()`, `set_score()` (upsert per week/month/quarter; daily scores are edited in place), `update_score()` (value and cell note), and `clear_score()`. +- `graphql_url` parameter on `Client` and `AsyncClient` (defaults to the host of `base_url` plus `/graphql/`). +- `GraphQLError` exception (subclass of `APIError`) with the raw GraphQL `errors`. +- v2 pydantic models in `bloomy.v2.models`, with timezone-aware UTC datetimes; v2 date parameters accept `datetime`, `date`, unix seconds, or ISO 8601 strings. + ## [0.21.0] - 2026-01-09 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 9a2593d..fcfdef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,9 +71,10 @@ The Bloomy Python SDK is organized as a client-based architecture where all API 4. **v2 (GraphQL) Operations Pattern (`src/bloomy/v2/`)**: - `client.v2` is the GraphQL namespace, separate from `client.v1`'s REST namespace above; it covers the same entities (users, meetings, issues, headlines, todos, goals, milestones, metrics) through GraphQL documents instead of REST calls - - Transport and shared helpers (request/response handling, error checking, timestamp conversion, notes handling) live in `src/bloomy/v2/base.py`; models live in `src/bloomy/v2/models.py` - - Unlike v1, each entity's GraphQL documents, response transforms, and sync/async operations classes live together in one module under `src/bloomy/v2/operations/` (e.g. `operations/issue.py` holds `IssueOperationsMixin`, `IssueOperations`, and `AsyncIssueOperations` side by side), instead of v1's separate `operations/`, `operations/async_/`, and `operations/mixins/` directories - - Each entity module's mixin (`OperationsMixin`) holds the GraphQL query/mutation strings and `_transform_*` methods; both the sync and async operations classes inherit from it alongside their respective base class (`GraphQLOperations`/`AsyncGraphQLOperations`) + - Transport and shared helpers live in `src/bloomy/v2/base.py`: module-level functions (`to_timestamp`, `default_due_date`, `compact`, `dig_nodes`, `merge_nodes`), shared field constants (`NOTES_FIELDS`, `USER_REF_FIELDS`, `MEETING_REF_FIELDS`, `MILESTONES_CONNECTION`), and methods on the base classes: `_mutate` (runs a mutation and checks its result), `_one` (reads one entity or raises "not found"), and `_notes_input` (creates the notes pad for a create/edit input). `AbstractGraphQLOperations` subclasses v1's `AbstractOperations`, so meeting_id/user_id checks use `_validate_mutual_exclusion`. One `UserIdCache` per client shares the current user's id across all v2 operations objects + - Models live in `src/bloomy/v2/models.py`. They inherit `GqlBaseModel`, which validates the raw camelCase keys (field names also work, so `model_dump()` round-trips) and fills `notes` from the notes fields, so operations return `Model.model_validate(raw_node)` with no per-entity transform code + - Unlike v1, each entity's GraphQL documents, input builders, and sync/async operations classes live together in one module under `src/bloomy/v2/operations/` (e.g. `operations/issue.py` holds `IssueOperationsMixin`, `IssueOperations`, and `AsyncIssueOperations` side by side), instead of v1's separate `operations/`, `operations/async_/`, and `operations/mixins/` directories + - Each entity module's mixin (`OperationsMixin`) holds the GraphQL documents and static builders such as `_todo_list_request`, `_todos_from`, `_todo_create_input`, and `_todo_edit_fields`; the sync and async classes, which inherit it alongside `GraphQLOperations`/`AsyncGraphQLOperations`, only execute and return. Updates and state changes (archive/restore/solve/complete) go through a per-class `_edit(entity_id, fields, *, action)` helper that runs the edit mutation and re-reads the entity (milestones instead delegate `complete()` to `update()`) - Operations are accessed via `client.v2.user`, `client.v2.issue`, etc. 5. **Models (`src/bloomy/models.py`)**: diff --git a/docs/guide/v2-graphql-api.md b/docs/guide/v2-graphql-api.md index c9491ee..0c9cf23 100644 --- a/docs/guide/v2-graphql-api.md +++ b/docs/guide/v2-graphql-api.md @@ -156,7 +156,7 @@ return-type documentation lives on each entity's API reference page. | Method | Signature | Description | |---|---|---| | `details()` | `details(headline_id) -> Headline` | Get a headline. | -| `list()` | `list(meeting_id=None, user_id=None, include_archived=False) -> list[Headline]` | List headlines for a meeting or a user (mutually exclusive). | +| `list()` | `list(meeting_id=None, user_id=None, *, include_archived=False) -> list[Headline]` | List headlines for a meeting or a user (mutually exclusive). | | `create()` | `create(meeting_id, title, user_id=None, notes=None) -> Headline` | Create a headline. | | `update()` | `update(headline_id, *, title=None, user_id=None, notes=None) -> Headline` | Update a headline. | | `archive()` | `archive(headline_id) -> Headline` | Archive a headline. | @@ -201,15 +201,27 @@ return-type documentation lives on each entity's API reference page. | Method | Signature | Description | |---|---|---| | `details()` | `details(metric_id) -> Metric` | Get a metric. | -| `list()` | `list(meeting_id=None, user_id=None, frequency=None) -> list[Metric]` | List metrics for a meeting or a user (mutually exclusive). | +| `list()` | `list(meeting_id=None, user_id=None, *, frequency=None) -> list[Metric]` | List metrics for a meeting or a user (mutually exclusive). | | `create()` | `create(meeting_id, title, user_id=None, goal=None, units=MetricUnit.NONE, rule=MetricRule.GREATER_THAN, frequency=MetricFrequency.WEEKLY, min_goal=None, max_goal=None, notes=None) -> Metric` | Create a metric. | | `update()` | `update(metric_id, *, title=None, user_id=None, goal=None, min_goal=None, max_goal=None, units=None, rule=None, notes=None) -> Metric` | Update a metric. | | `archive()` | `archive(metric_id) -> Metric` | Archive a metric. There is no `restore()`. | -| `scores()` | `scores(metric_id, start=None, end=None, include_empty=False) -> list[MetricScore]` | List a metric's scores, most recent first. | +| `scores()` | `scores(metric_id, *, start=None, end=None, include_empty=False) -> list[MetricScore]` | List a metric's scores, most recent first. | | `set_score()` | `set_score(metric_id, value, timestamp) -> MetricScore` | Set a score for a given time. | | `update_score()` | `update_score(metric_id, score_id, *, value=None, notes=None) -> MetricScore` | Update an existing score's value and/or note. | | `clear_score()` | `clear_score(metric_id, score_id) -> MetricScore` | Clear a score's value, leaving an empty placeholder. | +## Dates and times + +Parameters such as `due_date`, `timestamp`, `start`, and `end` accept any of: + +- a `datetime` (a naive `datetime` is treated as UTC), +- a `date` (00:00 UTC of that day), +- unix seconds as an `int` or `float`, +- an ISO 8601 string, such as `"2026-09-21"` or `"2026-09-21T10:30:00+02:00"`. + +Date fields on v2 models (`due_date`, `created_date`, `completed_date`, and so +on) are timezone-aware UTC `datetime` objects. + ## Description ("notes") behavior Every entity with a `notes` field (`Issue`, `Headline`, `Todo`, `Goal`, @@ -312,14 +324,16 @@ A `GraphQLError` is raised for: - A mutation result shaped `{success, message, errorDetails}` where `success` is `false` (used by, for example, `issue.update()` and `milestone.delete()`). +- A mutation that returns a `null` result, or an `{id}` result whose id is + `0`. !!! note "Some v2 mutations return only `{id}`" A few mutations (`headline`/`goal`/`milestone`/`metric` create and edit) return only `IdModel { id }` rather than the - `{success, message, errorDetails}` shape. For those, failures surface - exclusively through the top-level `errors` array — there is no - `success: false` body to check. This is transparent to callers: either - way, a failed write raises `GraphQLError`. + `{success, message, errorDetails}` shape. There is no `success: false` + body to check, so the SDK treats a top-level `errors` array, a `null` + result, or an id of `0` as a failure. Either way, a failed write raises + `GraphQLError`. ```python from bloomy import Client, GraphQLError diff --git a/src/bloomy/async_client.py b/src/bloomy/async_client.py index 662b3f5..c32e643 100644 --- a/src/bloomy/async_client.py +++ b/src/bloomy/async_client.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Self -from urllib.parse import urlsplit import httpx @@ -94,9 +93,12 @@ def __init__( "environment variable, or in ~/.bloomy/config.yaml configuration file." ) + # Lazy imports to avoid circular dependencies + from .v1 import AsyncV1 + from .v2 import AsyncV2, default_graphql_url + if graphql_url is None: - parsed = urlsplit(base_url) - graphql_url = f"{parsed.scheme}://{parsed.netloc}/graphql/" + graphql_url = default_graphql_url(base_url) self._graphql_url = graphql_url self._client = httpx.AsyncClient( @@ -108,10 +110,6 @@ def __init__( timeout=timeout, ) - # Lazy imports to avoid circular dependencies - from .v1 import AsyncV1 - from .v2 import AsyncV2 - # Initialize the v1 (REST) and v2 (GraphQL) operation namespaces, # sharing the same httpx client (v2 requests pass an absolute URL, # which overrides the client's base_url). diff --git a/src/bloomy/client.py b/src/bloomy/client.py index 994bb70..5f94171 100644 --- a/src/bloomy/client.py +++ b/src/bloomy/client.py @@ -3,14 +3,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Self -from urllib.parse import urlsplit import httpx from .configuration import Configuration from .exceptions import ConfigurationError from .v1 import V1 -from .v2 import V2 +from .v2 import V2, default_graphql_url if TYPE_CHECKING: from typing import Any @@ -83,8 +82,7 @@ def __init__( self._base_url = base_url if graphql_url is None: - parsed = urlsplit(base_url) - graphql_url = f"{parsed.scheme}://{parsed.netloc}/graphql/" + graphql_url = default_graphql_url(base_url) self._graphql_url = graphql_url # Initialize HTTP client diff --git a/src/bloomy/v2/__init__.py b/src/bloomy/v2/__init__.py index 1499a96..54c3e95 100644 --- a/src/bloomy/v2/__init__.py +++ b/src/bloomy/v2/__init__.py @@ -1,15 +1,16 @@ """v2: the Bloom Growth GraphQL API. Exposed as `client.v2` / `AsyncClient().v2`. See `bloomy.v2.base` for the -transport and shared helper conventions, and `bloomy.v2.models` for the -model conventions (owner/related-entity representation, datetime handling, -notes) that every entity added here follows. +transport and shared helpers, and `bloomy.v2.models` for the model +conventions every entity here follows. """ from __future__ import annotations from typing import TYPE_CHECKING +from urllib.parse import urlsplit +from .base import UserIdCache from .models import ( Goal, GoalStatus, @@ -51,10 +52,25 @@ import httpx +def default_graphql_url(base_url: str) -> str: + """Derive the GraphQL endpoint from the REST base URL. + + Args: + base_url: The v1 REST base URL (e.g. `https://app.bloomgrowth.com/api/v1`). + + Returns: + The scheme and host of `base_url` plus `/graphql/`. + + """ + parsed = urlsplit(base_url) + return f"{parsed.scheme}://{parsed.netloc}/graphql/" + + class V2: """Namespace for the Bloom Growth GraphQL API (v2) operations. Instantiated once by `bloomy.client.Client` and exposed as `client.v2`. + Every operations object shares one cached current-user id. """ def __init__(self, client: httpx.Client, graphql_url: str) -> None: @@ -65,21 +81,22 @@ def __init__(self, client: httpx.Client, graphql_url: str) -> None: graphql_url: The absolute URL of the GraphQL endpoint. """ - self.user = UserOperations(client, graphql_url) - self.meeting = MeetingOperations(client, graphql_url) - self.issue = IssueOperations(client, graphql_url) - self.headline = HeadlineOperations(client, graphql_url) - self.todo = TodoOperations(client, graphql_url) - self.goal = GoalOperations(client, graphql_url) - self.milestone = MilestoneOperations(client, graphql_url) - self.metric = MetricOperations(client, graphql_url) + args = (client, graphql_url, UserIdCache()) + self.user = UserOperations(*args) + self.meeting = MeetingOperations(*args) + self.issue = IssueOperations(*args) + self.headline = HeadlineOperations(*args) + self.todo = TodoOperations(*args) + self.goal = GoalOperations(*args) + self.milestone = MilestoneOperations(*args) + self.metric = MetricOperations(*args) class AsyncV2: """Async namespace for the Bloom Growth GraphQL API (v2) operations. Instantiated once by `bloomy.async_client.AsyncClient` and exposed as - `client.v2`. + `client.v2`. Every operations object shares one cached current-user id. """ def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: @@ -90,14 +107,15 @@ def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: graphql_url: The absolute URL of the GraphQL endpoint. """ - self.user = AsyncUserOperations(client, graphql_url) - self.meeting = AsyncMeetingOperations(client, graphql_url) - self.issue = AsyncIssueOperations(client, graphql_url) - self.headline = AsyncHeadlineOperations(client, graphql_url) - self.todo = AsyncTodoOperations(client, graphql_url) - self.goal = AsyncGoalOperations(client, graphql_url) - self.milestone = AsyncMilestoneOperations(client, graphql_url) - self.metric = AsyncMetricOperations(client, graphql_url) + args = (client, graphql_url, UserIdCache()) + self.user = AsyncUserOperations(*args) + self.meeting = AsyncMeetingOperations(*args) + self.issue = AsyncIssueOperations(*args) + self.headline = AsyncHeadlineOperations(*args) + self.todo = AsyncTodoOperations(*args) + self.goal = AsyncGoalOperations(*args) + self.milestone = AsyncMilestoneOperations(*args) + self.metric = AsyncMetricOperations(*args) __all__ = [ @@ -119,4 +137,5 @@ def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: "Todo", "User", "UserRef", + "default_graphql_url", ] diff --git a/src/bloomy/v2/base.py b/src/bloomy/v2/base.py index 56a1389..1b0a3f4 100644 --- a/src/bloomy/v2/base.py +++ b/src/bloomy/v2/base.py @@ -1,52 +1,50 @@ """Transport and shared helpers for the Bloomy v2 (GraphQL) API. -Every v2 entity module builds on the classes in this file: `GraphQLOperations` -for sync operations, `AsyncGraphQLOperations` for async. Both execute -`{query, variables}` documents against the GraphQL endpoint over the SAME -httpx client instance the v1 REST operations use (an absolute URL passed to -`client.post()` overrides the client's `base_url`), and share the error -handling, timestamp conversion, and description ("notes") helpers defined on -`AbstractGraphQLOperations`. - -Error handling contract (see `bloomy.exceptions.GraphQLError`): - - A response body carrying a top-level `errors` array raises - `GraphQLError`, regardless of HTTP status (validation errors return - HTTP 400 with `errors`; execution errors return HTTP 200 with - `errors` alongside `data`). - - A response that fails to parse as JSON (e.g. a plain-text 401/500) - falls back to `response.raise_for_status()`. - - A response that parses as JSON without an `errors` key still goes - through `response.raise_for_status()` before its `data` is returned, - so a JSON error body with no `errors` array (or an expired-token 401 - with an empty body) still raises a clear `httpx.HTTPStatusError` - instead of failing deep inside `data` access. - - Mutations shaped `{success message errorDetails{message}}` (the - `GraphQLResponseOfBoolean`/`OfString`/`Base` family) raise - `GraphQLError` when `success` is `false`, via `_check_mutation_result`. - - A by-id query returning `null` for the requested entity (unknown or - not visible to the caller), and a `Create*` mutation returning a - `null` result or an `id` of `0`, both raise `GraphQLError` via - `_require_entity`/`_require_created_id` rather than failing deep - inside a model/transform call. +`GraphQLOperations` (sync) and `AsyncGraphQLOperations` (async) post +`{query, variables}` documents over the same httpx client the v1 REST +operations use: an absolute URL passed to `client.post()` overrides the +client's `base_url`. The module-level helpers carry no state, so entity mixins +(shared by the sync and async classes) and the models can call them directly. """ from __future__ import annotations +import asyncio import html import re -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta from typing import TYPE_CHECKING, Any, cast from ..exceptions import GraphQLError +from ..utils.abstract_operations import AbstractOperations if TYPE_CHECKING: import httpx _TAG_RE = re.compile(r"<[^>]+>") -#: Fields shared by every `{success message errorDetails{message}}` mutation -#: result (the `GraphQLResponseOfBoolean` / `OfString` / `Base` family). +#: Fields of every `{success message errorDetails{message}}` mutation result. MUTATION_RESULT_FIELDS = "success message errorDetails { message }" +#: Description ("notes") fields read by `extract_notes`. +NOTES_FIELDS = "notesId notesText localHtml collaborationEnabled" +#: An entity's owner, validated into `UserRef`. +USER_REF_FIELDS = "assignee { id fullName }" +#: An entity's meeting, validated into `MeetingRef`. +MEETING_REF_FIELDS = "meeting { id name }" +#: Fields of a goal milestone, validated into `Milestone`. +MILESTONE_FIELDS = "id goalId title dueDate completed status dateCreated" +#: A goal's `milestones` connection, read by both goal and milestone queries. +#: The API already drops soft-deleted milestones; the filter matches the web app. +MILESTONES_CONNECTION = f""" +milestones( + where: {{ and: [{{ dateDeleted: {{ eq: null }} }}] }} + order: [{{ dueDate: ASC }}] +) {{ + nodes {{ + {MILESTONE_FIELDS} + }} +}} +""" _CREATE_NOTE_MUTATION = f""" mutation($text: String) {{ @@ -60,6 +58,84 @@ _GET_AUTHENTICATED_USER_ID_MUTATION = "mutation { getAuthenticatedUserId { id } }" +#: Accepted input for dates and times: a datetime (naive means UTC), a date +#: (00:00 UTC), unix seconds, or an ISO 8601 string such as "2026-09-21". +type TimeInput = datetime | date | float | int | str + + +def to_utc_datetime(value: TimeInput) -> datetime: + """Convert a `TimeInput` into an aware UTC datetime. + + A naive `datetime` (or an ISO string without an offset) is treated as UTC. + A `date` (or a date-only ISO string) becomes 00:00 UTC of that day. + + Args: + value: The value to convert. + + Returns: + A timezone-aware `datetime`. + + """ + if isinstance(value, str): + value = datetime.fromisoformat(value) + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=UTC) + return datetime.fromtimestamp(float(value), tz=UTC) + + +def to_timestamp(value: TimeInput) -> float: + """Convert a datetime, date, or number into unix seconds, as the API expects. + + Args: + value: The value to convert (see `to_utc_datetime`). + + Returns: + Unix seconds as a float. + + """ + if isinstance(value, int | float): + return float(value) + return to_utc_datetime(value).timestamp() + + +def now_timestamp() -> float: + """Return the current time as unix seconds. + + Returns: + Unix seconds as a float. + + """ + return datetime.now(tz=UTC).timestamp() + + +def default_due_date(days: int) -> date: + """Return the date `days` days after today's UTC date. + + Args: + days: The number of days to add. + + Returns: + The due date (sent as 00:00 UTC by `to_timestamp`). + + """ + return datetime.now(tz=UTC).date() + timedelta(days=days) + + +def compact(**fields: Any) -> dict[str, Any]: + """Build a dict from keyword arguments, dropping `None` values. + + Args: + **fields: The candidate fields. + + Returns: + The fields whose value is not `None`. + + """ + return {key: value for key, value in fields.items() if value is not None} + + def prepare_note_text(text: str) -> str: """Escape plain text for Etherpad's `setHTML`, as `CreateNote` expects. @@ -74,17 +150,10 @@ def prepare_note_text(text: str) -> str: def extract_notes(data: dict[str, Any]) -> str | None: - """Extract plain description text from a notes projection. - - Expects `notesText`, `localHtml`, and `collaborationEnabled` to have been - selected on `data` (selecting `notesText` without `collaborationEnabled` - makes the API resolve it to `""`). + """Extract plain description text from the `NOTES_FIELDS` projection. - This is a module-level function, not only a method on - `AbstractGraphQLOperations`, so that entity mixins (which are combined - with both the sync and async base classes, and so cannot themselves - inherit from `AbstractGraphQLOperations`) can import and call it - directly instead of going through `self`. + Selecting `notesText` without `collaborationEnabled` makes the API resolve + it to `""`, so both must be selected. Args: data: The raw GraphQL object carrying the notes fields. @@ -101,74 +170,88 @@ def extract_notes(data: dict[str, Any]) -> str | None: return text or None -def merge_nodes( - *node_lists: list[dict[str, Any]], date_field: str = "dateCreated" -) -> list[dict[str, Any]]: +def merge_nodes(*node_lists: list[dict[str, Any]]) -> list[dict[str, Any]]: """Merge raw GraphQL node lists, deduping by id and sorting by creation date. - Used to combine the results of a single query that selects several - connections conditionally with `@include(if: ...)` (e.g. `issues` plus - `recentlySolvedIssues`/`archivedIssues`) into one ordered list, since each - connection is independently ordered but the combined result is not. + Combines connections selected conditionally in one document (e.g. `issues` + plus `recentlySolvedIssues`), which are each ordered but not combined. Args: *node_lists: Raw node lists to merge, in any order. - date_field: The raw field name holding each node's creation - timestamp, used as the primary sort key. Returns: The merged nodes with each id appearing once (first occurrence - wins), sorted by `(date_field, id)`. + wins), sorted by `(dateCreated, id)`. """ seen: dict[int, dict[str, Any]] = {} for nodes in node_lists: for node in nodes: seen.setdefault(node["id"], node) - return sorted(seen.values(), key=lambda node: (node[date_field], node["id"])) + return sorted(seen.values(), key=lambda node: (node["dateCreated"], node["id"])) -def dig_nodes(data: dict[str, Any] | None, *keys: str) -> list[dict[str, Any]]: - """Walk nested dict keys (each defaulting to `{}`) and read a `nodes` list. +def _dig(data: dict[str, Any] | None, keys: tuple[str, ...]) -> Any: + """Walk nested dict keys, returning `None` as soon as a level is missing. + + Returns: + The value found at the end of `keys`, or `None`. + + """ + current: Any = data + for key in keys: + if not isinstance(current, dict): + return None + current = cast("dict[str, Any]", current).get(key) + return current - GraphQL connection results are shaped like - `{"meeting": {"issues": {"nodes": [...]}}}`; any level can be missing - (e.g. a `null` `meeting`), so this walks each key defensively instead of - chaining `.get(...) or {}` inline (which loses precise typing under - strict mode once an untyped `{}` fallback enters the chain). + +def dig_nodes(data: dict[str, Any] | None, *keys: str) -> list[dict[str, Any]]: + """Walk nested dict keys and read the `nodes` list of the connection there. Args: data: The raw GraphQL `data` object (or a nested object within it). - *keys: The nested keys to walk before reading `nodes`. + *keys: The nested keys leading to the connection. Returns: - The `nodes` list found this way, or an empty list if any step, or - `nodes` itself, is missing. + The connection's nodes, or an empty list if any level is missing or + `null`. """ - current: dict[str, Any] = data or {} - for key in keys: - current = current.get(key) or {} - nodes = current.get("nodes") + connection = _dig(data, keys) + if not isinstance(connection, dict): + return [] + nodes = cast("dict[str, Any]", connection).get("nodes") return list(nodes) if nodes else [] -class AbstractGraphQLOperations: +class UserIdCache: + """The authenticated user's id, shared by every operations object of a client.""" + + def __init__(self) -> None: + """Initialize an empty cache.""" + self.user_id: int | None = None + self.lock = asyncio.Lock() + + +class AbstractGraphQLOperations(AbstractOperations): """Shared, transport-agnostic logic for v2 (GraphQL) operations.""" - def __init__(self, client: Any, graphql_url: str) -> None: + def __init__( + self, client: Any, graphql_url: str, user_id_cache: UserIdCache | None = None + ) -> None: """Initialize the operations class. Args: client: The httpx client (sync or async) to use for requests. graphql_url: The absolute URL of the GraphQL endpoint. + user_id_cache: The client's shared current-user cache. A new, + unshared cache is created when omitted. """ - self._client = client + super().__init__(client) self._graphql_url = graphql_url - self._user_id: int | None = None - - # -- request/response plumbing ------------------------------------------------ + self._user_id_cache = user_id_cache or UserIdCache() def _build_payload( self, query: str, variables: dict[str, Any] | None @@ -184,6 +267,10 @@ def _build_payload( def _parse_response(self, response: httpx.Response) -> dict[str, Any]: """Parse a GraphQL HTTP response into its `data` object. + A JSON body without an `errors` array still goes through + `raise_for_status()`, so a non-2xx status (e.g. an expired-token 401) + raises `httpx.HTTPStatusError` instead of returning empty `data`. + Args: response: The raw httpx response from the GraphQL endpoint. @@ -221,21 +308,25 @@ def _parse_response(self, response: httpx.Response) -> dict[str, Any]: def _check_mutation_result( self, result: dict[str, Any] | None, *, action: str - ) -> None: - """Raise `GraphQLError` for a failed `{success message errorDetails}` result. + ) -> dict[str, Any]: + """Check a mutation result object and return it. Args: result: The mutation's result object (e.g. `data["EditIssue"]`). - action: A short description of the mutation, used in the error - message when the result is missing entirely. + action: A short description of the mutation, used in error + messages (e.g. `"create issue"`). + + Returns: + `result`, unchanged. Raises: - GraphQLError: If `result` is missing or `success` is falsy. + GraphQLError: If `result` is missing, has a falsy `success`, or + has an `id` of `0`. """ - if result is None: + if not result: raise GraphQLError(f"{action} failed: no result returned") - if not result.get("success", False): + if "success" in result and not result["success"]: error_details: list[dict[str, Any]] = result.get("errorDetails") or [] detail_messages = [ detail.get("message", "") for detail in error_details if detail @@ -246,119 +337,48 @@ def _check_mutation_result( or f"{action} failed" ) raise GraphQLError(message, errors=error_details) - - # -- entity presence ------------------------------------------------------- + if "id" in result and not result["id"]: + raise GraphQLError(f"{action} failed: no id returned") + return result @staticmethod - def _require_entity( - data: dict[str, Any], field: str, entity_id: int, label: str + def _one( + data: dict[str, Any], *path: str, label: str, entity_id: int ) -> dict[str, Any]: - """Get a required nested entity object from a GraphQL response, or raise. + """Read the single entity at `path`, or raise if it is missing. - A by-id query (e.g. `issue(id: $id)`) returns `null` for that field - instead of a top-level GraphQL error when the id is unknown or not - visible to the caller, so every `details()`-style read needs this - check before handing the raw object to a model/transform. + A by-id query returns `null` rather than a GraphQL error for an unknown + or invisible id. When `path` ends at a connection or list, its first + node is returned. Args: data: The raw GraphQL `data` object. - field: The key in `data` holding the entity object (e.g. - `"issue"`). - entity_id: The id that was requested, used in the error message. - label: A human-readable label for the entity (e.g. `"Issue"`). + *path: The nested keys leading to the entity. + label: A human-readable entity label (e.g. `"Issue"`). + entity_id: The requested id, used in the error message. Returns: The entity object. Raises: - GraphQLError: If `data[field]` is missing or `null`. + GraphQLError: If the entity is missing, `null`, or an empty list. """ - entity = data.get(field) - if not entity: + found = _dig(data, path) + if isinstance(found, dict) and "nodes" in found: + found = cast("dict[str, Any]", found)["nodes"] + if isinstance(found, list): + nodes = cast("list[Any]", found) + found = nodes[0] if nodes else None + if not isinstance(found, dict) or not found: raise GraphQLError(f"{label} {entity_id} not found") - return entity - - @staticmethod - def _require_created_id(result: dict[str, Any] | None, *, label: str) -> int: - """Get a newly created entity's id from a `Create*` mutation result, or raise. - - Some v2 `Create*` mutations (e.g. `CreateGoal`) return `IdModel { id }` - and report a failure by returning a `null` result, or an `id` of `0`, - rather than a top-level GraphQL error -- re-reading id `0` would - otherwise surface as a confusing "not found" from the follow-up read. - - Args: - result: The mutation's result object (e.g. `data["CreateGoal"]`). - label: A human-readable label for the entity being created, used - in the error message (e.g. `"goal"`). - - Returns: - The newly created entity's id. - - Raises: - GraphQLError: If `result` is missing, or its `id` is missing or - `0`. - - """ - entity_id = (result or {}).get("id") - if not entity_id: - raise GraphQLError(f"create {label} failed: no id returned") - return int(entity_id) - - # -- timestamps ----------------------------------------------------------- - - @staticmethod - def _to_timestamp(value: datetime | date | float | int) -> float: - """Convert a datetime/date/number into unix seconds (UTC). - - A naive `datetime` is treated as UTC. A `date` becomes 00:00 UTC of - that day. - - Args: - value: The value to convert. - - Returns: - Unix seconds as a float. - - """ - if isinstance(value, datetime): - aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC) - return aware.timestamp() - if isinstance(value, date): - return datetime(value.year, value.month, value.day, tzinfo=UTC).timestamp() - return float(value) - - @staticmethod - def _now_timestamp() -> float: - """Return the current time as unix seconds (UTC). - - Returns: - Unix seconds as a float. - - """ - return datetime.now(tz=UTC).timestamp() - - # -- descriptions ("notes") ------------------------------------------------- - # See the module-level `prepare_note_text`/`extract_notes` docstrings. - - _prepare_note_text = staticmethod(prepare_note_text) - _extract_notes = staticmethod(extract_notes) + return cast("dict[str, Any]", found) class GraphQLOperations(AbstractGraphQLOperations): """Sync base class for v2 (GraphQL) operation classes.""" - def __init__(self, client: httpx.Client, graphql_url: str) -> None: - """Initialize the operations class. - - Args: - client: The sync HTTP client to use for GraphQL requests. - graphql_url: The absolute URL of the GraphQL endpoint. - - """ - super().__init__(client, graphql_url) - self._client: httpx.Client = client + _client: httpx.Client @property def user_id(self) -> int: @@ -368,19 +388,11 @@ def user_id(self) -> int: The user ID of the authenticated user. """ - if self._user_id is None: - self._user_id = self._fetch_authenticated_user_id() - return self._user_id - - def _fetch_authenticated_user_id(self) -> int: - """Fetch the authenticated user's ID via `getAuthenticatedUserId`. - - Returns: - The user ID of the authenticated user. - - """ - data = self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) - return int(data["getAuthenticatedUserId"]["id"]) + cache = self._user_id_cache + if cache.user_id is None: + data = self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) + cache.user_id = int(data["getAuthenticatedUserId"]["id"]) + return cache.user_id def _execute( self, query: str, variables: dict[str, Any] | None = None @@ -401,7 +413,7 @@ def _execute( ) return self._parse_response(response) - def _run_mutation( + def _mutate( self, query: str, variables: dict[str, Any] | None, @@ -409,12 +421,16 @@ def _run_mutation( root_field: str, action: str, ) -> dict[str, Any]: - """Execute a mutation and check its `{success message errorDetails}` result. + """Execute a mutation and return its checked result object. + + A result shaped `{success ...}` must succeed and a result with an `id` + must have a non-zero id, since the API reports some failures that way + instead of through `errors`. Args: query: The GraphQL mutation document. variables: The GraphQL variables for the document. - root_field: The mutation's root field name in `data` (e.g. `EditIssue`). + root_field: The mutation's root field in `data` (e.g. `EditIssue`). action: A short description of the mutation, used in error messages. Returns: @@ -423,44 +439,45 @@ def _run_mutation( """ data = self._execute(query, variables) - result = data.get(root_field) - self._check_mutation_result(result, action=action) - return result or {} + return self._check_mutation_result(data.get(root_field), action=action) def _create_note(self, text: str) -> str: """Create a new Etherpad note pad from plain text. Args: - text: The plain text to store. Newlines and HTML-sensitive - characters are escaped for Etherpad automatically. + text: The plain text to store (escaped for Etherpad here). Returns: The new pad's id, for use as `notesId` on a create/edit input. - See `_check_mutation_result` for the errors this can raise. """ - result = self._run_mutation( + result = self._mutate( _CREATE_NOTE_MUTATION, - {"text": self._prepare_note_text(text)}, + {"text": prepare_note_text(text)}, root_field="CreateNote", - action="CreateNote", + action="create note", ) return str(result["data"]) - -class AsyncGraphQLOperations(AbstractGraphQLOperations): - """Async base class for v2 (GraphQL) operation classes.""" - - def __init__(self, client: httpx.AsyncClient, graphql_url: str) -> None: - """Initialize the async operations class. + def _notes_input(self, notes: str | None) -> dict[str, Any]: + """Build the notes fields of a create/edit input, creating the pad. Args: - client: The async HTTP client to use for GraphQL requests. - graphql_url: The absolute URL of the GraphQL endpoint. + notes: The description text, or `None` to leave notes unchanged. + + Returns: + `{}` when `notes` is `None`, else `notesId`/`collaborationEnabled`. """ - super().__init__(client, graphql_url) - self._client: httpx.AsyncClient = client + if notes is None: + return {} + return {"notesId": self._create_note(notes), "collaborationEnabled": True} + + +class AsyncGraphQLOperations(AbstractGraphQLOperations): + """Async base class for v2 (GraphQL) operation classes.""" + + _client: httpx.AsyncClient @property def user_id(self) -> int: @@ -472,35 +489,30 @@ def user_id(self) -> int: RuntimeError: If the user ID has not been fetched yet. """ - if self._user_id is None: + user_id = self._user_id_cache.user_id + if user_id is None: raise RuntimeError("User ID not set. Use get_user_id() to fetch from API.") - return self._user_id + return user_id @user_id.setter def user_id(self, value: int) -> None: """Set the cached user ID.""" - self._user_id = value + self._user_id_cache.user_id = value async def get_user_id(self) -> int: - """Get the current user's ID, fetching it if needed. + """Get the current user's ID, fetching it once per client if needed. Returns: The user ID of the authenticated user. """ - if self._user_id is None: - self._user_id = await self._fetch_authenticated_user_id() - return self._user_id - - async def _fetch_authenticated_user_id(self) -> int: - """Fetch the authenticated user's ID via `getAuthenticatedUserId`. - - Returns: - The user ID of the authenticated user. - - """ - data = await self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) - return int(data["getAuthenticatedUserId"]["id"]) + cache = self._user_id_cache + if cache.user_id is None: + async with cache.lock: + if cache.user_id is None: + data = await self._execute(_GET_AUTHENTICATED_USER_ID_MUTATION) + cache.user_id = int(data["getAuthenticatedUserId"]["id"]) + return cache.user_id async def _execute( self, query: str, variables: dict[str, Any] | None = None @@ -521,7 +533,7 @@ async def _execute( ) return self._parse_response(response) - async def _run_mutation( + async def _mutate( self, query: str, variables: dict[str, Any] | None, @@ -529,40 +541,54 @@ async def _run_mutation( root_field: str, action: str, ) -> dict[str, Any]: - """Execute a mutation and check its `{success message errorDetails}` result. + """Execute a mutation and return its checked result object. + + See `GraphQLOperations._mutate`. Args: query: The GraphQL mutation document. variables: The GraphQL variables for the document. - root_field: The mutation's root field name in `data` (e.g. `EditIssue`). + root_field: The mutation's root field in `data` (e.g. `EditIssue`). action: A short description of the mutation, used in error messages. Returns: - The mutation's result object (`data[root_field]`). See - `_check_mutation_result` for the errors this can raise. + The mutation's result object (`data[root_field]`). """ data = await self._execute(query, variables) - result = data.get(root_field) - self._check_mutation_result(result, action=action) - return result or {} + return self._check_mutation_result(data.get(root_field), action=action) async def _create_note(self, text: str) -> str: """Create a new Etherpad note pad from plain text. Args: - text: The plain text to store. Newlines and HTML-sensitive - characters are escaped for Etherpad automatically. + text: The plain text to store (escaped for Etherpad here). Returns: The new pad's id, for use as `notesId` on a create/edit input. - See `_check_mutation_result` for the errors this can raise. """ - result = await self._run_mutation( + result = await self._mutate( _CREATE_NOTE_MUTATION, - {"text": self._prepare_note_text(text)}, + {"text": prepare_note_text(text)}, root_field="CreateNote", - action="CreateNote", + action="create note", ) return str(result["data"]) + + async def _notes_input(self, notes: str | None) -> dict[str, Any]: + """Build the notes fields of a create/edit input, creating the pad. + + Args: + notes: The description text, or `None` to leave notes unchanged. + + Returns: + `{}` when `notes` is `None`, else `notesId`/`collaborationEnabled`. + + """ + if notes is None: + return {} + return { + "notesId": await self._create_note(notes), + "collaborationEnabled": True, + } diff --git a/src/bloomy/v2/models.py b/src/bloomy/v2/models.py index 731fa58..449f922 100644 --- a/src/bloomy/v2/models.py +++ b/src/bloomy/v2/models.py @@ -1,73 +1,70 @@ """Pydantic models for the Bloomy v2 (GraphQL) API. -Conventions (apply these uniformly to models added for other v2 entities — -headline, todo, goal, milestone, metric): - -- **Aliases are camelCase**, matching the GraphQL field names verbatim - (`Field(alias="dateCreated")`), unlike v1's PascalCase REST aliases. Since - GraphQL response shapes already map ~1:1 onto these models, operation - mixins build most models by spreading the raw response dict as keyword - arguments (`Model(**data, notes=computed_notes)`), relying on - `BloomyBaseModel`'s `validate_by_alias=True` to do the field mapping and - on Pydantic's default `extra="ignore"` to drop unmapped raw keys (e.g. - `notesText`, `collaborationEnabled`). This works cleanly for `Issue` - (see `IssueOperationsMixin._transform_issue`), whose fields all come from - one flat response object. Where a mixin instead reshapes a nested - connection by hand (e.g. `Meeting.attendees`, or flattening a nested - `user { email }` onto `User`, in `MeetingOperationsMixin._transform_*`), - construct the model with **alias keyword names** - (`User(firstName=..., fullName=...)`), not the snake_case field names: - Pydantic's generated `__init__` exposes the alias — not the field name — - to static type checkers whenever `Field(alias=...)` is set, even though - `validate_by_name=True` also accepts the field name at runtime. Mixing the - two (spreading a raw dict vs. hand-building kwargs) is fine; mixing - aliases and field names *within one call* is not checked by pydantic and - will silently pass `None`/defaults for the field-name-spelled arguments. -- **Owners are nested refs**: an entity's assignee is exposed as - `owner: UserRef | None` (aliased from the GraphQL `assignee` field), never - flattened into `owner_id`/`owner_name`. `owner` is optional because - `assignee` can be `null` (e.g. unowned issues). -- **Related entities are nested refs** too: an `Issue`'s parent meeting is - `meeting: MeetingRef | None` (aliased from GraphQL `meeting`), not - flattened `meeting_id`/`meeting_name`. Reuse `MeetingRef`/`UserRef` for - this rather than inventing new ref types per entity. -- **Datetimes use the `_date` suffix** (`created_date`, `due_date`, - `completed_date`, `archived_date`) and are always timezone-aware UTC - `datetime` objects, converted from the API's unix-seconds floats via the - `GqlDatetime`/`GqlOptionalDatetime` annotated types. This mirrors v1's - `_date` naming and lines up with the public API's `due_date` parameters. -- **Notes**: description text is exposed as `notes: str | None`, computed by - `bloomy.v2.base.extract_notes` from the raw - `notesText`/`localHtml`/`collaborationEnabled` projection. `notes_id` is - kept alongside it (aliased from `notesId`) since it is needed to update - the description later (see `v2/base.py`). +Conventions for v2 models: + +- Aliases are the camelCase GraphQL field names; operations build every model + with `Model.model_validate` on the raw GraphQL object. +- Owners and related entities are nested refs: `owner: UserRef | None` + (aliased from `assignee`, which can be `null`) and `meeting: MeetingRef | + None`, never flattened ids/names. +- Datetimes use the `_date` suffix and are timezone-aware UTC `datetime` + objects, converted from the API's unix-seconds floats. +- `notes` is the plain description text, computed from the `NOTES_FIELDS` + projection (see `bloomy.v2.base.extract_notes`); `notes_id` is the pad id + needed to update it. """ from __future__ import annotations -from datetime import UTC, date, datetime +from datetime import datetime from enum import StrEnum -from typing import Annotated, Any +from typing import Annotated, Any, cast -from pydantic import BeforeValidator, Field +from pydantic import ( + AliasChoices, + AliasPath, + BeforeValidator, + Field, + model_validator, +) from ..models import BloomyBaseModel, OptionalFloat +from .base import extract_notes, to_utc_datetime def _parse_gql_datetime(value: Any) -> datetime | None: - """Convert a GraphQL unix-seconds timestamp into a timezone-aware datetime. + """Convert a GraphQL unix-seconds timestamp into an aware UTC datetime. Returns: The converted datetime, or `None` if `value` is `None`. + """ + return None if value is None else to_utc_datetime(value) + + +def _connection_nodes(value: Any) -> Any: + """Unwrap a GraphQL connection (`{"nodes": [...]}`) into its node list. + + Returns: + The node list (`[]` for `null`), or `value` unchanged if it is not a + connection object. + """ if value is None: - return None - if isinstance(value, datetime): - return value if value.tzinfo is not None else value.replace(tzinfo=UTC) - if isinstance(value, date): - return datetime(value.year, value.month, value.day, tzinfo=UTC) - return datetime.fromtimestamp(float(value), tz=UTC) + return [] + if isinstance(value, dict): + return cast("dict[str, Any]", value).get("nodes") or [] + return value + + +def _raw_dict(data: Any) -> dict[str, Any] | None: + """Narrow a `mode="before"` validator's input to a raw GraphQL object. + + Returns: + `data` if it is a dict, else `None`. + + """ + return cast("dict[str, Any]", data) if isinstance(data, dict) else None # Reusable annotated types for the v2 API's unix-seconds timestamps. @@ -75,9 +72,33 @@ def _parse_gql_datetime(value: Any) -> datetime | None: type GqlOptionalDatetime = Annotated[ datetime | None, BeforeValidator(_parse_gql_datetime) ] +# A nested connection (`field { nodes { ... } }`), validated as a plain list. +type Connection[T] = Annotated[list[T], BeforeValidator(_connection_nodes)] + +class GqlBaseModel(BloomyBaseModel): + """Base model for v2 entities, validated from raw GraphQL objects. -class UserRef(BloomyBaseModel): + Fields validate by alias (the raw GraphQL key) or by field name, so + `Model.model_validate(model.model_dump())` round-trips. + """ + + @model_validator(mode="before") + @classmethod + def _fill_notes(cls, data: Any) -> Any: + """Compute `notes` when the raw object carries the `NOTES_FIELDS`. + + Returns: + The raw object, with `notes` added when applicable. + + """ + raw = _raw_dict(data) + if raw is not None and "collaborationEnabled" in raw: + return {**raw, "notes": extract_notes(raw)} + return data + + +class UserRef(GqlBaseModel): """Minimal reference to a user, as nested inside other v2 entities. This is what GraphQL `assignee { id fullName }` projections return; use @@ -88,7 +109,7 @@ class UserRef(BloomyBaseModel): full_name: str | None = Field(default=None, alias="fullName") -class MeetingRef(BloomyBaseModel): +class MeetingRef(GqlBaseModel): """Minimal reference to a meeting, as nested inside other v2 entities. This is what GraphQL `meeting { id name }` projections return; use @@ -99,18 +120,21 @@ class MeetingRef(BloomyBaseModel): name: str | None = None -class User(BloomyBaseModel): +class User(GqlBaseModel): """Model for a full v2 user, from the `user`/`users` queries.""" id: int first_name: str | None = Field(default=None, alias="firstName") last_name: str | None = Field(default=None, alias="lastName") full_name: str | None = Field(default=None, alias="fullName") - email: str | None = None + email: str | None = Field( + default=None, + validation_alias=AliasChoices("email", AliasPath("user", "email")), + ) avatar: str | None = None -class MeetingListItem(BloomyBaseModel): +class MeetingListItem(GqlBaseModel): """Model for a meeting list item, from `user(id){ meetingsListLookup }`.""" id: int @@ -122,7 +146,7 @@ class MeetingListItem(BloomyBaseModel): is_current_user_admin: bool = Field(default=False, alias="isCurrentUserAdmin") -class Meeting(BloomyBaseModel): +class Meeting(GqlBaseModel): """Model for full meeting details, from the `meeting(id)` query.""" id: int @@ -131,10 +155,10 @@ class Meeting(BloomyBaseModel): meeting_type: str | None = Field(default=None, alias="meetingType") created_date: GqlDatetime = Field(alias="createdTimestamp") archived: bool = False - attendees: list[User] = Field(default_factory=list) + attendees: Connection[User] = Field(default_factory=list) -class Issue(BloomyBaseModel): +class Issue(GqlBaseModel): """Model for an issue, from the `issue(id)`/`meeting(id){ issues }` queries.""" id: int @@ -156,8 +180,26 @@ class Issue(BloomyBaseModel): num_star_votes: int = Field(default=0, alias="numStarVotes") issue_number: int | None = Field(default=None, alias="issueNumber") + @model_validator(mode="before") + @classmethod + def _long_term_is_not_archived(cls, data: Any) -> Any: + """Report a long-term issue as not archived. -class Headline(BloomyBaseModel): + The API stores every long-term issue as archived, while the web app + lists them on the Long-Term tab; archiving a long-term issue clears + `addToDepartmentPlan`, so it then counts as archived. + + Returns: + The raw object, with `archived` cleared for a long-term issue. + + """ + raw = _raw_dict(data) + if raw is not None and raw.get("addToDepartmentPlan"): + return {**raw, "archived": False} + return data + + +class Headline(GqlBaseModel): """Model for a headline, from `headline(id)`/`meeting(id){ headlines }` queries.""" id: int @@ -172,8 +214,8 @@ class Headline(BloomyBaseModel): created_date: GqlDatetime = Field(alias="dateCreated") -class Todo(BloomyBaseModel): - """Model for a to-do, from the `todo(id)`/`meeting(id){ todos }` queries.""" +class Todo(GqlBaseModel): + """Model for a to-do, from the `todo(id)` query and the to-do list queries.""" id: int title: str | None = None @@ -203,7 +245,7 @@ class GoalStatus(StrEnum): COMPLETED = "COMPLETED" -class Milestone(BloomyBaseModel): +class Milestone(GqlBaseModel): """Model for a goal milestone, from `goal(id){ milestones }`. There is no root `milestone(id)` query in the GraphQL API: a milestone is @@ -219,7 +261,7 @@ class Milestone(BloomyBaseModel): created_date: GqlOptionalDatetime = Field(default=None, alias="dateCreated") -class Goal(BloomyBaseModel): +class Goal(GqlBaseModel): """Model for a goal (rock), from the `goal(id)`/`goals(userId)` queries.""" id: int @@ -232,8 +274,8 @@ class Goal(BloomyBaseModel): created_date: GqlDatetime = Field(alias="dateCreated") notes_id: str | None = Field(default=None, alias="notesId") notes: str | None = None - milestones: list[Milestone] = Field(default_factory=list) - meetings: list[MeetingRef] = Field(default_factory=list) + milestones: Connection[Milestone] = Field(default_factory=list) + meetings: Connection[MeetingRef] = Field(default_factory=list) class MetricUnit(StrEnum): @@ -280,7 +322,7 @@ class MetricFrequency(StrEnum): DAILY = "DAILY" -class Metric(BloomyBaseModel): +class Metric(GqlBaseModel): """Model for a metric (KPI), from the `metric(id)`/`meeting(id){ metrics }` queries. `id` is always the Measurable id (aliased from `measurableId`). On @@ -305,7 +347,7 @@ class Metric(BloomyBaseModel): is_externally_synced: bool = Field(default=False, alias="isExternallySynced") -class MetricScore(BloomyBaseModel): +class MetricScore(GqlBaseModel): """Model for a metric score, from `metric(id){ scoresNonPaginated }`. There is no root `score(id)` query: a score is only reachable through @@ -316,4 +358,6 @@ class MetricScore(BloomyBaseModel): metric_id: int = Field(alias="measurableId") value: OptionalFloat = None week_date: GqlOptionalDatetime = Field(default=None, alias="timestamp") - notes: str | None = None + notes: Annotated[str | None, BeforeValidator(lambda value: value or None)] = Field( + default=None, validation_alias="notesText" + ) diff --git a/src/bloomy/v2/operations/goal.py b/src/bloomy/v2/operations/goal.py index 2c7498f..155bba7 100644 --- a/src/bloomy/v2/operations/goal.py +++ b/src/bloomy/v2/operations/goal.py @@ -3,18 +3,22 @@ from __future__ import annotations import builtins -from datetime import UTC, date, datetime, timedelta from typing import Any from ...exceptions import GraphQLError -from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes -from ..models import Goal, GoalStatus, MeetingRef, Milestone - -# Kept in sync with `milestone.py`'s `_MILESTONE_FIELDS` (duplicated rather -# than imported, to keep the two entity modules independent). `dateDeleted` -# is already filtered server-side; the explicit filter matches the web app's -# own query. -_GOAL_MILESTONE_FIELDS = "id goalId title dueDate completed status dateCreated" +from ..base import ( + MILESTONES_CONNECTION, + NOTES_FIELDS, + USER_REF_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + TimeInput, + compact, + default_due_date, + dig_nodes, + to_timestamp, +) +from ..models import Goal, GoalStatus _GOAL_FIELDS = f""" id @@ -24,30 +28,28 @@ archived archivedTimestamp dateCreated -notesId -notesText -localHtml -collaborationEnabled -assignee {{ id fullName }} +{NOTES_FIELDS} +{USER_REF_FIELDS} meetings {{ nodes {{ id name }} }} -milestones( - where: {{ and: [{{ dateDeleted: {{ eq: null }} }}] }} - order: [{{ dueDate: ASC }}] -) {{ +{MILESTONES_CONNECTION} +""" + +_GOALS_CONNECTION = f""" +goals(where: $where, order: [{{ dueDate: ASC }}]) {{ nodes {{ - {_GOAL_MILESTONE_FIELDS} + {_GOAL_FIELDS} }} }} """ class GoalOperationsMixin: - """Shared GraphQL documents and response transforms for goal operations.""" + """GraphQL documents, inputs, and response parsing shared by goal operations.""" _GOAL_DETAILS_QUERY = f""" query($id: Long!) {{ @@ -57,32 +59,20 @@ class GoalOperationsMixin: }} """ - _GOAL_LIST_BY_MEETING_QUERY = f""" + _GOAL_MEETING_LIST_QUERY = f""" query($meetingId: Long!, $where: GoalQueryModelFilterInput) {{ meeting(id: $meetingId) {{ - goals(where: $where, order: [{{ dueDate: ASC }}]) {{ - nodes {{ - {_GOAL_FIELDS} - }} - }} + {_GOALS_CONNECTION} }} }} """ - # `goals(userId)` (the root field) unconditionally excludes archived - # goals server-side regardless of `where` (verified live: a `where: - # {archived: {eq: true}}` filter still returns 0 results for a user with - # hundreds of archived goals). `user(id){ goals }` respects the filter - # (verified live), so it is used here instead to make `include_archived` - # actually work. - _GOAL_LIST_BY_USER_QUERY = f""" + # Root `goals(userId)` drops archived goals whatever `where` says, so the + # user list reads `user(id){ goals }` instead. + _GOAL_USER_LIST_QUERY = f""" query($userId: Long!, $where: GoalQueryModelFilterInput) {{ user(id: $userId) {{ - goals(where: $where, order: [{{ dueDate: ASC }}]) {{ - nodes {{ - {_GOAL_FIELDS} - }} - }} + {_GOALS_CONNECTION} }} }} """ @@ -95,13 +85,6 @@ class GoalOperationsMixin: } """ - # CreateGoal/EditGoal return only `IdModel { id }` (verified live), not - # the `{success message errorDetails}` shape `_run_mutation` expects. A - # failed mutation raises through the standard `errors` array instead - # (an invalid input throws server-side, including `IdModel(0)` on a - # failed create), so these are executed with plain `_execute`. `archive` - # additionally re-reads the goal, because `EditGoal{archived: true}` can - # report success without archiving anything (see `archive`'s docstring). _GOAL_EDIT_MUTATION = """ mutation($input: GoalEditModelInput!) { EditGoal(input: $input) { @@ -110,37 +93,44 @@ class GoalOperationsMixin: } """ - def _goal_where(self, *, include_archived: bool) -> dict[str, Any] | None: - """Build the `where` filter for a goal list connection. + @classmethod + def _goal_list_request( + cls, meeting_id: int | None, user_id: int | None, *, include_archived: bool + ) -> tuple[str, dict[str, Any]]: + """Pick the list document for a meeting or a user, with its variables. Returns: - A `GoalQueryModelFilterInput`-shaped dictionary, or `None` to - leave the connection unfiltered (both archived and active). + The query document and its variables. """ - if include_archived: - return None - return {"and": [{"archived": {"eq": False}}]} + where = None if include_archived else {"and": [{"archived": {"eq": False}}]} + if meeting_id is not None: + return cls._GOAL_MEETING_LIST_QUERY, { + "meetingId": meeting_id, + "where": where, + } + return cls._GOAL_USER_LIST_QUERY, {"userId": user_id, "where": where} - def _default_due_date(self) -> date: - """Compute the default due date: 90 days from today, 00:00 UTC. + @staticmethod + def _goals_from(data: dict[str, Any]) -> list[Goal]: + """Validate the goals of a meeting or user list response. + + A response carries either `meeting` or `user`; the absent one reads + as empty. Returns: - A `date` 90 days from today (UTC). + The goals, ordered by due date. """ - return datetime.now(tz=UTC).date() + timedelta(days=90) - - def _parse_milestone_item( - self, item: dict[str, Any] | tuple[Any, ...] - ) -> tuple[Any, Any, bool]: - """Parse a `create()` milestone item into `(title, due_date, completed)`. + nodes = [ + *dig_nodes(data, "meeting", "goals"), + *dig_nodes(data, "user", "goals"), + ] + return [Goal.model_validate(node) for node in nodes] - `due_date` is returned as given (not yet converted to a unix - timestamp): that conversion needs `_to_timestamp`, a base-class - helper this mixin does not itself have access to (see - `GraphQLOperations`/`AsyncGraphQLOperations.create`, which call it - on the parsed result instead). + @staticmethod + def _goal_milestone_input(item: dict[str, Any] | tuple[Any, ...]) -> dict[str, Any]: + """Build a `Goal_MilestoneCreateModelInput` from a `create()` milestone item. Args: item: Either `{"title": ..., "due_date": ..., "completed": ...}` @@ -148,33 +138,74 @@ def _parse_milestone_item( `(title, due_date, completed)` tuple. Returns: - A `(title, due_date, completed)` tuple. + The input fields. """ if isinstance(item, dict): - return item["title"], item["due_date"], item.get("completed", False) - title, due_date, *rest = item - return title, due_date, (rest[0] if rest else False) + title, due_date = item["title"], item["due_date"] + completed = item.get("completed", False) + else: + title, due_date, *rest = item + completed = rest[0] if rest else False + return { + "title": title, + "dueDate": to_timestamp(due_date), + "completed": completed, + } - def _transform_goal(self, data: dict[str, Any]) -> Goal: - """Transform a raw GraphQL goal object into a `Goal` model. + @classmethod + def _goal_create_input( + cls, + *, + meeting_id: int, + title: str, + user_id: int, + due_date: TimeInput | None, + status: GoalStatus | str, + milestones: list[dict[str, Any] | tuple[Any, ...]] | None, + ) -> dict[str, Any]: + """Build the `GoalCreateModelInput` fields, apart from notes. Returns: - A `Goal` model instance. + The input fields, with the due date defaulting to 90 days out. """ - milestone_nodes = dig_nodes(data, "milestones") - meeting_nodes = dig_nodes(data, "meetings") - rest = { - key: value - for key, value in data.items() - if key not in ("milestones", "meetings") + input_: dict[str, Any] = { + "title": title, + "assignee": user_id, + "dueDate": to_timestamp( + default_due_date(90) if due_date is None else due_date + ), + "status": str(status), + "meetingsAndPlans": [ + {"meetingId": meeting_id, "addToDepartmentPlan": False} + ], } - return Goal( - **rest, - notes=extract_notes(data), - milestones=[Milestone(**node) for node in milestone_nodes], - meetings=[MeetingRef(**node) for node in meeting_nodes], + if milestones: + input_["milestones"] = [ + cls._goal_milestone_input(item) for item in milestones + ] + return input_ + + @staticmethod + def _goal_edit_fields( + *, + title: str | None, + status: GoalStatus | str | None, + due_date: TimeInput | None, + user_id: int | None, + ) -> dict[str, Any]: + """Build the `GoalEditModelInput` fields that were given, apart from notes. + + Returns: + The non-`None` input fields. + + """ + return compact( + title=title, + status=None if status is None else str(status), + dueDate=None if due_date is None else to_timestamp(due_date), + assignee=user_id, ) @@ -209,27 +240,14 @@ def list( # Returns: [Goal(id=1, title='Ship v2', ...), ...] ``` - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._goal_where(include_archived=include_archived) - - if meeting_id is not None: - data = self._execute( - self._GOAL_LIST_BY_MEETING_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "goals") - else: - if user_id is None: - user_id = self.user_id - data = self._execute( - self._GOAL_LIST_BY_USER_QUERY, {"userId": user_id, "where": where} - ) - nodes = dig_nodes(data, "user", "goals") - - return [self._transform_goal(node) for node in nodes] + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: + user_id = self.user_id + query, variables = self._goal_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._goals_from(self._execute(query, variables)) def details(self, goal_id: int) -> Goal: """Get details for a goal, including its milestones. @@ -248,14 +266,16 @@ def details(self, goal_id: int) -> Goal: """ data = self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) - return self._transform_goal(self._require_entity(data, "goal", goal_id, "Goal")) + return Goal.model_validate( + self._one(data, "goal", label="Goal", entity_id=goal_id) + ) def create( self, meeting_id: int, title: str, user_id: int | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, status: GoalStatus | str = GoalStatus.ON_TRACK, notes: str | None = None, milestones: builtins.list[dict[str, Any] | tuple[Any, ...]] | None = None, @@ -287,36 +307,21 @@ def create( """ if user_id is None: user_id = self.user_id - if due_date is None: - due_date = self._default_due_date() - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "dueDate": self._to_timestamp(due_date), - "status": str(status), - "meetingsAndPlans": [ - {"meetingId": meeting_id, "addToDepartmentPlan": False} - ], - } - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - if milestones: - input_["milestones"] = [ - { - "title": parsed_title, - "dueDate": self._to_timestamp(parsed_due_date), - "completed": parsed_completed, - } - for parsed_title, parsed_due_date, parsed_completed in ( - self._parse_milestone_item(item) for item in milestones - ) - ] - - data = self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) - goal_id = self._require_created_id(data.get("CreateGoal"), label="goal") - return self.details(goal_id) + input_ = self._goal_create_input( + meeting_id=meeting_id, + title=title, + user_id=user_id, + due_date=due_date, + status=status, + milestones=milestones, + ) + result = self._mutate( + self._GOAL_CREATE_MUTATION, + {"input": {**input_, **self._notes_input(notes)}}, + root_field="CreateGoal", + action="create goal", + ) + return self.details(result["id"]) def update( self, @@ -324,7 +329,7 @@ def update( *, title: str | None = None, status: GoalStatus | str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, user_id: int | None = None, notes: str | None = None, ) -> Goal: @@ -352,40 +357,21 @@ def update( ``` """ - if ( - title is None - and status is None - and due_date is None - and user_id is None - and notes is None - ): + fields = self._goal_edit_fields( + title=title, status=status, due_date=due_date, user_id=user_id + ) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"goalId": goal_id} - if title is not None: - input_["title"] = title - if status is not None: - input_["status"] = str(status) - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if user_id is not None: - input_["assignee"] = user_id - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - self._execute(self._GOAL_EDIT_MUTATION, {"input": input_}) - return self.details(goal_id) + fields.update(self._notes_input(notes)) + return self._edit(goal_id, fields, action="update goal") def archive(self, goal_id: int) -> Goal: """Archive a goal. Note: Verified live: `EditGoal{archived: true}` can report success - without archiving anything, because the server detaches the goal - from its meetings through an un-awaited call whose exceptions - are lost, and the archive step itself is wrapped in a bare - `try/catch`. This re-reads the goal afterwards and raises + without archiving the goal, because the server discards the + archive step's exceptions. This re-reads the goal and raises `GraphQLError` if it is still not archived. Args: @@ -398,10 +384,7 @@ def archive(self, goal_id: int) -> Goal: GraphQLError: If the goal is still not archived after the edit. """ - self._execute( - self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": True}} - ) - result = self.details(goal_id) + result = self._edit(goal_id, {"archived": True}, action="archive goal") if not result.archived: raise GraphQLError( f"archive goal {goal_id} failed: goal is still not archived" @@ -423,8 +406,20 @@ def restore(self, goal_id: int) -> Goal: The updated `Goal`. """ - self._execute( - self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": False}} + return self._edit(goal_id, {"archived": False}, action="restore goal") + + def _edit(self, goal_id: int, fields: dict[str, Any], *, action: str) -> Goal: + """Run `EditGoal` with `fields`, then re-read the goal. + + Returns: + The updated `Goal`. + + """ + self._mutate( + self._GOAL_EDIT_MUTATION, + {"input": {"goalId": goal_id, **fields}}, + root_field="EditGoal", + action=action, ) return self.details(goal_id) @@ -454,27 +449,14 @@ async def list( Raises: ValueError: If both `meeting_id` and `user_id` are provided. - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._goal_where(include_archived=include_archived) - - if meeting_id is not None: - data = await self._execute( - self._GOAL_LIST_BY_MEETING_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "goals") - else: - if user_id is None: - user_id = await self.get_user_id() - data = await self._execute( - self._GOAL_LIST_BY_USER_QUERY, {"userId": user_id, "where": where} - ) - nodes = dig_nodes(data, "user", "goals") - - return [self._transform_goal(node) for node in nodes] + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: + user_id = await self.get_user_id() + query, variables = self._goal_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._goals_from(await self._execute(query, variables)) async def details(self, goal_id: int) -> Goal: """Get details for a goal, including its milestones. @@ -487,14 +469,16 @@ async def details(self, goal_id: int) -> Goal: """ data = await self._execute(self._GOAL_DETAILS_QUERY, {"id": goal_id}) - return self._transform_goal(self._require_entity(data, "goal", goal_id, "Goal")) + return Goal.model_validate( + self._one(data, "goal", label="Goal", entity_id=goal_id) + ) async def create( self, meeting_id: int, title: str, user_id: int | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, status: GoalStatus | str = GoalStatus.ON_TRACK, notes: str | None = None, milestones: builtins.list[dict[str, Any] | tuple[Any, ...]] | None = None, @@ -520,36 +504,21 @@ async def create( """ if user_id is None: user_id = await self.get_user_id() - if due_date is None: - due_date = self._default_due_date() - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "dueDate": self._to_timestamp(due_date), - "status": str(status), - "meetingsAndPlans": [ - {"meetingId": meeting_id, "addToDepartmentPlan": False} - ], - } - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - if milestones: - input_["milestones"] = [ - { - "title": parsed_title, - "dueDate": self._to_timestamp(parsed_due_date), - "completed": parsed_completed, - } - for parsed_title, parsed_due_date, parsed_completed in ( - self._parse_milestone_item(item) for item in milestones - ) - ] - - data = await self._execute(self._GOAL_CREATE_MUTATION, {"input": input_}) - goal_id = self._require_created_id(data.get("CreateGoal"), label="goal") - return await self.details(goal_id) + input_ = self._goal_create_input( + meeting_id=meeting_id, + title=title, + user_id=user_id, + due_date=due_date, + status=status, + milestones=milestones, + ) + result = await self._mutate( + self._GOAL_CREATE_MUTATION, + {"input": {**input_, **await self._notes_input(notes)}}, + root_field="CreateGoal", + action="create goal", + ) + return await self.details(result["id"]) async def update( self, @@ -557,7 +526,7 @@ async def update( *, title: str | None = None, status: GoalStatus | str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, user_id: int | None = None, notes: str | None = None, ) -> Goal: @@ -579,40 +548,21 @@ async def update( ValueError: If no update fields are provided. """ - if ( - title is None - and status is None - and due_date is None - and user_id is None - and notes is None - ): + fields = self._goal_edit_fields( + title=title, status=status, due_date=due_date, user_id=user_id + ) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"goalId": goal_id} - if title is not None: - input_["title"] = title - if status is not None: - input_["status"] = str(status) - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if user_id is not None: - input_["assignee"] = user_id - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - await self._execute(self._GOAL_EDIT_MUTATION, {"input": input_}) - return await self.details(goal_id) + fields.update(await self._notes_input(notes)) + return await self._edit(goal_id, fields, action="update goal") async def archive(self, goal_id: int) -> Goal: """Archive a goal. Note: Verified live: `EditGoal{archived: true}` can report success - without archiving anything, because the server detaches the goal - from its meetings through an un-awaited call whose exceptions - are lost, and the archive step itself is wrapped in a bare - `try/catch`. This re-reads the goal afterwards and raises + without archiving the goal, because the server discards the + archive step's exceptions. This re-reads the goal and raises `GraphQLError` if it is still not archived. Args: @@ -625,10 +575,7 @@ async def archive(self, goal_id: int) -> Goal: GraphQLError: If the goal is still not archived after the edit. """ - await self._execute( - self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": True}} - ) - result = await self.details(goal_id) + result = await self._edit(goal_id, {"archived": True}, action="archive goal") if not result.archived: raise GraphQLError( f"archive goal {goal_id} failed: goal is still not archived" @@ -650,7 +597,19 @@ async def restore(self, goal_id: int) -> Goal: The updated `Goal`. """ - await self._execute( - self._GOAL_EDIT_MUTATION, {"input": {"goalId": goal_id, "archived": False}} + return await self._edit(goal_id, {"archived": False}, action="restore goal") + + async def _edit(self, goal_id: int, fields: dict[str, Any], *, action: str) -> Goal: + """Run `EditGoal` with `fields`, then re-read the goal. + + Returns: + The updated `Goal`. + + """ + await self._mutate( + self._GOAL_EDIT_MUTATION, + {"input": {"goalId": goal_id, **fields}}, + root_field="EditGoal", + action=action, ) return await self.details(goal_id) diff --git a/src/bloomy/v2/operations/headline.py b/src/bloomy/v2/operations/headline.py index 1684938..383b955 100644 --- a/src/bloomy/v2/operations/headline.py +++ b/src/bloomy/v2/operations/headline.py @@ -6,32 +6,32 @@ from typing import Any from ..base import ( + MEETING_REF_FIELDS, + NOTES_FIELDS, + USER_REF_FIELDS, AsyncGraphQLOperations, GraphQLOperations, + compact, dig_nodes, - extract_notes, merge_nodes, ) from ..models import Headline -_HEADLINE_FIELDS = """ +_HEADLINE_FIELDS = f""" id title recurrenceId -notesId -notesText -localHtml -collaborationEnabled archived archivedTimestamp dateCreated -assignee { id fullName } -meeting { id name } +{NOTES_FIELDS} +{USER_REF_FIELDS} +{MEETING_REF_FIELDS} """ class HeadlineOperationsMixin: - """Shared GraphQL documents and response transforms for headline operations.""" + """GraphQL documents, inputs, and response parsing shared by headline operations.""" _HEADLINE_DETAILS_QUERY = f""" query($id: Long!) {{ @@ -41,12 +41,8 @@ class HeadlineOperationsMixin: }} """ - # `meeting.headlines` returns only OPEN headlines (`CloseTime == null`); - # `meeting.archivedHeadlines` is a separate connection for archived ones, - # selected conditionally in this one document via `@include` so `list()` - # never issues more than one request. Verified live against production - # (`archivedHeadlines` is omitted from the response entirely when - # `$includeArchived` is `false`). + # `meeting.headlines` holds only open headlines; archived ones are in the + # separate `archivedHeadlines` connection. _HEADLINE_MEETING_LIST_QUERY = f""" query($meetingId: Long!, $includeArchived: Boolean!) {{ meeting(id: $meetingId) {{ @@ -65,12 +61,9 @@ class HeadlineOperationsMixin: }} """ - # Root `headlines(userId)` returns every headline owned by the user, - # archived ones included, with no way to filter server-side beyond - # `where`; filtering is done client-side in `list()` instead. _HEADLINE_USER_LIST_QUERY = f""" - query($userId: Long!) {{ - headlines(userId: $userId, order: [{{ dateCreated: ASC }}]) {{ + query($userId: Long!, $where: HeadlineQueryModelFilterInput) {{ + headlines(userId: $userId, where: $where, order: [{{ dateCreated: ASC }}]) {{ nodes {{ {_HEADLINE_FIELDS} }} @@ -86,10 +79,6 @@ class HeadlineOperationsMixin: } """ - # `EditHeadline` returns `IdModel { id }`, not the - # `{success message errorDetails}` shape `EditIssue` returns, so it is - # run with a plain `_execute` rather than `_run_mutation`; a failure - # surfaces as a top-level GraphQL `errors` entry instead. _HEADLINE_EDIT_MUTATION = """ mutation($input: HeadlineEditModelInput!) { EditHeadline(input: $input) { @@ -98,14 +87,68 @@ class HeadlineOperationsMixin: } """ - def _transform_headline(self, data: dict[str, Any]) -> Headline: - """Transform a raw GraphQL headline object into a `Headline` model. + @classmethod + def _headline_list_request( + cls, meeting_id: int | None, user_id: int | None, *, include_archived: bool + ) -> tuple[str, dict[str, Any]]: + """Pick the list document for a meeting or a user, with its variables. + + Root `headlines(userId)` includes archived headlines, so the user + variant filters them out with `where` unless `include_archived`. Returns: - A `Headline` model instance. + The query document and its variables. + + """ + if meeting_id is not None: + return cls._HEADLINE_MEETING_LIST_QUERY, { + "meetingId": meeting_id, + "includeArchived": include_archived, + } + where = None if include_archived else {"archived": {"eq": False}} + return cls._HEADLINE_USER_LIST_QUERY, {"userId": user_id, "where": where} + + @staticmethod + def _headlines_from(data: dict[str, Any]) -> list[Headline]: + """Validate the headlines of a meeting or user list response. + + A response carries either the meeting's connections or the root + `headlines` connection; the absent ones read as empty. + + Returns: + The headlines, ordered by creation date. + + """ + nodes = merge_nodes( + dig_nodes(data, "meeting", "headlines"), + dig_nodes(data, "meeting", "archivedHeadlines"), + dig_nodes(data, "headlines"), + ) + return [Headline.model_validate(node) for node in nodes] + + @staticmethod + def _headline_create_input( + *, meeting_id: int, title: str, user_id: int + ) -> dict[str, Any]: + """Build the `HeadlineCreateModelInput` fields, apart from notes. + + Returns: + The input fields. + + """ + return {"title": title, "assignee": user_id, "meetings": [meeting_id]} + + @staticmethod + def _headline_edit_fields( + *, title: str | None, user_id: int | None + ) -> dict[str, Any]: + """Build the `HeadlineEditModelInput` fields that were given, apart from notes. + + Returns: + The non-`None` input fields. """ - return Headline(**data, notes=extract_notes(data)) + return compact(title=title, assignee=user_id) class HeadlineOperations(GraphQLOperations, HeadlineOperationsMixin): @@ -128,8 +171,8 @@ def details(self, headline_id: int) -> Headline: """ data = self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) - return self._transform_headline( - self._require_entity(data, "headline", headline_id, "Headline") + return Headline.model_validate( + self._one(data, "headline", label="Headline", entity_id=headline_id) ) def list( @@ -161,28 +204,14 @@ def list( # Returns: [Headline(id=1, title='Headline 1', ...), ...] ``` - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - if meeting_id is not None: - data = self._execute( - self._HEADLINE_MEETING_LIST_QUERY, - {"meetingId": meeting_id, "includeArchived": include_archived}, - ) - merged = merge_nodes( - dig_nodes(data, "meeting", "headlines"), - dig_nodes(data, "meeting", "archivedHeadlines"), - ) - return [self._transform_headline(node) for node in merged] - - if user_id is None: + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: user_id = self.user_id - data = self._execute(self._HEADLINE_USER_LIST_QUERY, {"userId": user_id}) - nodes = dig_nodes(data, "headlines") - if not include_archived: - nodes = [node for node in nodes if not node.get("archived")] - return [self._transform_headline(node) for node in nodes] + query, variables = self._headline_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._headlines_from(self._execute(query, variables)) def create( self, @@ -213,21 +242,16 @@ def create( """ if user_id is None: user_id = self.user_id - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "meetings": [meeting_id], - } - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - data = self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) - headline_id = self._require_created_id( - data.get("CreateHeadline"), label="headline" + input_ = self._headline_create_input( + meeting_id=meeting_id, title=title, user_id=user_id ) - return self.details(headline_id) + result = self._mutate( + self._HEADLINE_CREATE_MUTATION, + {"input": {**input_, **self._notes_input(notes)}}, + root_field="CreateHeadline", + action="create headline", + ) + return self.details(result["id"]) def update( self, @@ -258,20 +282,11 @@ def update( ``` """ - if title is None and user_id is None and notes is None: + fields = self._headline_edit_fields(title=title, user_id=user_id) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"headlineId": headline_id} - if title is not None: - input_["title"] = title - if user_id is not None: - input_["assignee"] = user_id - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - self._execute(self._HEADLINE_EDIT_MUTATION, {"input": input_}) - return self.details(headline_id) + fields.update(self._notes_input(notes)) + return self._edit(headline_id, fields, action="update headline") def archive(self, headline_id: int) -> Headline: """Archive a headline. @@ -283,11 +298,7 @@ def archive(self, headline_id: int) -> Headline: The updated `Headline`. """ - self._execute( - self._HEADLINE_EDIT_MUTATION, - {"input": {"headlineId": headline_id, "archived": True}}, - ) - return self.details(headline_id) + return self._edit(headline_id, {"archived": True}, action="archive headline") def restore(self, headline_id: int) -> Headline: """Restore an archived headline. @@ -299,9 +310,22 @@ def restore(self, headline_id: int) -> Headline: The updated `Headline`. """ - self._execute( + return self._edit(headline_id, {"archived": False}, action="restore headline") + + def _edit( + self, headline_id: int, fields: dict[str, Any], *, action: str + ) -> Headline: + """Run `EditHeadline` with `fields`, then re-read the headline. + + Returns: + The updated `Headline`. + + """ + self._mutate( self._HEADLINE_EDIT_MUTATION, - {"input": {"headlineId": headline_id, "archived": False}}, + {"input": {"headlineId": headline_id, **fields}}, + root_field="EditHeadline", + action=action, ) return self.details(headline_id) @@ -320,8 +344,8 @@ async def details(self, headline_id: int) -> Headline: """ data = await self._execute(self._HEADLINE_DETAILS_QUERY, {"id": headline_id}) - return self._transform_headline( - self._require_entity(data, "headline", headline_id, "Headline") + return Headline.model_validate( + self._one(data, "headline", label="Headline", entity_id=headline_id) ) async def list( @@ -347,28 +371,14 @@ async def list( Raises: ValueError: If both `meeting_id` and `user_id` are provided. - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - if meeting_id is not None: - data = await self._execute( - self._HEADLINE_MEETING_LIST_QUERY, - {"meetingId": meeting_id, "includeArchived": include_archived}, - ) - merged = merge_nodes( - dig_nodes(data, "meeting", "headlines"), - dig_nodes(data, "meeting", "archivedHeadlines"), - ) - return [self._transform_headline(node) for node in merged] - - if user_id is None: + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: user_id = await self.get_user_id() - data = await self._execute(self._HEADLINE_USER_LIST_QUERY, {"userId": user_id}) - nodes = dig_nodes(data, "headlines") - if not include_archived: - nodes = [node for node in nodes if not node.get("archived")] - return [self._transform_headline(node) for node in nodes] + query, variables = self._headline_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._headlines_from(await self._execute(query, variables)) async def create( self, @@ -393,21 +403,16 @@ async def create( """ if user_id is None: user_id = await self.get_user_id() - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "meetings": [meeting_id], - } - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - data = await self._execute(self._HEADLINE_CREATE_MUTATION, {"input": input_}) - headline_id = self._require_created_id( - data.get("CreateHeadline"), label="headline" + input_ = self._headline_create_input( + meeting_id=meeting_id, title=title, user_id=user_id ) - return await self.details(headline_id) + result = await self._mutate( + self._HEADLINE_CREATE_MUTATION, + {"input": {**input_, **await self._notes_input(notes)}}, + root_field="CreateHeadline", + action="create headline", + ) + return await self.details(result["id"]) async def update( self, @@ -432,20 +437,11 @@ async def update( ValueError: If no update fields are provided. """ - if title is None and user_id is None and notes is None: + fields = self._headline_edit_fields(title=title, user_id=user_id) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"headlineId": headline_id} - if title is not None: - input_["title"] = title - if user_id is not None: - input_["assignee"] = user_id - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - await self._execute(self._HEADLINE_EDIT_MUTATION, {"input": input_}) - return await self.details(headline_id) + fields.update(await self._notes_input(notes)) + return await self._edit(headline_id, fields, action="update headline") async def archive(self, headline_id: int) -> Headline: """Archive a headline. @@ -457,11 +453,9 @@ async def archive(self, headline_id: int) -> Headline: The updated `Headline`. """ - await self._execute( - self._HEADLINE_EDIT_MUTATION, - {"input": {"headlineId": headline_id, "archived": True}}, + return await self._edit( + headline_id, {"archived": True}, action="archive headline" ) - return await self.details(headline_id) async def restore(self, headline_id: int) -> Headline: """Restore an archived headline. @@ -473,8 +467,23 @@ async def restore(self, headline_id: int) -> Headline: The updated `Headline`. """ - await self._execute( + return await self._edit( + headline_id, {"archived": False}, action="restore headline" + ) + + async def _edit( + self, headline_id: int, fields: dict[str, Any], *, action: str + ) -> Headline: + """Run `EditHeadline` with `fields`, then re-read the headline. + + Returns: + The updated `Headline`. + + """ + await self._mutate( self._HEADLINE_EDIT_MUTATION, - {"input": {"headlineId": headline_id, "archived": False}}, + {"input": {"headlineId": headline_id, **fields}}, + root_field="EditHeadline", + action=action, ) return await self.details(headline_id) diff --git a/src/bloomy/v2/operations/issue.py b/src/bloomy/v2/operations/issue.py index 55f44e5..d74f059 100644 --- a/src/bloomy/v2/operations/issue.py +++ b/src/bloomy/v2/operations/issue.py @@ -6,24 +6,24 @@ from typing import Any from ..base import ( + MEETING_REF_FIELDS, MUTATION_RESULT_FIELDS, + NOTES_FIELDS, + USER_REF_FIELDS, AsyncGraphQLOperations, GraphQLOperations, + compact, dig_nodes, - extract_notes, merge_nodes, + now_timestamp, ) from ..models import Issue -_ISSUE_FIELDS = """ +_ISSUE_FIELDS = f""" id title recurrenceId addToDepartmentPlan -notesId -notesText -localHtml -collaborationEnabled completed completedTimestamp archived @@ -32,38 +32,51 @@ priorityVoteRank numStarVotes issueNumber -assignee { id fullName } -meeting { id name } +{NOTES_FIELDS} +{USER_REF_FIELDS} """ +_ISSUE_LIST_CONNECTIONS = ( + "issues", + "longTermIssues", + "recentlySolvedIssues", + "archivedIssues", +) + class IssueOperationsMixin: - """Shared GraphQL documents and response transforms for issue operations.""" + """GraphQL documents, inputs, and response parsing shared by issue operations.""" _ISSUE_DETAILS_QUERY = f""" query($id: Long!) {{ issue(id: $id) {{ {_ISSUE_FIELDS} + {MEETING_REF_FIELDS} }} }} """ - # `meeting.issues` excludes solved and archived issues unconditionally - # (it filters `CloseTime == null` server-side); `recentlySolvedIssues` - # and `archivedIssues` are separate connections for those, selected - # conditionally in this one document via `@include` so `list()` never - # issues more than one request. Verified live against production - # (`recentlySolvedIssues`/`archivedIssues` are omitted from the response - # entirely when their `@include(if: ...)` variable is `false`). + # `issues` drops solved and archived issues server-side, and the API stores + # long-term issues as archived, so each of those lists is its own connection. _ISSUE_LIST_QUERY = f""" query( $meetingId: Long! $where: IssueQueryModelFilterInput + $longTerm: Boolean! $includeSolved: Boolean! $includeArchived: Boolean! ) {{ meeting(id: $meetingId) {{ - issues(where: $where, order: [{{ dateCreated: ASC }}]) {{ + id + name + issues(where: $where, order: [{{ dateCreated: ASC }}]) + @skip(if: $longTerm) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + longTermIssues(where: $where, order: [{{ dateCreated: ASC }}]) + @include(if: $longTerm) {{ nodes {{ {_ISSUE_FIELDS} }} @@ -84,29 +97,15 @@ class IssueOperationsMixin: }} """ - # The API stores every long-term (department plan) issue with - # `archived: true`, so `meeting.issues` (which drops archived issues - # server-side) never returns them; this dedicated connection does. - _ISSUE_LONG_TERM_QUERY = f""" - query($meetingId: Long!, $where: IssueQueryModelFilterInput) {{ - meeting(id: $meetingId) {{ - longTermIssues(where: $where, order: [{{ dateCreated: ASC }}]) {{ - nodes {{ - {_ISSUE_FIELDS} - }} - }} + _ISSUE_CREATE_MUTATION = f""" + mutation($input: IssueCreateModelInput!) {{ + CreateIssue(input: $input) {{ + {_ISSUE_FIELDS} + {MEETING_REF_FIELDS} }} }} """ - _ISSUE_CREATE_MUTATION = """ - mutation($input: IssueCreateModelInput!) { - CreateIssue(input: $input) { - id - } - } - """ - _ISSUE_EDIT_MUTATION = f""" mutation($input: IssueEditModelInput!) {{ EditIssue(input: $input) {{ @@ -115,38 +114,89 @@ class IssueOperationsMixin: }} """ - def _issue_where(self, *, long_term: bool) -> dict[str, Any]: - """Build the base `where` filter shared by every issue list connection. + @staticmethod + def _issue_list_variables( + meeting_id: int, + *, + long_term: bool, + include_solved: bool, + include_archived: bool, + ) -> dict[str, Any]: + """Build the variables for `_ISSUE_LIST_QUERY`. Returns: - An `IssueQueryModelFilterInput`-shaped dictionary. + The query variables. """ conditions: list[dict[str, Any]] = [{"addToDepartmentPlan": {"eq": long_term}}] if not long_term: - # Excludes issues sent to another meeting; only meaningful for the - # short-term open list (mirrors the web app's own query). + # Drops issues sent to another meeting, as the web app's open list does. conditions.append({"sentToIssueMeetingName": {"eq": None}}) - return {"and": conditions} + return { + "meetingId": meeting_id, + "where": {"and": conditions}, + "longTerm": long_term, + "includeSolved": include_solved and not long_term, + "includeArchived": include_archived and not long_term, + } - def _transform_issue(self, data: dict[str, Any]) -> Issue: - """Transform a raw GraphQL issue object into an `Issue` model. + @staticmethod + def _issues_from(data: dict[str, Any]) -> list[Issue]: + """Validate the issues of an `_ISSUE_LIST_QUERY` response. - The API reports every long-term issue as archived, while the web app - lists them on the Long-Term tab. Archiving a long-term issue clears - `addToDepartmentPlan`, so an issue counts as archived only when it is - archived and not long-term. + Every connection is keyed by the issue's recurrence, so each issue's + meeting ref is the parent meeting's. Returns: - An `Issue` model instance. + The issues of every selected connection, ordered by creation date. + + """ + meeting: dict[str, Any] = data.get("meeting") or {} + meeting_ref = {"id": meeting.get("id"), "name": meeting.get("name")} + nodes = merge_nodes( + *(dig_nodes(meeting, name) for name in _ISSUE_LIST_CONNECTIONS) + ) + return [ + Issue.model_validate({**node, "meeting": meeting_ref}) for node in nodes + ] + + @staticmethod + def _issue_create_input( + *, meeting_id: int, title: str, user_id: int, long_term: bool + ) -> dict[str, Any]: + """Build the `IssueCreateModelInput` fields, apart from notes. + + Returns: + The input fields. """ - fields: dict[str, Any] = { - **data, - "archived": bool(data.get("archived")) - and not data.get("addToDepartmentPlan"), + return { + "title": title, + "ownerId": user_id, + "recurrenceId": meeting_id, + "addToDepartmentPlan": long_term, } - return Issue(**fields, notes=extract_notes(data)) + + @staticmethod + def _issue_edit_fields( + *, + title: str | None, + user_id: int | None, + long_term: bool | None, + meeting_id: int | None, + ) -> dict[str, Any]: + """Build the `IssueEditModelInput` fields that were given, apart from notes. + + Returns: + The non-`None` input fields. + + """ + return compact( + title=title, + assigneeId=user_id, + addToDepartmentPlan=long_term, + meetingId=meeting_id, + ) class IssueOperations(GraphQLOperations, IssueOperationsMixin): @@ -169,8 +219,8 @@ def details(self, issue_id: int) -> Issue: """ data = self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) - return self._transform_issue( - self._require_entity(data, "issue", issue_id, "Issue") + return Issue.model_validate( + self._one(data, "issue", label="Issue", entity_id=issue_id) ) def list( @@ -201,30 +251,13 @@ def list( ``` """ - where = self._issue_where(long_term=long_term) - - if long_term: - data = self._execute( - self._ISSUE_LONG_TERM_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes = dig_nodes(data, "meeting", "longTermIssues") - return [self._transform_issue(node) for node in nodes] - - data = self._execute( - self._ISSUE_LIST_QUERY, - { - "meetingId": meeting_id, - "where": where, - "includeSolved": include_solved, - "includeArchived": include_archived, - }, - ) - merged = merge_nodes( - dig_nodes(data, "meeting", "issues"), - dig_nodes(data, "meeting", "recentlySolvedIssues"), - dig_nodes(data, "meeting", "archivedIssues"), + variables = self._issue_list_variables( + meeting_id, + long_term=long_term, + include_solved=include_solved, + include_archived=include_archived, ) - return [self._transform_issue(node) for node in merged] + return self._issues_from(self._execute(self._ISSUE_LIST_QUERY, variables)) def create( self, @@ -257,20 +290,16 @@ def create( """ if user_id is None: user_id = self.user_id - - input_: dict[str, Any] = { - "title": title, - "ownerId": user_id, - "recurrenceId": meeting_id, - "addToDepartmentPlan": long_term, - } - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - data = self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) - issue_id = self._require_created_id(data.get("CreateIssue"), label="issue") - return self.details(issue_id) + input_ = self._issue_create_input( + meeting_id=meeting_id, title=title, user_id=user_id, long_term=long_term + ) + result = self._mutate( + self._ISSUE_CREATE_MUTATION, + {"input": {**input_, **self._notes_input(notes)}}, + root_field="CreateIssue", + action="create issue", + ) + return Issue.model_validate(result) def update( self, @@ -306,35 +335,13 @@ def update( ``` """ - if ( - title is None - and user_id is None - and notes is None - and long_term is None - and meeting_id is None - ): - raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"id": issue_id} - if title is not None: - input_["title"] = title - if user_id is not None: - input_["assigneeId"] = user_id - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - if long_term is not None: - input_["addToDepartmentPlan"] = long_term - if meeting_id is not None: - input_["meetingId"] = meeting_id - - self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": input_}, - root_field="EditIssue", - action="update issue", + fields = self._issue_edit_fields( + title=title, user_id=user_id, long_term=long_term, meeting_id=meeting_id ) - return self.details(issue_id) + if not fields and notes is None: + raise ValueError("At least one field must be provided for update") + fields.update(self._notes_input(notes)) + return self._edit(issue_id, fields, action="update issue") def solve(self, issue_id: int) -> Issue: """Mark an issue as solved. @@ -351,19 +358,11 @@ def solve(self, issue_id: int) -> Issue: The updated `Issue`. """ - self._run_mutation( - self._ISSUE_EDIT_MUTATION, - { - "input": { - "id": issue_id, - "completed": True, - "completedTimestamp": self._now_timestamp(), - } - }, - root_field="EditIssue", + return self._edit( + issue_id, + {"completed": True, "completedTimestamp": now_timestamp()}, action="solve issue", ) - return self.details(issue_id) def reopen(self, issue_id: int) -> Issue: """Reopen a solved issue. @@ -375,13 +374,11 @@ def reopen(self, issue_id: int) -> Issue: The updated `Issue`. """ - self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "completed": False, "completedTimestamp": None}}, - root_field="EditIssue", + return self._edit( + issue_id, + {"completed": False, "completedTimestamp": None}, action="reopen issue", ) - return self.details(issue_id) def archive(self, issue_id: int) -> Issue: """Archive an issue. @@ -393,13 +390,7 @@ def archive(self, issue_id: int) -> Issue: The updated `Issue`. """ - self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "archived": True}}, - root_field="EditIssue", - action="archive issue", - ) - return self.details(issue_id) + return self._edit(issue_id, {"archived": True}, action="archive issue") def restore(self, issue_id: int) -> Issue: """Restore an archived issue. @@ -414,11 +405,20 @@ def restore(self, issue_id: int) -> Issue: The updated `Issue`. """ - self._run_mutation( + return self._edit(issue_id, {"archived": False}, action="restore issue") + + def _edit(self, issue_id: int, fields: dict[str, Any], *, action: str) -> Issue: + """Run `EditIssue` with `fields`, then re-read the issue. + + Returns: + The updated `Issue`. + + """ + self._mutate( self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "archived": False}}, + {"input": {"id": issue_id, **fields}}, root_field="EditIssue", - action="restore issue", + action=action, ) return self.details(issue_id) @@ -437,8 +437,8 @@ async def details(self, issue_id: int) -> Issue: """ data = await self._execute(self._ISSUE_DETAILS_QUERY, {"id": issue_id}) - return self._transform_issue( - self._require_entity(data, "issue", issue_id, "Issue") + return Issue.model_validate( + self._one(data, "issue", label="Issue", entity_id=issue_id) ) async def list( @@ -463,30 +463,13 @@ async def list( A list of `Issue` model instances, ordered by creation date. """ - where = self._issue_where(long_term=long_term) - - if long_term: - data = await self._execute( - self._ISSUE_LONG_TERM_QUERY, {"meetingId": meeting_id, "where": where} - ) - nodes = dig_nodes(data, "meeting", "longTermIssues") - return [self._transform_issue(node) for node in nodes] - - data = await self._execute( - self._ISSUE_LIST_QUERY, - { - "meetingId": meeting_id, - "where": where, - "includeSolved": include_solved, - "includeArchived": include_archived, - }, - ) - merged = merge_nodes( - dig_nodes(data, "meeting", "issues"), - dig_nodes(data, "meeting", "recentlySolvedIssues"), - dig_nodes(data, "meeting", "archivedIssues"), + variables = self._issue_list_variables( + meeting_id, + long_term=long_term, + include_solved=include_solved, + include_archived=include_archived, ) - return [self._transform_issue(node) for node in merged] + return self._issues_from(await self._execute(self._ISSUE_LIST_QUERY, variables)) async def create( self, @@ -513,20 +496,16 @@ async def create( """ if user_id is None: user_id = await self.get_user_id() - - input_: dict[str, Any] = { - "title": title, - "ownerId": user_id, - "recurrenceId": meeting_id, - "addToDepartmentPlan": long_term, - } - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - data = await self._execute(self._ISSUE_CREATE_MUTATION, {"input": input_}) - issue_id = self._require_created_id(data.get("CreateIssue"), label="issue") - return await self.details(issue_id) + input_ = self._issue_create_input( + meeting_id=meeting_id, title=title, user_id=user_id, long_term=long_term + ) + result = await self._mutate( + self._ISSUE_CREATE_MUTATION, + {"input": {**input_, **await self._notes_input(notes)}}, + root_field="CreateIssue", + action="create issue", + ) + return Issue.model_validate(result) async def update( self, @@ -556,35 +535,13 @@ async def update( ValueError: If no update fields are provided. """ - if ( - title is None - and user_id is None - and notes is None - and long_term is None - and meeting_id is None - ): - raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"id": issue_id} - if title is not None: - input_["title"] = title - if user_id is not None: - input_["assigneeId"] = user_id - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - if long_term is not None: - input_["addToDepartmentPlan"] = long_term - if meeting_id is not None: - input_["meetingId"] = meeting_id - - await self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": input_}, - root_field="EditIssue", - action="update issue", + fields = self._issue_edit_fields( + title=title, user_id=user_id, long_term=long_term, meeting_id=meeting_id ) - return await self.details(issue_id) + if not fields and notes is None: + raise ValueError("At least one field must be provided for update") + fields.update(await self._notes_input(notes)) + return await self._edit(issue_id, fields, action="update issue") async def solve(self, issue_id: int) -> Issue: """Mark an issue as solved. @@ -601,19 +558,11 @@ async def solve(self, issue_id: int) -> Issue: The updated `Issue`. """ - await self._run_mutation( - self._ISSUE_EDIT_MUTATION, - { - "input": { - "id": issue_id, - "completed": True, - "completedTimestamp": self._now_timestamp(), - } - }, - root_field="EditIssue", + return await self._edit( + issue_id, + {"completed": True, "completedTimestamp": now_timestamp()}, action="solve issue", ) - return await self.details(issue_id) async def reopen(self, issue_id: int) -> Issue: """Reopen a solved issue. @@ -625,13 +574,11 @@ async def reopen(self, issue_id: int) -> Issue: The updated `Issue`. """ - await self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "completed": False, "completedTimestamp": None}}, - root_field="EditIssue", + return await self._edit( + issue_id, + {"completed": False, "completedTimestamp": None}, action="reopen issue", ) - return await self.details(issue_id) async def archive(self, issue_id: int) -> Issue: """Archive an issue. @@ -643,13 +590,7 @@ async def archive(self, issue_id: int) -> Issue: The updated `Issue`. """ - await self._run_mutation( - self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "archived": True}}, - root_field="EditIssue", - action="archive issue", - ) - return await self.details(issue_id) + return await self._edit(issue_id, {"archived": True}, action="archive issue") async def restore(self, issue_id: int) -> Issue: """Restore an archived issue. @@ -664,10 +605,21 @@ async def restore(self, issue_id: int) -> Issue: The updated `Issue`. """ - await self._run_mutation( + return await self._edit(issue_id, {"archived": False}, action="restore issue") + + async def _edit( + self, issue_id: int, fields: dict[str, Any], *, action: str + ) -> Issue: + """Run `EditIssue` with `fields`, then re-read the issue. + + Returns: + The updated `Issue`. + + """ + await self._mutate( self._ISSUE_EDIT_MUTATION, - {"input": {"id": issue_id, "archived": False}}, + {"input": {"id": issue_id, **fields}}, root_field="EditIssue", - action="restore issue", + action=action, ) return await self.details(issue_id) diff --git a/src/bloomy/v2/operations/meeting.py b/src/bloomy/v2/operations/meeting.py index 67e519d..2256e55 100644 --- a/src/bloomy/v2/operations/meeting.py +++ b/src/bloomy/v2/operations/meeting.py @@ -3,14 +3,26 @@ from __future__ import annotations import builtins -from typing import Any from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes from ..models import Meeting, MeetingListItem, User +_ATTENDEES_FIELD = """ +attendees(order: [{ fullName: ASC }]) { + nodes { + id + firstName + lastName + fullName + avatar + user { email } + } +} +""" + class MeetingOperationsMixin: - """Shared GraphQL documents and response transforms for meeting operations.""" + """GraphQL documents shared by meeting operations.""" # `user(id){ meetingsListLookup }` is used instead of the root `meetings` # field, which is expensive and can trip the server's execution timeout. @@ -35,8 +47,6 @@ class MeetingOperationsMixin: } """ - _ATTENDEE_FIELDS = "id firstName lastName fullName avatar user { email }" - _MEETING_DETAILS_QUERY = f""" query($id: Long!) {{ meeting(id: $id) {{ @@ -46,11 +56,7 @@ class MeetingOperationsMixin: meetingType createdTimestamp archived - attendees(order: [{{ fullName: ASC }}]) {{ - nodes {{ - {_ATTENDEE_FIELDS} - }} - }} + {_ATTENDEES_FIELD} }} }} """ @@ -58,65 +64,11 @@ class MeetingOperationsMixin: _MEETING_ATTENDEES_QUERY = f""" query($id: Long!) {{ meeting(id: $id) {{ - attendees(order: [{{ fullName: ASC }}]) {{ - nodes {{ - {_ATTENDEE_FIELDS} - }} - }} + {_ATTENDEES_FIELD} }} }} """ - def _transform_attendee(self, node: dict[str, Any]) -> User: - """Transform a raw `attendees` connection node into a `User` model. - - Returns: - A `User` model instance. - - """ - user_ref: dict[str, Any] = node.get("user") or {} - # Constructed with alias keyword names (matching the raw GraphQL - # field names) rather than the model's snake_case field names: the - # model declares `Field(alias=...)` for these, and that alias is - # what pydantic's generated `__init__` exposes to static type - # checkers even though `validate_by_name=True` also accepts the - # field name at runtime. - return User( - id=node["id"], - firstName=node.get("firstName"), - lastName=node.get("lastName"), - fullName=node.get("fullName"), - avatar=node.get("avatar"), - email=user_ref.get("email"), - ) - - def _transform_meeting_list_item(self, node: dict[str, Any]) -> MeetingListItem: - """Transform a raw `meetingsListLookup` node into a `MeetingListItem`. - - Returns: - A `MeetingListItem` model instance. - - """ - return MeetingListItem(**node) - - def _transform_meeting(self, data: dict[str, Any]) -> Meeting: - """Transform a raw `meeting(id)` response into a `Meeting` model. - - Returns: - A `Meeting` model instance. - - """ - attendee_nodes = dig_nodes(data, "attendees") - return Meeting( - id=data["id"], - name=data.get("name"), - orgId=data.get("orgId"), - meetingType=data.get("meetingType"), - createdTimestamp=data["createdTimestamp"], - archived=data.get("archived", False), - attendees=[self._transform_attendee(node) for node in attendee_nodes], - ) - class MeetingOperations(GraphQLOperations, MeetingOperationsMixin): """Class to handle v2 (GraphQL) operations related to meetings.""" @@ -139,10 +91,11 @@ def list(self, user_id: int | None = None) -> builtins.list[MeetingListItem]: """ if user_id is None: user_id = self.user_id - data = self._execute(self._MEETING_LIST_QUERY, {"userId": user_id}) - nodes = dig_nodes(data, "user", "meetingsListLookup") - return [self._transform_meeting_list_item(node) for node in nodes] + return [ + MeetingListItem.model_validate(node) + for node in dig_nodes(data, "user", "meetingsListLookup") + ] def details(self, meeting_id: int) -> Meeting: """Get details for a meeting, including its attendees. @@ -161,8 +114,8 @@ def details(self, meeting_id: int) -> Meeting: """ data = self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) - return self._transform_meeting( - self._require_entity(data, "meeting", meeting_id, "Meeting") + return Meeting.model_validate( + self._one(data, "meeting", label="Meeting", entity_id=meeting_id) ) def attendees(self, meeting_id: int) -> builtins.list[User]: @@ -176,8 +129,10 @@ def attendees(self, meeting_id: int) -> builtins.list[User]: """ data = self._execute(self._MEETING_ATTENDEES_QUERY, {"id": meeting_id}) - nodes = dig_nodes(data, "meeting", "attendees") - return [self._transform_attendee(node) for node in nodes] + return [ + User.model_validate(node) + for node in dig_nodes(data, "meeting", "attendees") + ] class AsyncMeetingOperations(AsyncGraphQLOperations, MeetingOperationsMixin): @@ -195,10 +150,11 @@ async def list(self, user_id: int | None = None) -> builtins.list[MeetingListIte """ if user_id is None: user_id = await self.get_user_id() - data = await self._execute(self._MEETING_LIST_QUERY, {"userId": user_id}) - nodes = dig_nodes(data, "user", "meetingsListLookup") - return [self._transform_meeting_list_item(node) for node in nodes] + return [ + MeetingListItem.model_validate(node) + for node in dig_nodes(data, "user", "meetingsListLookup") + ] async def details(self, meeting_id: int) -> Meeting: """Get details for a meeting, including its attendees. @@ -211,8 +167,8 @@ async def details(self, meeting_id: int) -> Meeting: """ data = await self._execute(self._MEETING_DETAILS_QUERY, {"id": meeting_id}) - return self._transform_meeting( - self._require_entity(data, "meeting", meeting_id, "Meeting") + return Meeting.model_validate( + self._one(data, "meeting", label="Meeting", entity_id=meeting_id) ) async def attendees(self, meeting_id: int) -> builtins.list[User]: @@ -226,5 +182,7 @@ async def attendees(self, meeting_id: int) -> builtins.list[User]: """ data = await self._execute(self._MEETING_ATTENDEES_QUERY, {"id": meeting_id}) - nodes = dig_nodes(data, "meeting", "attendees") - return [self._transform_attendee(node) for node in nodes] + return [ + User.model_validate(node) + for node in dig_nodes(data, "meeting", "attendees") + ] diff --git a/src/bloomy/v2/operations/metric.py b/src/bloomy/v2/operations/metric.py index 7a75b19..9d2f2a8 100644 --- a/src/bloomy/v2/operations/metric.py +++ b/src/bloomy/v2/operations/metric.py @@ -3,21 +3,25 @@ from __future__ import annotations import builtins -from datetime import UTC, date, datetime from typing import Any from ...exceptions import GraphQLError -from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes, extract_notes +from ..base import ( + NOTES_FIELDS, + USER_REF_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + TimeInput, + compact, + dig_nodes, + to_timestamp, + to_utc_datetime, +) from ..models import Metric, MetricFrequency, MetricRule, MetricScore, MetricUnit -# Deliberately does NOT select `id`: on `meeting(id){ metrics }` that field is -# the meeting-link id (`L10Recurrence_Measurable`), not the metric id, so -# selecting it risks the raw dict's `id` key shadowing `measurableId` when -# spread into `Metric(**data)` (both map to the model's aliased `id` field -# under `validate_by_name=True`). `measurableId` alone is always the real -# metric id, on every query path (`metric(id)`, `meeting.metrics`, -# `user.metrics`). -_METRIC_FIELDS = """ +# `id` is not selected: on `meeting.metrics` it is the meeting-link id, while +# `measurableId` is the metric id on every query path. +_METRIC_FIELDS = f""" measurableId title units @@ -27,21 +31,31 @@ minGoalValue maxGoalValue archived -metricType -notesId -notesText -localHtml -collaborationEnabled dateCreated isExternallySynced -assignee { id fullName } +{NOTES_FIELDS} +{USER_REF_FIELDS} """ _METRIC_SCORE_FIELDS = "id value timestamp notesText measurableId" +# A DAILY score is stored at the start of its day, not at the requested time, so +# `set_score()` searches this many seconds either side and matches the UTC day. +_DAILY_SCORE_SEARCH_WINDOW = 2 * 24 * 60 * 60 + + +def _text(value: object) -> str | None: + """Stringify an enum or decimal input value, as the API expects. + + Returns: + `str(value)`, or `None` if `value` is `None`. + + """ + return None if value is None else str(value) + class MetricOperationsMixin: - """Shared GraphQL documents and response transforms for metric operations.""" + """GraphQL documents, inputs, and response parsing shared by metric operations.""" _METRIC_DETAILS_QUERY = f""" query($id: Long!) {{ @@ -51,10 +65,6 @@ class MetricOperationsMixin: }} """ - # `meeting.metrics` also returns metric-divider rows (`metricType: - # DIVIDER`, `measurableId: 0`) mixed in with real metrics; the `where` - # filter excludes them server-side (and archived rows, which this - # connection does not exclude on its own, unlike `user.metrics`). _METRIC_MEETING_LIST_QUERY = f""" query($meetingId: Long!, $where: MetricQueryModelFilterInput) {{ meeting(id: $meetingId) {{ @@ -67,9 +77,6 @@ class MetricOperationsMixin: }} """ - # `user(id){ metrics }` already excludes archived/deleted metrics - # server-side; the `archived` filter is kept for parity with the - # meeting-scoped query above. _METRIC_USER_LIST_QUERY = f""" query($userId: Long!, $where: MetricQueryModelFilterInput) {{ user(id: $userId) {{ @@ -82,11 +89,6 @@ class MetricOperationsMixin: }} """ - # CreateMetric/EditMetric return only `IdModel { id }` (verified live - # against production), not the `{success message errorDetails}` shape - # `_run_mutation` expects. A failed create/edit raises through the - # standard `errors` array instead, so these are executed with plain - # `_execute`. _METRIC_CREATE_MUTATION = """ mutation($input: MetricCreateModelInput!) { CreateMetric(input: $input) { @@ -103,9 +105,6 @@ class MetricOperationsMixin: } """ - # `scoresNonPaginated` is a plain list, not a `{ nodes }` connection - # (unlike every other list field in the v2 API), so it is read directly - # rather than through `dig_nodes`. _METRIC_SCORES_QUERY = f""" query($metricId: Long!, $where: MetricScoreQueryModelFilterInput) {{ metric(id: $metricId) {{ @@ -116,10 +115,6 @@ class MetricOperationsMixin: }} """ - # CreateMetricScore/EditMetricScore also return `IdModel { id }`. - # CreateMetricScore additionally returns `{"CreateMetricScore": null}` - # with no `errors` entry when `value` cannot be parsed as a decimal - # (verified live) -- `set_score()` checks for that explicitly. _METRIC_SCORE_CREATE_MUTATION = """ mutation($input: MetricScoreCreateModelInput!) { CreateMetricScore(input: $input) { @@ -136,12 +131,21 @@ class MetricOperationsMixin: } """ - @staticmethod - def _metric_where(*, frequency: MetricFrequency | str | None) -> dict[str, Any]: - """Build the `where` filter shared by both metric list connections. + @classmethod + def _metric_list_request( + cls, + meeting_id: int | None, + user_id: int | None, + *, + frequency: MetricFrequency | str | None, + ) -> tuple[str, dict[str, Any]]: + """Pick the list document for a meeting or a user, with its variables. + + `meeting.metrics` also returns archived metrics and scorecard dividers + (`metricType: DIVIDER`), so both documents filter them out with `where`. Returns: - A `MetricQueryModelFilterInput`-shaped dictionary. + The query document and its variables. """ conditions: list[dict[str, Any]] = [ @@ -150,25 +154,30 @@ def _metric_where(*, frequency: MetricFrequency | str | None) -> dict[str, Any]: ] if frequency is not None: conditions.append({"frequency": {"eq": str(frequency)}}) - return {"and": conditions} + where = {"and": conditions} + if meeting_id is not None: + return cls._METRIC_MEETING_LIST_QUERY, { + "meetingId": meeting_id, + "where": where, + } + return cls._METRIC_USER_LIST_QUERY, {"userId": user_id, "where": where} @staticmethod - def _metric_score_where( - start_ts: float | None, end_ts: float | None - ) -> dict[str, Any] | None: - """Build the `where` filter for a score timestamp range. + def _metrics_from(data: dict[str, Any]) -> list[Metric]: + """Validate the metrics of a meeting or user list response. + + A response carries either `meeting` or `user`; the absent one reads as + empty. Returns: - A `MetricScoreQueryModelFilterInput`-shaped dictionary, or `None` - if neither bound is given (no filtering). + The metrics. """ - conditions: list[dict[str, Any]] = [] - if start_ts is not None: - conditions.append({"timestamp": {"gte": start_ts}}) - if end_ts is not None: - conditions.append({"timestamp": {"lte": end_ts}}) - return {"and": conditions} if conditions else None + return [ + Metric.model_validate(node) + for owner in ("meeting", "user") + for node in dig_nodes(data, owner, "metrics") + ] @staticmethod def _metric_goal_conflict( @@ -177,18 +186,14 @@ def _metric_goal_conflict( min_goal: float | str | None, max_goal: float | str | None, ) -> str | None: - """Check that goal fields match the BETWEEN/non-BETWEEN rule split. + """Check that the goal fields match the rule. - `singleGoalValue` (`goal`) and `minGoalValue`/`maxGoalValue` - (`min_goal`/`max_goal`) are mutually exclusive server-side: `goal` is - for every rule except `BETWEEN`, and `min_goal`/`max_goal` are for - `BETWEEN` only. Each caller raises `ValueError` itself (so pydoclint - can see the `raise` and check the docstring), using this to build the - message. + `goal` (`singleGoalValue`) is for every rule except `BETWEEN`, and + `min_goal`/`max_goal` are for `BETWEEN` only. Callers raise the + `ValueError` themselves so pydoclint can check their docstrings. Returns: - A message describing the conflict, or `None` if the fields are - consistent. + A message describing the conflict, or `None` if there is none. """ has_range_goal = min_goal is not None or max_goal is not None @@ -207,7 +212,7 @@ def _metric_goal_conflict( return None @staticmethod - def _metric_update_fields( + def _metric_fields( *, title: str | None, user_id: int | None, @@ -217,92 +222,167 @@ def _metric_update_fields( min_goal: float | str | None, max_goal: float | str | None, ) -> dict[str, Any]: - """Build the non-`None` `EditMetric` fields shared by `update()`. + """Build the metric fields that were given, apart from notes. + + `MetricEditModelInput` and `MetricCreateModelInput` name these fields + the same way, so `create()` reuses them. Returns: - A dict of `MetricEditModelInput` fields (excluding `metricId` and - the notes fields, which `update()` sets separately). + The non-`None` input fields. """ - fields: dict[str, Any] = {} - if title is not None: - fields["title"] = title - if user_id is not None: - fields["assignee"] = user_id - for key, value in ( - ("units", units), - ("rule", rule), - ("singleGoalValue", goal), - ("minGoalValue", min_goal), - ("maxGoalValue", max_goal), - ): - if value is not None: - fields[key] = str(value) - return fields + return compact( + title=title, + assignee=user_id, + units=_text(units), + rule=_text(rule), + singleGoalValue=_text(goal), + minGoalValue=_text(min_goal), + maxGoalValue=_text(max_goal), + ) - @staticmethod - def _is_duplicate_score_error(exc: GraphQLError) -> bool: - """Check whether a `GraphQLError` is the DAILY "already exists" error. + @classmethod + def _metric_create_input( + cls, + *, + meeting_id: int, + title: str, + user_id: int, + units: MetricUnit | str, + rule: MetricRule | str, + frequency: MetricFrequency | str, + goal: float | str | None, + min_goal: float | str | None, + max_goal: float | str | None, + ) -> dict[str, Any]: + """Build the `MetricCreateModelInput` fields, apart from notes. - `CreateMetricScore` upserts weekly/monthly/quarterly scores by - period, but a DAILY score throws this error instead when one already - exists for the same day (verified live); `set_score()` falls back to - `EditMetricScore` in that case. + Returns: + The input fields. + + """ + fields = cls._metric_fields( + title=title, + user_id=user_id, + units=units, + rule=rule, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ) + return { + **fields, + "frequency": str(frequency), + "averageOverrideType": "NONE", + "meetings": [meeting_id], + "customGoals": [], + } + + @staticmethod + def _score_create_input( + metric_id: int, value: float | str, timestamp: float + ) -> dict[str, Any]: + """Build the `MetricScoreCreateModelInput` fields. Returns: - `True` if any raw error's `extensions.message` reports an - existing score for the same metric and date. + The input fields. """ - for error in exc.errors: - extensions: dict[str, Any] = error.get("extensions") or {} - message = str(extensions.get("message") or "") - if "already exists" in message.lower(): - return True - return False + return {"metricId": metric_id, "value": str(value), "timestamp": timestamp} @staticmethod - def _scores_from(data: dict[str, Any]) -> builtins.list[dict[str, Any]]: - """Read the `scoresNonPaginated` list out of a metric query's `data`. + def _scores_variables( + metric_id: int, + start: TimeInput | None, + end: TimeInput | None, + ) -> dict[str, Any]: + """Build the `_METRIC_SCORES_QUERY` variables for a timestamp range. Returns: - The raw score nodes, or an empty list if the metric was missing. + The query variables, with a `null` `where` when neither bound is + given. """ - metric: dict[str, Any] = data.get("metric") or {} - return list(metric.get("scoresNonPaginated") or []) + conditions: list[dict[str, Any]] = [] + if start is not None: + conditions.append({"timestamp": {"gte": to_timestamp(start)}}) + if end is not None: + conditions.append({"timestamp": {"lte": to_timestamp(end)}}) + return { + "metricId": metric_id, + "where": {"and": conditions} if conditions else None, + } @staticmethod - def _same_day(timestamp: float, target: date) -> bool: - """Check whether a unix-seconds timestamp falls on `target` (UTC). + def _scores_from(data: dict[str, Any], *, include_empty: bool) -> list[MetricScore]: + """Validate the scores of a `_METRIC_SCORES_QUERY` response. + + `scoresNonPaginated` is a plain list, not a `{ nodes }` connection. + + Returns: + The scores, without empty placeholders unless `include_empty`. + + """ + metric: dict[str, Any] = data.get("metric") or {} + nodes: list[dict[str, Any]] = metric.get("scoresNonPaginated") or [] + scores = [MetricScore.model_validate(node) for node in nodes] + if include_empty: + return scores + return [score for score in scores if score.value is not None] + + @classmethod + def _score_on_day( + cls, data: dict[str, Any], timestamp: float + ) -> MetricScore | None: + """Find the score of a `_METRIC_SCORES_QUERY` response on `timestamp`'s UTC day. Returns: - `True` if `timestamp`'s UTC calendar date equals `target`. + The first matching score (empty placeholders included), or `None`. """ - return datetime.fromtimestamp(timestamp, tz=UTC).date() == target + day = to_utc_datetime(timestamp).date() + for score in cls._scores_from(data, include_empty=True): + if score.week_date is not None and score.week_date.date() == day: + return score + return None - def _transform_metric(self, data: dict[str, Any]) -> Metric: - """Transform a raw GraphQL metric object into a `Metric` model. + @staticmethod + def _created_score_id(data: dict[str, Any], value: float | str) -> int: + """Read the new score's id from a `CreateMetricScore` response. Returns: - A `Metric` model instance. + The score id. + + Raises: + GraphQLError: If the result is `null`, meaning `value` could not be + parsed as a decimal number. """ - return Metric(**data, notes=extract_notes(data)) + created: dict[str, Any] | None = data.get("CreateMetricScore") + if created is None: + raise GraphQLError( + f"CreateMetricScore: value {value!r} could not be parsed as a number" + ) + return int(created["id"]) - def _transform_score(self, data: dict[str, Any]) -> MetricScore: - """Transform a raw GraphQL metric-score object into a `MetricScore` model. + @staticmethod + def _is_duplicate_score_error(exc: GraphQLError) -> bool: + """Check whether `CreateMetricScore` failed because a DAILY score exists. - Scores carry a plain `notesText` note (no Etherpad pad, unlike - metrics themselves), so this reads it directly rather than through - `extract_notes`. + `CreateMetricScore` upserts weekly/monthly/quarterly scores by period, + but for a DAILY metric it raises this error when the day already has a + score (verified live). Returns: - A `MetricScore` model instance. + `True` if any raw error's `extensions.message` says the score + already exists. """ - return MetricScore(**data, notes=data.get("notesText") or None) + for error in exc.errors: + extensions: dict[str, Any] = error.get("extensions") or {} + if "already exists" in str(extensions.get("message") or "").lower(): + return True + return False class MetricOperations(GraphQLOperations, MetricOperationsMixin): @@ -332,8 +412,8 @@ def details(self, metric_id: int) -> Metric: """ data = self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) - return self._transform_metric( - self._require_entity(data, "metric", metric_id, "Metric") + return Metric.model_validate( + self._one(data, "metric", label="Metric", entity_id=metric_id) ) def list( @@ -367,31 +447,14 @@ def list( # Returns: [Metric(id=2036155, title='New Customers', ...), ...] ``` - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._metric_where(frequency=frequency) - - if meeting_id is not None: - data = self._execute( - self._METRIC_MEETING_LIST_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "metrics") - else: - if user_id is None: - user_id = self.user_id - data = self._execute( - self._METRIC_USER_LIST_QUERY, {"userId": user_id, "where": where} - ) - nodes = dig_nodes(data, "user", "metrics") - - return [ - self._transform_metric(node) - for node in nodes - if node.get("metricType") != "DIVIDER" - ] + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: + user_id = self.user_id + query, variables = self._metric_list_request( + meeting_id, user_id, frequency=frequency + ) + return self._metrics_from(self._execute(query, variables)) def create( self, @@ -444,30 +507,24 @@ def create( raise ValueError(conflict) if user_id is None: user_id = self.user_id - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "units": str(units), - "rule": str(rule), - "frequency": str(frequency), - "averageOverrideType": "NONE", - "meetings": [meeting_id], - "customGoals": [], - } - if goal is not None: - input_["singleGoalValue"] = str(goal) - if min_goal is not None: - input_["minGoalValue"] = str(min_goal) - if max_goal is not None: - input_["maxGoalValue"] = str(max_goal) - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - data = self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) - metric_id = self._require_created_id(data.get("CreateMetric"), label="metric") - return self.details(metric_id) + input_ = self._metric_create_input( + meeting_id=meeting_id, + title=title, + user_id=user_id, + units=units, + rule=rule, + frequency=frequency, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ) + result = self._mutate( + self._METRIC_CREATE_MUTATION, + {"input": {**input_, **self._notes_input(notes)}}, + root_field="CreateMetric", + action="create metric", + ) + return self.details(result["id"]) def update( self, @@ -514,34 +571,22 @@ def update( ``` """ - if all( - field is None - for field in (title, user_id, goal, min_goal, max_goal, units, rule, notes) - ): + fields = self._metric_fields( + title=title, + user_id=user_id, + units=units, + rule=rule, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) if conflict: raise ValueError(conflict) - - input_: dict[str, Any] = { - "metricId": metric_id, - **self._metric_update_fields( - title=title, - user_id=user_id, - units=units, - rule=rule, - goal=goal, - min_goal=min_goal, - max_goal=max_goal, - ), - } - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - self._execute(self._METRIC_EDIT_MUTATION, {"input": input_}) - return self.details(metric_id) + fields.update(self._notes_input(notes)) + return self._edit(metric_id, fields, action="update metric") def archive(self, metric_id: int) -> Metric: """Archive a metric. @@ -557,18 +602,14 @@ def archive(self, metric_id: int) -> Metric: The updated `Metric`. """ - self._execute( - self._METRIC_EDIT_MUTATION, - {"input": {"metricId": metric_id, "archived": True}}, - ) - return self.details(metric_id) + return self._edit(metric_id, {"archived": True}, action="archive metric") def scores( self, metric_id: int, *, - start: datetime | date | float | int | None = None, - end: datetime | date | float | int | None = None, + start: TimeInput | None = None, + end: TimeInput | None = None, include_empty: bool = False, ) -> builtins.list[MetricScore]: """List the scores of a metric. @@ -590,23 +631,15 @@ def scores( ``` """ - start_ts = self._to_timestamp(start) if start is not None else None - end_ts = self._to_timestamp(end) if end is not None else None - where = self._metric_score_where(start_ts, end_ts) - - data = self._execute( - self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} - ) - nodes = self._scores_from(data) - if not include_empty: - nodes = [node for node in nodes if node.get("value") not in (None, "")] - return [self._transform_score(node) for node in nodes] + variables = self._scores_variables(metric_id, start, end) + data = self._execute(self._METRIC_SCORES_QUERY, variables) + return self._scores_from(data, include_empty=include_empty) def set_score( self, metric_id: int, value: float | str, - timestamp: datetime | date | float | int, + timestamp: TimeInput, ) -> MetricScore: """Set a metric's score for a given time. @@ -634,32 +667,27 @@ def set_score( ``` """ - ts = self._to_timestamp(timestamp) + ts = to_timestamp(timestamp) try: data = self._execute( self._METRIC_SCORE_CREATE_MUTATION, - { - "input": { - "metricId": metric_id, - "value": str(value), - "timestamp": ts, - } - }, + {"input": self._score_create_input(metric_id, value, ts)}, ) except GraphQLError as exc: if not self._is_duplicate_score_error(exc): raise - existing = self._find_score_for_day(metric_id, ts) + variables = self._scores_variables( + metric_id, + ts - _DAILY_SCORE_SEARCH_WINDOW, + ts + _DAILY_SCORE_SEARCH_WINDOW, + ) + existing = self._score_on_day( + self._execute(self._METRIC_SCORES_QUERY, variables), ts + ) if existing is None: raise return self.update_score(metric_id, existing.id, value=value) - - result = data.get("CreateMetricScore") - if result is None: - raise GraphQLError( - f"CreateMetricScore: value {value!r} could not be parsed as a number" - ) - return self._read_score(metric_id, result["id"]) + return self._read_score(metric_id, self._created_score_id(data, value)) def update_score( self, @@ -691,17 +719,10 @@ def update_score( ``` """ - if value is None and notes is None: + fields = compact(value=_text(value), notesText=notes) + if not fields: raise ValueError("At least one of `value` or `notes` must be provided") - - input_: dict[str, Any] = {"id": score_id} - if value is not None: - input_["value"] = str(value) - if notes is not None: - input_["notesText"] = notes - - self._execute(self._METRIC_SCORE_EDIT_MUTATION, {"input": input_}) - return self._read_score(metric_id, score_id) + return self._edit_score(metric_id, score_id, fields, action="update score") def clear_score(self, metric_id: int, score_id: int) -> MetricScore: """Clear a metric score's value, leaving an empty placeholder. @@ -715,50 +736,62 @@ def clear_score(self, metric_id: int, score_id: int) -> MetricScore: The updated `MetricScore`, with `value=None`. """ - self._execute( - self._METRIC_SCORE_EDIT_MUTATION, {"input": {"id": score_id, "value": None}} + return self._edit_score( + metric_id, score_id, {"value": None}, action="clear score" ) - return self._read_score(metric_id, score_id) - def _read_score(self, metric_id: int, score_id: int) -> MetricScore: - """Re-read a single score through its parent metric. + def _edit(self, metric_id: int, fields: dict[str, Any], *, action: str) -> Metric: + """Run `EditMetric` with `fields`, then re-read the metric. Returns: - The `MetricScore` model instance. - - Raises: - GraphQLError: If the score is not found under the metric. + The updated `Metric`. """ - data = self._execute( - self._METRIC_SCORES_QUERY, - {"metricId": metric_id, "where": {"and": [{"id": {"eq": score_id}}]}}, + self._mutate( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, **fields}}, + root_field="EditMetric", + action=action, ) - for node in self._scores_from(data): - if node.get("id") == score_id: - return self._transform_score(node) - raise GraphQLError(f"score {score_id} not found under metric {metric_id}") + return self.details(metric_id) - def _find_score_for_day(self, metric_id: int, ts: float) -> MetricScore | None: - """Find the pre-existing score whose UTC calendar day matches `ts`. + def _edit_score( + self, metric_id: int, score_id: int, fields: dict[str, Any], *, action: str + ) -> MetricScore: + """Run `EditMetricScore` with `fields`, then re-read the score. + + Returns: + The updated `MetricScore`. + + """ + self._mutate( + self._METRIC_SCORE_EDIT_MUTATION, + {"input": {"id": score_id, **fields}}, + root_field="EditMetricScore", + action=action, + ) + return self._read_score(metric_id, score_id) - Used by `set_score()` to fall back to `EditMetricScore` for DAILY - metrics, where a placeholder score already exists for every day. + def _read_score(self, metric_id: int, score_id: int) -> MetricScore: + """Read a single score through its parent metric. Returns: - The matching `MetricScore`, or `None` if not found. + The `MetricScore` model instance. """ - target = datetime.fromtimestamp(ts, tz=UTC).date() - where = self._metric_score_where(ts - 172800, ts + 172800) data = self._execute( - self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + self._METRIC_SCORES_QUERY, + {"metricId": metric_id, "where": {"id": {"eq": score_id}}}, + ) + return MetricScore.model_validate( + self._one( + data, + "metric", + "scoresNonPaginated", + label="Metric score", + entity_id=score_id, + ) ) - for node in self._scores_from(data): - node_ts = node.get("timestamp") - if node_ts is not None and self._same_day(node_ts, target): - return self._transform_score(node) - return None class AsyncMetricOperations(AsyncGraphQLOperations, MetricOperationsMixin): @@ -780,8 +813,8 @@ async def details(self, metric_id: int) -> Metric: """ data = await self._execute(self._METRIC_DETAILS_QUERY, {"id": metric_id}) - return self._transform_metric( - self._require_entity(data, "metric", metric_id, "Metric") + return Metric.model_validate( + self._one(data, "metric", label="Metric", entity_id=metric_id) ) async def list( @@ -809,31 +842,14 @@ async def list( Raises: ValueError: If both `meeting_id` and `user_id` are provided. - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._metric_where(frequency=frequency) - - if meeting_id is not None: - data = await self._execute( - self._METRIC_MEETING_LIST_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "metrics") - else: - if user_id is None: - user_id = await self.get_user_id() - data = await self._execute( - self._METRIC_USER_LIST_QUERY, {"userId": user_id, "where": where} - ) - nodes = dig_nodes(data, "user", "metrics") - - return [ - self._transform_metric(node) - for node in nodes - if node.get("metricType") != "DIVIDER" - ] + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: + user_id = await self.get_user_id() + query, variables = self._metric_list_request( + meeting_id, user_id, frequency=frequency + ) + return self._metrics_from(await self._execute(query, variables)) async def create( self, @@ -880,30 +896,24 @@ async def create( raise ValueError(conflict) if user_id is None: user_id = await self.get_user_id() - - input_: dict[str, Any] = { - "title": title, - "assignee": user_id, - "units": str(units), - "rule": str(rule), - "frequency": str(frequency), - "averageOverrideType": "NONE", - "meetings": [meeting_id], - "customGoals": [], - } - if goal is not None: - input_["singleGoalValue"] = str(goal) - if min_goal is not None: - input_["minGoalValue"] = str(min_goal) - if max_goal is not None: - input_["maxGoalValue"] = str(max_goal) - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - data = await self._execute(self._METRIC_CREATE_MUTATION, {"input": input_}) - metric_id = self._require_created_id(data.get("CreateMetric"), label="metric") - return await self.details(metric_id) + input_ = self._metric_create_input( + meeting_id=meeting_id, + title=title, + user_id=user_id, + units=units, + rule=rule, + frequency=frequency, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ) + result = await self._mutate( + self._METRIC_CREATE_MUTATION, + {"input": {**input_, **await self._notes_input(notes)}}, + root_field="CreateMetric", + action="create metric", + ) + return await self.details(result["id"]) async def update( self, @@ -944,34 +954,22 @@ async def update( `max_goal` is combined with a non-`BETWEEN` `rule`. """ - if all( - field is None - for field in (title, user_id, goal, min_goal, max_goal, units, rule, notes) - ): + fields = self._metric_fields( + title=title, + user_id=user_id, + units=units, + rule=rule, + goal=goal, + min_goal=min_goal, + max_goal=max_goal, + ) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - conflict = self._metric_goal_conflict(rule, goal, min_goal, max_goal) if conflict: raise ValueError(conflict) - - input_: dict[str, Any] = { - "metricId": metric_id, - **self._metric_update_fields( - title=title, - user_id=user_id, - units=units, - rule=rule, - goal=goal, - min_goal=min_goal, - max_goal=max_goal, - ), - } - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - await self._execute(self._METRIC_EDIT_MUTATION, {"input": input_}) - return await self.details(metric_id) + fields.update(await self._notes_input(notes)) + return await self._edit(metric_id, fields, action="update metric") async def archive(self, metric_id: int) -> Metric: """Archive a metric. @@ -987,18 +985,14 @@ async def archive(self, metric_id: int) -> Metric: The updated `Metric`. """ - await self._execute( - self._METRIC_EDIT_MUTATION, - {"input": {"metricId": metric_id, "archived": True}}, - ) - return await self.details(metric_id) + return await self._edit(metric_id, {"archived": True}, action="archive metric") async def scores( self, metric_id: int, *, - start: datetime | date | float | int | None = None, - end: datetime | date | float | int | None = None, + start: TimeInput | None = None, + end: TimeInput | None = None, include_empty: bool = False, ) -> builtins.list[MetricScore]: """List the scores of a metric. @@ -1014,23 +1008,15 @@ async def scores( A list of `MetricScore` model instances, most recent first. """ - start_ts = self._to_timestamp(start) if start is not None else None - end_ts = self._to_timestamp(end) if end is not None else None - where = self._metric_score_where(start_ts, end_ts) - - data = await self._execute( - self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} - ) - nodes = self._scores_from(data) - if not include_empty: - nodes = [node for node in nodes if node.get("value") not in (None, "")] - return [self._transform_score(node) for node in nodes] + variables = self._scores_variables(metric_id, start, end) + data = await self._execute(self._METRIC_SCORES_QUERY, variables) + return self._scores_from(data, include_empty=include_empty) async def set_score( self, metric_id: int, value: float | str, - timestamp: datetime | date | float | int, + timestamp: TimeInput, ) -> MetricScore: """Set a metric's score for a given time. @@ -1052,32 +1038,27 @@ async def set_score( GraphQLError: If `value` cannot be parsed as a decimal number. """ - ts = self._to_timestamp(timestamp) + ts = to_timestamp(timestamp) try: data = await self._execute( self._METRIC_SCORE_CREATE_MUTATION, - { - "input": { - "metricId": metric_id, - "value": str(value), - "timestamp": ts, - } - }, + {"input": self._score_create_input(metric_id, value, ts)}, ) except GraphQLError as exc: if not self._is_duplicate_score_error(exc): raise - existing = await self._find_score_for_day(metric_id, ts) + variables = self._scores_variables( + metric_id, + ts - _DAILY_SCORE_SEARCH_WINDOW, + ts + _DAILY_SCORE_SEARCH_WINDOW, + ) + existing = self._score_on_day( + await self._execute(self._METRIC_SCORES_QUERY, variables), ts + ) if existing is None: raise return await self.update_score(metric_id, existing.id, value=value) - - result = data.get("CreateMetricScore") - if result is None: - raise GraphQLError( - f"CreateMetricScore: value {value!r} could not be parsed as a number" - ) - return await self._read_score(metric_id, result["id"]) + return await self._read_score(metric_id, self._created_score_id(data, value)) async def update_score( self, @@ -1103,17 +1084,12 @@ async def update_score( ValueError: If neither `value` nor `notes` is provided. """ - if value is None and notes is None: + fields = compact(value=_text(value), notesText=notes) + if not fields: raise ValueError("At least one of `value` or `notes` must be provided") - - input_: dict[str, Any] = {"id": score_id} - if value is not None: - input_["value"] = str(value) - if notes is not None: - input_["notesText"] = notes - - await self._execute(self._METRIC_SCORE_EDIT_MUTATION, {"input": input_}) - return await self._read_score(metric_id, score_id) + return await self._edit_score( + metric_id, score_id, fields, action="update score" + ) async def clear_score(self, metric_id: int, score_id: int) -> MetricScore: """Clear a metric score's value, leaving an empty placeholder. @@ -1127,49 +1103,61 @@ async def clear_score(self, metric_id: int, score_id: int) -> MetricScore: The updated `MetricScore`, with `value=None`. """ - await self._execute( - self._METRIC_SCORE_EDIT_MUTATION, {"input": {"id": score_id, "value": None}} + return await self._edit_score( + metric_id, score_id, {"value": None}, action="clear score" ) - return await self._read_score(metric_id, score_id) - async def _read_score(self, metric_id: int, score_id: int) -> MetricScore: - """Re-read a single score through its parent metric. + async def _edit( + self, metric_id: int, fields: dict[str, Any], *, action: str + ) -> Metric: + """Run `EditMetric` with `fields`, then re-read the metric. Returns: - The `MetricScore` model instance. - - Raises: - GraphQLError: If the score is not found under the metric. + The updated `Metric`. """ - data = await self._execute( - self._METRIC_SCORES_QUERY, - {"metricId": metric_id, "where": {"and": [{"id": {"eq": score_id}}]}}, + await self._mutate( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, **fields}}, + root_field="EditMetric", + action=action, ) - for node in self._scores_from(data): - if node.get("id") == score_id: - return self._transform_score(node) - raise GraphQLError(f"score {score_id} not found under metric {metric_id}") + return await self.details(metric_id) - async def _find_score_for_day( - self, metric_id: int, ts: float - ) -> MetricScore | None: - """Find the pre-existing score whose UTC calendar day matches `ts`. + async def _edit_score( + self, metric_id: int, score_id: int, fields: dict[str, Any], *, action: str + ) -> MetricScore: + """Run `EditMetricScore` with `fields`, then re-read the score. - Used by `set_score()` to fall back to `EditMetricScore` for DAILY - metrics, where a placeholder score already exists for every day. + Returns: + The updated `MetricScore`. + + """ + await self._mutate( + self._METRIC_SCORE_EDIT_MUTATION, + {"input": {"id": score_id, **fields}}, + root_field="EditMetricScore", + action=action, + ) + return await self._read_score(metric_id, score_id) + + async def _read_score(self, metric_id: int, score_id: int) -> MetricScore: + """Read a single score through its parent metric. Returns: - The matching `MetricScore`, or `None` if not found. + The `MetricScore` model instance. """ - target = datetime.fromtimestamp(ts, tz=UTC).date() - where = self._metric_score_where(ts - 172800, ts + 172800) data = await self._execute( - self._METRIC_SCORES_QUERY, {"metricId": metric_id, "where": where} + self._METRIC_SCORES_QUERY, + {"metricId": metric_id, "where": {"id": {"eq": score_id}}}, + ) + return MetricScore.model_validate( + self._one( + data, + "metric", + "scoresNonPaginated", + label="Metric score", + entity_id=score_id, + ) ) - for node in self._scores_from(data): - node_ts = node.get("timestamp") - if node_ts is not None and self._same_day(node_ts, target): - return self._transform_score(node) - return None diff --git a/src/bloomy/v2/operations/milestone.py b/src/bloomy/v2/operations/milestone.py index c2cb41b..7fe146b 100644 --- a/src/bloomy/v2/operations/milestone.py +++ b/src/bloomy/v2/operations/milestone.py @@ -3,43 +3,29 @@ from __future__ import annotations import builtins -from datetime import date, datetime from typing import Any -from ...exceptions import GraphQLError from ..base import ( + MILESTONE_FIELDS, + MILESTONES_CONNECTION, MUTATION_RESULT_FIELDS, AsyncGraphQLOperations, GraphQLOperations, + TimeInput, + compact, dig_nodes, + to_timestamp, ) from ..models import Milestone -#: Selected on every milestone read. Kept in sync with `goal.py`'s inline -#: milestone subselection (duplicated rather than imported, to keep the two -#: entity modules independent). -_MILESTONE_FIELDS = "id goalId title dueDate completed status dateCreated" - class MilestoneOperationsMixin: - """Shared GraphQL documents and response transforms for milestone operations.""" + """GraphQL documents, inputs, and parsing shared by milestone operations.""" - # There is no root `milestone(id)` query (see module docstring on - # `MilestoneOperations`), so both the list and the by-id read go through - # `goal(id){ milestones(...) }`. `dateDeleted` is already filtered - # server-side (soft-deleted milestones are never returned), but the - # explicit filter is kept for parity with the web app's own query. _MILESTONE_LIST_QUERY = f""" query($goalId: Long!) {{ goal(id: $goalId) {{ - milestones( - where: {{ and: [{{ dateDeleted: {{ eq: null }} }}] }} - order: [{{ dueDate: ASC }}] - ) {{ - nodes {{ - {_MILESTONE_FIELDS} - }} - }} + {MILESTONES_CONNECTION} }} }} """ @@ -49,19 +35,13 @@ class MilestoneOperationsMixin: goal(id: $goalId) {{ milestones(where: {{ id: {{ eq: $milestoneId }} }}) {{ nodes {{ - {_MILESTONE_FIELDS} + {MILESTONE_FIELDS} }} }} }} }} """ - # CreateMilestone/EditMilestone return only `IdModel { id }` (verified - # live against production), not the `{success message errorDetails}` - # shape `_run_mutation` expects. A failed create/edit raises through the - # standard `errors` array instead (an invalid input, e.g. a bad `status` - # string, throws server-side and surfaces there), so these are executed - # with plain `_execute`. _MILESTONE_CREATE_MUTATION = """ mutation($input: MilestoneCreateModelInput!) { CreateMilestone(input: $input) { @@ -78,8 +58,6 @@ class MilestoneOperationsMixin: } """ - # DeleteMilestone genuinely returns `GraphQLResponseBase` - # (`{success message errorDetails}`), unlike Create/EditMilestone. _MILESTONE_DELETE_MUTATION = f""" mutation($milestoneId: Long!) {{ DeleteMilestone(milestoneId: $milestoneId) {{ @@ -88,28 +66,68 @@ class MilestoneOperationsMixin: }} """ - def _transform_milestone(self, data: dict[str, Any]) -> Milestone: - """Transform a raw GraphQL milestone object into a `Milestone` model. + @staticmethod + def _milestones_from(data: dict[str, Any]) -> list[Milestone]: + """Validate the milestones of a `_MILESTONE_LIST_QUERY` response. Returns: - A `Milestone` model instance. + The milestones, ordered by due date (empty for an unknown goal). """ - return Milestone(**data) + return [ + Milestone.model_validate(node) + for node in dig_nodes(data, "goal", "milestones") + ] + + @staticmethod + def _milestone_create_input( + *, + goal_id: int, + title: str, + due_date: TimeInput, + completed: bool, + ) -> dict[str, Any]: + """Build the `MilestoneCreateModelInput` fields. + + Returns: + The input fields. + + """ + return { + "rockId": goal_id, + "title": title, + "dueDate": to_timestamp(due_date), + "completed": completed, + } + + @staticmethod + def _milestone_edit_fields( + *, + title: str | None, + due_date: TimeInput | None, + completed: bool | None, + ) -> dict[str, Any]: + """Build the `MilestoneEditModelInput` fields that were given. + + Returns: + The non-`None` input fields. + + """ + return compact( + title=title, + dueDate=None if due_date is None else to_timestamp(due_date), + completed=completed, + ) class MilestoneOperations(GraphQLOperations, MilestoneOperationsMixin): """Class to handle v2 (GraphQL) operations related to goal milestones. Note: - The GraphQL API has no root `milestone(id)` query — a milestone can - only be read through its parent goal (`goal(id){ milestones }`). So - `update()` and `complete()` require `goal_id` (keyword-only) in - addition to `milestone_id`: both verify that `milestone_id` actually - belongs to `goal_id` *before* sending any edit (so a wrong `goal_id` - fails without writing anything), and need `goal_id` again to re-read - the milestone afterwards. `delete()` needs neither the verification - nor `goal_id`, since `DeleteMilestone` does not return a `Milestone`. + The GraphQL API has no root `milestone(id)` query, so a milestone is + read through its parent goal. `update()` and `complete()` therefore + take `goal_id`: they check that `milestone_id` belongs to that goal + before writing anything, then re-read the milestone through it. """ @@ -129,15 +147,15 @@ def list(self, goal_id: int) -> builtins.list[Milestone]: ``` """ - data = self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) - nodes = dig_nodes(data, "goal", "milestones") - return [self._transform_milestone(node) for node in nodes] + return self._milestones_from( + self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) + ) def create( self, goal_id: int, title: str, - due_date: datetime | date | float | int, + due_date: TimeInput, completed: bool = False, ) -> Milestone: """Create a new milestone on a goal. @@ -159,17 +177,16 @@ def create( ``` """ - input_: dict[str, Any] = { - "rockId": goal_id, - "title": title, - "dueDate": self._to_timestamp(due_date), - "completed": completed, - } - data = self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) - milestone_id = self._require_created_id( - data.get("CreateMilestone"), label="milestone" + input_ = self._milestone_create_input( + goal_id=goal_id, title=title, due_date=due_date, completed=completed ) - return self._get(goal_id, milestone_id) + result = self._mutate( + self._MILESTONE_CREATE_MUTATION, + {"input": input_}, + root_field="CreateMilestone", + action="create milestone", + ) + return self._get(goal_id, result["id"]) def update( self, @@ -177,17 +194,15 @@ def update( *, goal_id: int, title: str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, completed: bool | None = None, ) -> Milestone: """Update an existing milestone. Args: milestone_id: The ID of the milestone to update. - goal_id: The ID of the milestone's parent goal. Keyword-only: - verified to actually own `milestone_id` (see the class - `Note`) *before* anything is written, and needed again to - re-read the milestone afterwards. + goal_id: The ID of the milestone's parent goal (see the class + `Note`). title: New title for the milestone. due_date: New due date, as a `datetime`, `date`, or unix timestamp (seconds). @@ -206,24 +221,19 @@ def update( ``` """ - if title is None and due_date is None and completed is None: + fields = self._milestone_edit_fields( + title=title, due_date=due_date, completed=completed + ) + if not fields: raise ValueError("At least one field must be provided for update") - - # Verify `milestone_id` actually belongs to `goal_id` before writing - # anything: without this, a wrong `goal_id` would still send - # `EditMilestone` and only report "not found" on the re-read after - # the write already happened. + # Checks that the milestone belongs to `goal_id` before writing. self._get(goal_id, milestone_id) - - input_: dict[str, Any] = {"milestoneId": milestone_id} - if title is not None: - input_["title"] = title - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if completed is not None: - input_["completed"] = completed - - self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) + self._mutate( + self._MILESTONE_EDIT_MUTATION, + {"input": {"milestoneId": milestone_id, **fields}}, + root_field="EditMilestone", + action="update milestone", + ) return self._get(goal_id, milestone_id) def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: @@ -231,22 +241,14 @@ def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: Args: milestone_id: The ID of the milestone to complete. - goal_id: The ID of the milestone's parent goal. Keyword-only: - verified to actually own `milestone_id` *before* anything is - written (see `update()`), and needed again to re-read the - milestone afterwards. + goal_id: The ID of the milestone's parent goal (see the class + `Note`). Returns: The updated `Milestone`. """ - self._get(goal_id, milestone_id) - - self._execute( - self._MILESTONE_EDIT_MUTATION, - {"input": {"milestoneId": milestone_id, "completed": True}}, - ) - return self._get(goal_id, milestone_id) + return self.update(milestone_id, goal_id=goal_id, completed=True) def delete(self, milestone_id: int) -> None: """Delete (soft-delete) a milestone. @@ -260,7 +262,7 @@ def delete(self, milestone_id: int) -> None: ``` """ - self._run_mutation( + self._mutate( self._MILESTONE_DELETE_MUTATION, {"milestoneId": milestone_id}, root_field="DeleteMilestone", @@ -268,25 +270,22 @@ def delete(self, milestone_id: int) -> None: ) def _get(self, goal_id: int, milestone_id: int) -> Milestone: - """Re-read a single milestone through its parent goal. + """Read a single milestone through its parent goal. Returns: - The `Milestone` model instance. - - Raises: - GraphQLError: If the milestone is not found under the goal. + The `Milestone` model instance. `_one` raises `GraphQLError` if + the goal has no such milestone. """ data = self._execute( self._MILESTONE_BY_ID_QUERY, {"goalId": goal_id, "milestoneId": milestone_id}, ) - nodes = dig_nodes(data, "goal", "milestones") - if not nodes: - raise GraphQLError( - f"milestone {milestone_id} not found under goal {goal_id}" + return Milestone.model_validate( + self._one( + data, "goal", "milestones", label="Milestone", entity_id=milestone_id ) - return self._transform_milestone(nodes[0]) + ) class AsyncMilestoneOperations(AsyncGraphQLOperations, MilestoneOperationsMixin): @@ -308,15 +307,15 @@ async def list(self, goal_id: int) -> builtins.list[Milestone]: A list of `Milestone` model instances, ordered by due date. """ - data = await self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) - nodes = dig_nodes(data, "goal", "milestones") - return [self._transform_milestone(node) for node in nodes] + return self._milestones_from( + await self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) + ) async def create( self, goal_id: int, title: str, - due_date: datetime | date | float | int, + due_date: TimeInput, completed: bool = False, ) -> Milestone: """Create a new milestone on a goal. @@ -332,17 +331,16 @@ async def create( The newly created `Milestone`. """ - input_: dict[str, Any] = { - "rockId": goal_id, - "title": title, - "dueDate": self._to_timestamp(due_date), - "completed": completed, - } - data = await self._execute(self._MILESTONE_CREATE_MUTATION, {"input": input_}) - milestone_id = self._require_created_id( - data.get("CreateMilestone"), label="milestone" + input_ = self._milestone_create_input( + goal_id=goal_id, title=title, due_date=due_date, completed=completed ) - return await self._get(goal_id, milestone_id) + result = await self._mutate( + self._MILESTONE_CREATE_MUTATION, + {"input": input_}, + root_field="CreateMilestone", + action="create milestone", + ) + return await self._get(goal_id, result["id"]) async def update( self, @@ -350,17 +348,15 @@ async def update( *, goal_id: int, title: str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, completed: bool | None = None, ) -> Milestone: """Update an existing milestone. Args: milestone_id: The ID of the milestone to update. - goal_id: The ID of the milestone's parent goal. Keyword-only: - verified to actually own `milestone_id` (see the class - `Note`) *before* anything is written, and needed again to - re-read the milestone afterwards. + goal_id: The ID of the milestone's parent goal (see the class + `Note`). title: New title for the milestone. due_date: New due date, as a `datetime`, `date`, or unix timestamp (seconds). @@ -373,22 +369,19 @@ async def update( ValueError: If no update fields are provided. """ - if title is None and due_date is None and completed is None: + fields = self._milestone_edit_fields( + title=title, due_date=due_date, completed=completed + ) + if not fields: raise ValueError("At least one field must be provided for update") - - # Verify `milestone_id` actually belongs to `goal_id` before writing - # anything (see `MilestoneOperations.update`). + # Checks that the milestone belongs to `goal_id` before writing. await self._get(goal_id, milestone_id) - - input_: dict[str, Any] = {"milestoneId": milestone_id} - if title is not None: - input_["title"] = title - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if completed is not None: - input_["completed"] = completed - - await self._execute(self._MILESTONE_EDIT_MUTATION, {"input": input_}) + await self._mutate( + self._MILESTONE_EDIT_MUTATION, + {"input": {"milestoneId": milestone_id, **fields}}, + root_field="EditMilestone", + action="update milestone", + ) return await self._get(goal_id, milestone_id) async def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: @@ -396,22 +389,14 @@ async def complete(self, milestone_id: int, *, goal_id: int) -> Milestone: Args: milestone_id: The ID of the milestone to complete. - goal_id: The ID of the milestone's parent goal. Keyword-only: - verified to actually own `milestone_id` *before* anything is - written (see `update()`), and needed again to re-read the - milestone afterwards. + goal_id: The ID of the milestone's parent goal (see the class + `Note`). Returns: The updated `Milestone`. """ - await self._get(goal_id, milestone_id) - - await self._execute( - self._MILESTONE_EDIT_MUTATION, - {"input": {"milestoneId": milestone_id, "completed": True}}, - ) - return await self._get(goal_id, milestone_id) + return await self.update(milestone_id, goal_id=goal_id, completed=True) async def delete(self, milestone_id: int) -> None: """Delete (soft-delete) a milestone. @@ -420,7 +405,7 @@ async def delete(self, milestone_id: int) -> None: milestone_id: The ID of the milestone to delete. """ - await self._run_mutation( + await self._mutate( self._MILESTONE_DELETE_MUTATION, {"milestoneId": milestone_id}, root_field="DeleteMilestone", @@ -428,22 +413,19 @@ async def delete(self, milestone_id: int) -> None: ) async def _get(self, goal_id: int, milestone_id: int) -> Milestone: - """Re-read a single milestone through its parent goal. + """Read a single milestone through its parent goal. Returns: - The `Milestone` model instance. - - Raises: - GraphQLError: If the milestone is not found under the goal. + The `Milestone` model instance. `_one` raises `GraphQLError` if + the goal has no such milestone. """ data = await self._execute( self._MILESTONE_BY_ID_QUERY, {"goalId": goal_id, "milestoneId": milestone_id}, ) - nodes = dig_nodes(data, "goal", "milestones") - if not nodes: - raise GraphQLError( - f"milestone {milestone_id} not found under goal {goal_id}" + return Milestone.model_validate( + self._one( + data, "goal", "milestones", label="Milestone", entity_id=milestone_id ) - return self._transform_milestone(nodes[0]) + ) diff --git a/src/bloomy/v2/operations/todo.py b/src/bloomy/v2/operations/todo.py index 4aa8c6b..3550dbd 100644 --- a/src/bloomy/v2/operations/todo.py +++ b/src/bloomy/v2/operations/todo.py @@ -3,19 +3,25 @@ from __future__ import annotations import builtins -from datetime import UTC, date, datetime, timedelta from typing import Any from ..base import ( + MEETING_REF_FIELDS, MUTATION_RESULT_FIELDS, + NOTES_FIELDS, + USER_REF_FIELDS, AsyncGraphQLOperations, GraphQLOperations, + TimeInput, + compact, + default_due_date, dig_nodes, - extract_notes, + now_timestamp, + to_timestamp, ) from ..models import Todo -_TODO_FIELDS = """ +_TODO_FIELDS = f""" id title dueDate @@ -24,17 +30,14 @@ archived archivedTimestamp dateCreated -notesId -notesText -localHtml -collaborationEnabled -assignee { id fullName } -meeting { id name } +{NOTES_FIELDS} +{USER_REF_FIELDS} +{MEETING_REF_FIELDS} """ class TodoOperationsMixin: - """Shared GraphQL documents and response transforms for to-do operations.""" + """GraphQL documents, inputs, and response parsing shared by to-do operations.""" _TODO_DETAILS_QUERY = f""" query($id: Long!) {{ @@ -44,15 +47,23 @@ class TodoOperationsMixin: }} """ - # `meeting.todos` returns EVERY to-do ever attached to the meeting, - # including archived and soft-deleted ones (unlike `meeting.issues`, - # which drops solved/archived issues server-side), so a single query - # with a client-built `where` filter covers every combination of - # `include_completed`/`include_archived` here. - _TODO_LIST_MEETING_QUERY = f""" - query($meetingId: Long!, $where: TodoQueryModelFilterInput) {{ + # `todosActives` drops archived and deleted to-dos server-side but keeps + # completed ones; `todos` returns every to-do ever attached to the meeting. + _TODO_MEETING_LIST_QUERY = f""" + query( + $meetingId: Long! + $where: TodoQueryModelFilterInput + $includeArchived: Boolean! + ) {{ meeting(id: $meetingId) {{ - todos(where: $where, order: [{{ dueDate: ASC }}]) {{ + todosActives(where: $where, order: [{{ dueDate: ASC }}]) + @skip(if: $includeArchived) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + todos(where: $where, order: [{{ dueDate: ASC }}]) + @include(if: $includeArchived) {{ nodes {{ {_TODO_FIELDS} }} @@ -61,10 +72,7 @@ class TodoOperationsMixin: }} """ - # The root `todos(userId)` connection already excludes archived to-dos - # unconditionally, server-side (it is built from `CloseTime == null`), - # so `include_archived` has no effect here; see `list()`. - _TODO_LIST_USER_QUERY = f""" + _TODO_USER_LIST_QUERY = f""" query($userId: Long!, $where: TodoQueryModelFilterInput) {{ todos(userId: $userId, where: $where, order: [{{ dueDate: ASC }}]) {{ nodes {{ @@ -74,14 +82,15 @@ class TodoOperationsMixin: }} """ - _TODO_CREATE_MUTATION = """ - mutation($input: TodoCreateModelInput!) { - CreateTodo(input: $input) { - id - } - } + _TODO_CREATE_MUTATION = f""" + mutation($input: TodoCreateModelInput!) {{ + CreateTodo(input: $input) {{ + {_TODO_FIELDS} + }} + }} """ + # `EditTodo` leaves an omitted field unchanged and clears one sent as `null`. _TODO_EDIT_MUTATION = f""" mutation($input: TodoEditModelInput!) {{ EditTodo(input: $input) {{ @@ -90,42 +99,89 @@ class TodoOperationsMixin: }} """ - def _todo_list_where( - self, *, include_completed: bool, include_archived: bool - ) -> dict[str, Any] | None: - """Build the `where` filter for a to-do list connection. + @classmethod + def _todo_list_request( + cls, + meeting_id: int | None, + user_id: int | None, + *, + include_completed: bool, + include_archived: bool, + ) -> tuple[str, dict[str, Any]]: + """Pick the list document for a meeting or a user, with its variables. Returns: - A `TodoQueryModelFilterInput`-shaped dictionary, or `None` for no - filter (list everything). + The query document and its variables. """ - conditions: list[dict[str, Any]] = [] - if not include_completed: - conditions.append({"completed": {"eq": False}}) - if not include_archived: - conditions.append({"archived": {"eq": False}}) - return {"and": conditions} if conditions else None + where = None if include_completed else {"completed": {"eq": False}} + if meeting_id is not None: + return cls._TODO_MEETING_LIST_QUERY, { + "meetingId": meeting_id, + "where": where, + "includeArchived": include_archived, + } + return cls._TODO_USER_LIST_QUERY, {"userId": user_id, "where": where} + + @staticmethod + def _todos_from(data: dict[str, Any]) -> list[Todo]: + """Validate the to-dos of a list query response. - def _default_due_date(self) -> date: - """Compute the default due date: 7 days from today, 00:00 UTC. + Each response carries exactly one of the three connections read here. - Matches the web app's own default for a new to-do. + Returns: + The to-dos, ordered by due date. + + """ + nodes = [ + *dig_nodes(data, "todos"), + *dig_nodes(data, "meeting", "todosActives"), + *dig_nodes(data, "meeting", "todos"), + ] + return [Todo.model_validate(node) for node in nodes] + + @staticmethod + def _todo_create_input( + *, + title: str, + meeting_id: int | None, + user_id: int, + due_date: TimeInput | None, + ) -> dict[str, Any]: + """Build the `TodoCreateModelInput` fields, apart from notes. Returns: - A `date` 7 days from today (UTC). + The input fields, with the due date defaulting to 7 days from + today. """ - return datetime.now(tz=UTC).date() + timedelta(days=7) + return { + "title": title, + "assigneeId": user_id, + "meetingRecurrenceId": meeting_id, + "dueDate": to_timestamp( + default_due_date(7) if due_date is None else due_date + ), + } - def _transform_todo(self, data: dict[str, Any]) -> Todo: - """Transform a raw GraphQL to-do object into a `Todo` model. + @staticmethod + def _todo_edit_fields( + *, + title: str | None, + due_date: TimeInput | None, + user_id: int | None, + ) -> dict[str, Any]: + """Build the `TodoEditModelInput` fields that were given, apart from notes. Returns: - A `Todo` model instance. + The non-`None` input fields. """ - return Todo(**data, notes=extract_notes(data)) + return compact( + title=title, + dueDate=None if due_date is None else to_timestamp(due_date), + assigneeId=user_id, + ) class TodoOperations(GraphQLOperations, TodoOperationsMixin): @@ -148,7 +204,9 @@ def details(self, todo_id: int) -> Todo: """ data = self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) - return self._transform_todo(self._require_entity(data, "todo", todo_id, "Todo")) + return Todo.model_validate( + self._one(data, "todo", label="Todo", entity_id=todo_id) + ) def list( self, @@ -183,36 +241,24 @@ def list( # Returns: [Todo(id=1, title='To-do 1', ...), ...] ``` - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._todo_list_where( - include_completed=include_completed, include_archived=include_archived - ) - - if meeting_id is not None: - data = self._execute( - self._TODO_LIST_MEETING_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "todos") - return [self._transform_todo(node) for node in nodes] - - if user_id is None: + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: user_id = self.user_id - data = self._execute( - self._TODO_LIST_USER_QUERY, {"userId": user_id, "where": where} + query, variables = self._todo_list_request( + meeting_id, + user_id, + include_completed=include_completed, + include_archived=include_archived, ) - nodes = dig_nodes(data, "todos") - return [self._transform_todo(node) for node in nodes] + return self._todos_from(self._execute(query, variables)) def create( self, title: str, meeting_id: int | None = None, user_id: int | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, notes: str | None = None, ) -> Todo: """Create a new to-do. @@ -241,29 +287,23 @@ def create( """ if user_id is None: user_id = self.user_id - if due_date is None: - due_date = self._default_due_date() - - input_: dict[str, Any] = { - "title": title, - "assigneeId": user_id, - "meetingRecurrenceId": meeting_id, - "dueDate": self._to_timestamp(due_date), - } - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - data = self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) - todo_id = self._require_created_id(data.get("CreateTodo"), label="todo") - return self.details(todo_id) + input_ = self._todo_create_input( + title=title, meeting_id=meeting_id, user_id=user_id, due_date=due_date + ) + result = self._mutate( + self._TODO_CREATE_MUTATION, + {"input": {**input_, **self._notes_input(notes)}}, + root_field="CreateTodo", + action="create todo", + ) + return Todo.model_validate(result) def update( self, todo_id: int, *, title: str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, user_id: int | None = None, notes: str | None = None, ) -> Todo: @@ -290,27 +330,11 @@ def update( ``` """ - if title is None and due_date is None and user_id is None and notes is None: + fields = self._todo_edit_fields(title=title, due_date=due_date, user_id=user_id) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"todoId": todo_id} - if title is not None: - input_["title"] = title - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if user_id is not None: - input_["assigneeId"] = user_id - if notes is not None: - input_["notesId"] = self._create_note(notes) - input_["collaborationEnabled"] = True - - self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": input_}, - root_field="EditTodo", - action="update todo", - ) - return self.details(todo_id) + fields.update(self._notes_input(notes)) + return self._edit(todo_id, fields, action="update todo") def complete(self, todo_id: int) -> Todo: """Mark a to-do as complete. @@ -322,22 +346,13 @@ def complete(self, todo_id: int) -> Todo: The updated `Todo`. """ - self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "completedTimestamp": self._now_timestamp()}}, - root_field="EditTodo", - action="complete todo", + return self._edit( + todo_id, {"completedTimestamp": now_timestamp()}, action="complete todo" ) - return self.details(todo_id) def reopen(self, todo_id: int) -> Todo: """Reopen a completed to-do. - Note: - Sends `completedTimestamp: null` explicitly: `EditTodo` - distinguishes an omitted field (no change) from an explicit - `null` (clear it). - Args: todo_id: The ID of the to-do to reopen. @@ -345,13 +360,7 @@ def reopen(self, todo_id: int) -> Todo: The updated `Todo`. """ - self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "completedTimestamp": None}}, - root_field="EditTodo", - action="reopen todo", - ) - return self.details(todo_id) + return self._edit(todo_id, {"completedTimestamp": None}, action="reopen todo") def archive(self, todo_id: int) -> Todo: """Archive a to-do. @@ -363,22 +372,13 @@ def archive(self, todo_id: int) -> Todo: The updated `Todo`. """ - self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "archivedTimestamp": self._now_timestamp()}}, - root_field="EditTodo", - action="archive todo", + return self._edit( + todo_id, {"archivedTimestamp": now_timestamp()}, action="archive todo" ) - return self.details(todo_id) def restore(self, todo_id: int) -> Todo: """Restore an archived to-do. - Note: - Sends `archivedTimestamp: null` explicitly: `EditTodo` - distinguishes an omitted field (no change) from an explicit - `null` (clear it). - Args: todo_id: The ID of the to-do to restore. @@ -386,11 +386,20 @@ def restore(self, todo_id: int) -> Todo: The updated `Todo`. """ - self._run_mutation( + return self._edit(todo_id, {"archivedTimestamp": None}, action="restore todo") + + def _edit(self, todo_id: int, fields: dict[str, Any], *, action: str) -> Todo: + """Run `EditTodo` with `fields`, then re-read the to-do. + + Returns: + The updated `Todo`. + + """ + self._mutate( self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "archivedTimestamp": None}}, + {"input": {"todoId": todo_id, **fields}}, root_field="EditTodo", - action="restore todo", + action=action, ) return self.details(todo_id) @@ -409,7 +418,9 @@ async def details(self, todo_id: int) -> Todo: """ data = await self._execute(self._TODO_DETAILS_QUERY, {"id": todo_id}) - return self._transform_todo(self._require_entity(data, "todo", todo_id, "Todo")) + return Todo.model_validate( + self._one(data, "todo", label="Todo", entity_id=todo_id) + ) async def list( self, @@ -438,36 +449,24 @@ async def list( Raises: ValueError: If both `meeting_id` and `user_id` are provided. - """ - if meeting_id is not None and user_id is not None: - raise ValueError("Please provide either meeting_id or user_id, not both.") - - where = self._todo_list_where( - include_completed=include_completed, include_archived=include_archived - ) - - if meeting_id is not None: - data = await self._execute( - self._TODO_LIST_MEETING_QUERY, - {"meetingId": meeting_id, "where": where}, - ) - nodes = dig_nodes(data, "meeting", "todos") - return [self._transform_todo(node) for node in nodes] - - if user_id is None: + """ # noqa: DOC502 + self._validate_mutual_exclusion(meeting_id, user_id, "meeting_id", "user_id") + if meeting_id is None and user_id is None: user_id = await self.get_user_id() - data = await self._execute( - self._TODO_LIST_USER_QUERY, {"userId": user_id, "where": where} + query, variables = self._todo_list_request( + meeting_id, + user_id, + include_completed=include_completed, + include_archived=include_archived, ) - nodes = dig_nodes(data, "todos") - return [self._transform_todo(node) for node in nodes] + return self._todos_from(await self._execute(query, variables)) async def create( self, title: str, meeting_id: int | None = None, user_id: int | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, notes: str | None = None, ) -> Todo: """Create a new to-do. @@ -490,29 +489,23 @@ async def create( """ if user_id is None: user_id = await self.get_user_id() - if due_date is None: - due_date = self._default_due_date() - - input_: dict[str, Any] = { - "title": title, - "assigneeId": user_id, - "meetingRecurrenceId": meeting_id, - "dueDate": self._to_timestamp(due_date), - } - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - data = await self._execute(self._TODO_CREATE_MUTATION, {"input": input_}) - todo_id = self._require_created_id(data.get("CreateTodo"), label="todo") - return await self.details(todo_id) + input_ = self._todo_create_input( + title=title, meeting_id=meeting_id, user_id=user_id, due_date=due_date + ) + result = await self._mutate( + self._TODO_CREATE_MUTATION, + {"input": {**input_, **await self._notes_input(notes)}}, + root_field="CreateTodo", + action="create todo", + ) + return Todo.model_validate(result) async def update( self, todo_id: int, *, title: str | None = None, - due_date: datetime | date | float | int | None = None, + due_date: TimeInput | None = None, user_id: int | None = None, notes: str | None = None, ) -> Todo: @@ -533,27 +526,11 @@ async def update( ValueError: If no update fields are provided. """ - if title is None and due_date is None and user_id is None and notes is None: + fields = self._todo_edit_fields(title=title, due_date=due_date, user_id=user_id) + if not fields and notes is None: raise ValueError("At least one field must be provided for update") - - input_: dict[str, Any] = {"todoId": todo_id} - if title is not None: - input_["title"] = title - if due_date is not None: - input_["dueDate"] = self._to_timestamp(due_date) - if user_id is not None: - input_["assigneeId"] = user_id - if notes is not None: - input_["notesId"] = await self._create_note(notes) - input_["collaborationEnabled"] = True - - await self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": input_}, - root_field="EditTodo", - action="update todo", - ) - return await self.details(todo_id) + fields.update(await self._notes_input(notes)) + return await self._edit(todo_id, fields, action="update todo") async def complete(self, todo_id: int) -> Todo: """Mark a to-do as complete. @@ -565,22 +542,13 @@ async def complete(self, todo_id: int) -> Todo: The updated `Todo`. """ - await self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "completedTimestamp": self._now_timestamp()}}, - root_field="EditTodo", - action="complete todo", + return await self._edit( + todo_id, {"completedTimestamp": now_timestamp()}, action="complete todo" ) - return await self.details(todo_id) async def reopen(self, todo_id: int) -> Todo: """Reopen a completed to-do. - Note: - Sends `completedTimestamp: null` explicitly: `EditTodo` - distinguishes an omitted field (no change) from an explicit - `null` (clear it). - Args: todo_id: The ID of the to-do to reopen. @@ -588,13 +556,9 @@ async def reopen(self, todo_id: int) -> Todo: The updated `Todo`. """ - await self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "completedTimestamp": None}}, - root_field="EditTodo", - action="reopen todo", + return await self._edit( + todo_id, {"completedTimestamp": None}, action="reopen todo" ) - return await self.details(todo_id) async def archive(self, todo_id: int) -> Todo: """Archive a to-do. @@ -606,22 +570,13 @@ async def archive(self, todo_id: int) -> Todo: The updated `Todo`. """ - await self._run_mutation( - self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "archivedTimestamp": self._now_timestamp()}}, - root_field="EditTodo", - action="archive todo", + return await self._edit( + todo_id, {"archivedTimestamp": now_timestamp()}, action="archive todo" ) - return await self.details(todo_id) async def restore(self, todo_id: int) -> Todo: """Restore an archived to-do. - Note: - Sends `archivedTimestamp: null` explicitly: `EditTodo` - distinguishes an omitted field (no change) from an explicit - `null` (clear it). - Args: todo_id: The ID of the to-do to restore. @@ -629,10 +584,21 @@ async def restore(self, todo_id: int) -> Todo: The updated `Todo`. """ - await self._run_mutation( + return await self._edit( + todo_id, {"archivedTimestamp": None}, action="restore todo" + ) + + async def _edit(self, todo_id: int, fields: dict[str, Any], *, action: str) -> Todo: + """Run `EditTodo` with `fields`, then re-read the to-do. + + Returns: + The updated `Todo`. + + """ + await self._mutate( self._TODO_EDIT_MUTATION, - {"input": {"todoId": todo_id, "archivedTimestamp": None}}, + {"input": {"todoId": todo_id, **fields}}, root_field="EditTodo", - action="restore todo", + action=action, ) return await self.details(todo_id) diff --git a/src/bloomy/v2/operations/user.py b/src/bloomy/v2/operations/user.py index 7628426..fc09be0 100644 --- a/src/bloomy/v2/operations/user.py +++ b/src/bloomy/v2/operations/user.py @@ -3,16 +3,15 @@ from __future__ import annotations import builtins -from typing import Any -from ..base import AsyncGraphQLOperations, GraphQLOperations +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes from ..models import User +_USER_FIELDS = "id firstName lastName fullName email avatar" -class UserOperationsMixin: - """Shared GraphQL documents and response transforms for user operations.""" - _USER_FIELDS = "id firstName lastName fullName email avatar" +class UserOperationsMixin: + """GraphQL documents shared by user operations.""" _USER_DETAILS_QUERY = f""" query($id: Long!) {{ @@ -32,15 +31,6 @@ class UserOperationsMixin: }} """ - def _transform_user(self, data: dict[str, Any]) -> User: - """Transform a raw GraphQL user object into a `User` model. - - Returns: - A `User` model instance. - - """ - return User(**data) - class UserOperations(GraphQLOperations, UserOperationsMixin): """Class to handle v2 (GraphQL) operations related to users.""" @@ -63,9 +53,10 @@ def details(self, user_id: int | None = None) -> User: """ if user_id is None: user_id = self.user_id - data = self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) - return self._transform_user(self._require_entity(data, "user", user_id, "User")) + return User.model_validate( + self._one(data, "user", label="User", entity_id=user_id) + ) def list(self) -> builtins.list[User]: """List every user in the organization. @@ -81,7 +72,7 @@ def list(self) -> builtins.list[User]: """ data = self._execute(self._USER_LIST_QUERY) - return [self._transform_user(node) for node in data["users"]["nodes"]] + return [User.model_validate(node) for node in dig_nodes(data, "users")] class AsyncUserOperations(AsyncGraphQLOperations, UserOperationsMixin): @@ -99,9 +90,10 @@ async def details(self, user_id: int | None = None) -> User: """ if user_id is None: user_id = await self.get_user_id() - data = await self._execute(self._USER_DETAILS_QUERY, {"id": user_id}) - return self._transform_user(self._require_entity(data, "user", user_id, "User")) + return User.model_validate( + self._one(data, "user", label="User", entity_id=user_id) + ) async def list(self) -> builtins.list[User]: """List every user in the organization. @@ -111,4 +103,4 @@ async def list(self) -> builtins.list[User]: """ data = await self._execute(self._USER_LIST_QUERY) - return [self._transform_user(node) for node in data["users"]["nodes"]] + return [User.model_validate(node) for node in dig_nodes(data, "users")] diff --git a/tests/test_integration_v2_goal_milestone.py b/tests/test_integration_v2_goal_milestone.py index 6baf2c1..0bd83a6 100644 --- a/tests/test_integration_v2_goal_milestone.py +++ b/tests/test_integration_v2_goal_milestone.py @@ -211,7 +211,9 @@ def test_list_by_user_finds_the_same_goal(self, client: Client) -> None: def test_list_requires_only_one_scope(self, client: Client) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): client.v2.goal.list(meeting_id=MEETING_ID, user_id=1305290) def test_update_requires_a_field(self, client: Client) -> None: diff --git a/tests/test_integration_v2_headline.py b/tests/test_integration_v2_headline.py index f3be149..1f33218 100644 --- a/tests/test_integration_v2_headline.py +++ b/tests/test_integration_v2_headline.py @@ -143,6 +143,14 @@ def test_full_lifecycle(self, client: Client) -> None: ) assert any(h.id == created.id for h in archived_list) + open_by_user = client.v2.headline.list(user_id=created.owner.id) + assert all(h.id != created.id for h in open_by_user) + assert all(h.archived is False for h in open_by_user) + all_by_user = client.v2.headline.list( + user_id=created.owner.id, include_archived=True + ) + assert any(h.id == created.id and h.archived for h in all_by_user) + restored = client.v2.headline.restore(created.id) assert restored.archived is False finally: @@ -151,7 +159,9 @@ def test_full_lifecycle(self, client: Client) -> None: def test_list_requires_at_most_one_of_meeting_or_user(self, client: Client) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): client.v2.headline.list(meeting_id=MEETING_ID, user_id=1305290) def test_update_requires_a_field(self, client: Client) -> None: diff --git a/tests/test_integration_v2_metric.py b/tests/test_integration_v2_metric.py index 0a36174..aec721e 100644 --- a/tests/test_integration_v2_metric.py +++ b/tests/test_integration_v2_metric.py @@ -240,7 +240,9 @@ def test_create_goal_with_between_raises(self, client: Client) -> None: def test_list_requires_only_one_scope(self, client: Client) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): client.v2.metric.list(meeting_id=MEETING_ID, user_id=1305290) def test_update_requires_a_field(self, client: Client) -> None: diff --git a/tests/test_integration_v2_todo.py b/tests/test_integration_v2_todo.py index 3eec92c..db539eb 100644 --- a/tests/test_integration_v2_todo.py +++ b/tests/test_integration_v2_todo.py @@ -179,9 +179,56 @@ def test_personal_todo(self, client: Client) -> None: finally: client.v2.todo.archive(created.id) + def test_list_include_flags_by_meeting(self, client: Client) -> None: + """Each include-flag combination lists the to-dos it should, live. + + The default and `include_completed` lists read `todosActives` (which + keeps completed to-dos and drops archived ones); `include_archived` + lists read `todos`. + """ + tag = uuid.uuid4().hex[:8] + created = client.v2.todo.create(_title(tag), meeting_id=MEETING_ID) + + def listed(**flags: bool) -> bool: + todos = client.v2.todo.list(meeting_id=MEETING_ID, **flags) + return any(t.id == created.id for t in todos) + + try: + assert listed() + + client.v2.todo.complete(created.id) + assert not listed() + assert listed(include_completed=True) + + client.v2.todo.archive(created.id) + assert not listed(include_completed=True) + assert not listed(include_archived=True) + assert listed(include_completed=True, include_archived=True) + finally: + client.v2.todo.archive(created.id) + + def test_create_result_matches_details(self, client: Client) -> None: + """`create()`'s result (no re-read) matches a later `details()` read. + + `created_date` is compared to the second: the mutation result keeps + sub-second precision that the stored value rounds away. + """ + tag = uuid.uuid4().hex[:8] + created = client.v2.todo.create(_title(tag), meeting_id=MEETING_ID) + + try: + details = client.v2.todo.details(created.id) + assert created.model_dump(exclude={"created_date"}) == details.model_dump( + exclude={"created_date"} + ) + delta = created.created_date - details.created_date + assert abs(delta.total_seconds()) < 1 + finally: + client.v2.todo.archive(created.id) + def test_list_requires_only_one_scope(self, client: Client) -> None: """`list()` with both `meeting_id` and `user_id` raises `ValueError`.""" - with pytest.raises(ValueError, match="not both"): + with pytest.raises(ValueError, match="Cannot specify both meeting_id and"): client.v2.todo.list(meeting_id=MEETING_ID, user_id=1305290) def test_update_requires_a_field(self, client: Client) -> None: diff --git a/tests/test_v2_base.py b/tests/test_v2_base.py index 3d0b7a9..1644ca2 100644 --- a/tests/test_v2_base.py +++ b/tests/test_v2_base.py @@ -2,19 +2,29 @@ from __future__ import annotations -from datetime import UTC, date, datetime +import asyncio +from datetime import UTC, date, datetime, timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock import httpx import pytest from bloomy.exceptions import GraphQLError +from bloomy.utils.abstract_operations import AbstractOperations from bloomy.v2.base import ( AsyncGraphQLOperations, GraphQLOperations, + UserIdCache, + compact, + default_due_date, + dig_nodes, extract_notes, merge_nodes, + now_timestamp, prepare_note_text, + to_timestamp, + to_utc_datetime, ) GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" @@ -98,6 +108,19 @@ def test_non_json_failure_falls_back_to_raise_for_status(self) -> None: with pytest.raises(httpx.HTTPStatusError): ops._execute("query {}") + def test_non_json_success_reraises_decode_error(self) -> None: + """A non-JSON body with a 2xx status re-raises the JSON decode error.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.json.side_effect = ValueError("not json") + response.raise_for_status = Mock() + client.post.return_value = response + + with pytest.raises(ValueError, match="not json"): + ops._execute("query {}") + def test_json_without_errors_still_calls_raise_for_status(self) -> None: """A JSON body without `errors` still surfaces a non-2xx via httpx. @@ -145,85 +168,100 @@ def test_empty_lists_return_empty(self) -> None: """Merging only empty lists returns an empty list.""" assert merge_nodes([], []) == [] - def test_custom_date_field(self) -> None: - """A custom `date_field` is used for sorting instead of `dateCreated`.""" - a = {"id": 1, "timestamp": 200} - b = {"id": 2, "timestamp": 100} - result = merge_nodes([a, b], date_field="timestamp") +class TestDigNodes: + """Tests for `dig_nodes`.""" + + def test_reads_nested_connection_nodes(self) -> None: + """Nested keys are walked down to the connection's `nodes`.""" + data = {"meeting": {"issues": {"nodes": [{"id": 1}]}}} + + assert dig_nodes(data, "meeting", "issues") == [{"id": 1}] - assert [node["id"] for node in result] == [2, 1] + @pytest.mark.parametrize( + "data", + [None, {}, {"meeting": None}, {"meeting": {"issues": None}}], + ) + def test_missing_levels_return_empty(self, data: dict[str, Any] | None) -> None: + """Any missing or `null` level yields an empty list.""" + assert dig_nodes(data, "meeting", "issues") == [] -class TestRequireEntity: - """Tests for `_require_entity`.""" +class TestOne: + """Tests for `_one`.""" def test_returns_entity_when_present(self) -> None: """A present, non-null entity is returned unchanged.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + ops = GraphQLOperations(Mock(), GRAPHQL_URL) - result = ops._require_entity({"issue": {"id": 1}}, "issue", 1, "Issue") + result = ops._one({"issue": {"id": 1}}, "issue", label="Issue", entity_id=1) assert result == {"id": 1} def test_raises_when_field_missing(self) -> None: - """A response with no `field` key at all raises `GraphQLError`.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + """A response with no such key at all raises `GraphQLError`.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) with pytest.raises(GraphQLError, match="Issue 999 not found"): - ops._require_entity({}, "issue", 999, "Issue") + ops._one({}, "issue", label="Issue", entity_id=999) def test_raises_when_field_is_null(self) -> None: """A `null` entity (unknown or invisible id) raises `GraphQLError`.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + ops = GraphQLOperations(Mock(), GRAPHQL_URL) with pytest.raises(GraphQLError, match="Meeting 999999999 not found"): - ops._require_entity({"meeting": None}, "meeting", 999999999, "Meeting") + ops._one({"meeting": None}, "meeting", label="Meeting", entity_id=999999999) + def test_connection_path_returns_first_node(self) -> None: + """A path ending at a `{nodes}` connection returns its first node.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + data = {"goal": {"milestones": {"nodes": [{"id": 5}, {"id": 6}]}}} -class TestRequireCreatedId: - """Tests for `_require_created_id`.""" + result = ops._one(data, "goal", "milestones", label="Milestone", entity_id=5) - def test_returns_id_when_present(self) -> None: - """A non-zero `id` is returned as an `int`.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + assert result == {"id": 5} - assert ops._require_created_id({"id": 5265048}, label="goal") == 5265048 + def test_list_path_returns_first_item(self) -> None: + """A path ending at a plain list returns its first item.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + data = {"metric": {"scoresNonPaginated": [{"id": 7}]}} - def test_raises_when_result_is_none(self) -> None: - """A `None` mutation result (e.g. a `null` `CreateGoal`) raises.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + result = ops._one( + data, "metric", "scoresNonPaginated", label="Score", entity_id=7 + ) - with pytest.raises(GraphQLError, match="create goal failed"): - ops._require_created_id(None, label="goal") + assert result == {"id": 7} - def test_raises_when_id_is_zero(self) -> None: - """An `id` of `0` (e.g. a failed `CreateGoal` returning `IdModel(0)`) raises.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + @pytest.mark.parametrize( + "data", + [ + {"goal": {"milestones": {"nodes": []}}}, + {"goal": None}, + {"goal": {"milestones": []}}, + ], + ) + def test_empty_or_missing_list_raises(self, data: dict[str, Any]) -> None: + """An empty connection, or a missing parent, raises `GraphQLError`.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) - with pytest.raises(GraphQLError, match="create goal failed"): - ops._require_created_id({"id": 0}, label="goal") + with pytest.raises(GraphQLError, match="Milestone 5 not found"): + ops._one(data, "goal", "milestones", label="Milestone", entity_id=5) class TestCheckMutationResult: - """Tests for `_check_mutation_result` (shared by `_run_mutation`).""" + """Tests for `_check_mutation_result` (shared by `_mutate`).""" - def test_success_does_not_raise(self) -> None: - """A `success: true` result passes silently.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) - ops._check_mutation_result({"success": True}, action="do thing") + def test_success_returns_result(self) -> None: + """A `success: true` result is returned unchanged.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + result = ops._check_mutation_result({"success": True}, action="do thing") + + assert result == {"success": True} def test_success_false_raises_with_message(self) -> None: """A `success: false` result raises with the top-level `message`.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + ops = GraphQLOperations(Mock(), GRAPHQL_URL) with pytest.raises(GraphQLError, match="not allowed"): ops._check_mutation_result( @@ -233,8 +271,7 @@ def test_success_false_raises_with_message(self) -> None: def test_success_false_falls_back_to_error_details(self) -> None: """Without a top-level `message`, `errorDetails` messages are used.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + ops = GraphQLOperations(Mock(), GRAPHQL_URL) with pytest.raises(GraphQLError, match="bad title") as exc_info: ops._check_mutation_result( @@ -247,34 +284,56 @@ def test_success_false_falls_back_to_error_details(self) -> None: ) assert exc_info.value.errors == [{"message": "bad title"}] - def test_missing_result_raises(self) -> None: - """A missing mutation result (`None`) raises with the action name.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + def test_success_false_without_messages_uses_action(self) -> None: + """Without any message, the action name is used.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) with pytest.raises(GraphQLError, match="do thing failed"): - ops._check_mutation_result(None, action="do thing") + ops._check_mutation_result({"success": False}, action="do thing") + + @pytest.mark.parametrize("result", [None, {}]) + def test_missing_result_raises(self, result: dict[str, Any] | None) -> None: + """A missing (`null`) or empty result raises with the action name.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="do thing failed: no result"): + ops._check_mutation_result(result, action="do thing") + + def test_nonzero_id_returns_result(self) -> None: + """An `IdModel` result with a non-zero `id` is returned unchanged.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + result = ops._check_mutation_result({"id": 5265048}, action="create goal") + + assert result == {"id": 5265048} + + def test_zero_id_raises(self) -> None: + """An `id` of `0` (e.g. a failed `CreateGoal`) raises.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="create goal failed: no id"): + ops._check_mutation_result({"id": 0}, action="create goal") -class TestRunMutation: - """Tests for `_run_mutation`.""" +class TestMutate: + """Tests for the sync and async `_mutate`.""" - def test_run_mutation_returns_result_on_success(self) -> None: - """`_run_mutation` returns the root field's result on success.""" + def test_returns_result_on_success(self) -> None: + """`_mutate` returns the root field's result on success.""" client = Mock() ops = GraphQLOperations(client, GRAPHQL_URL) client.post.return_value = _response( {"data": {"EditIssue": {"success": True, "message": None}}} ) - result = ops._run_mutation( + result = ops._mutate( "mutation {}", {"input": {}}, root_field="EditIssue", action="update" ) assert result == {"success": True, "message": None} - def test_run_mutation_raises_on_failure(self) -> None: - """`_run_mutation` raises `GraphQLError` when `success` is false.""" + def test_raises_on_failure(self) -> None: + """`_mutate` raises `GraphQLError` when `success` is false.""" client = Mock() ops = GraphQLOperations(client, GRAPHQL_URL) client.post.return_value = _response( @@ -290,10 +349,33 @@ def test_run_mutation_raises_on_failure(self) -> None: ) with pytest.raises(GraphQLError, match="nope"): - ops._run_mutation( + ops._mutate( "mutation {}", {"input": {}}, root_field="EditIssue", action="update" ) + def test_raises_on_null_create_result(self) -> None: + """A `null` `Create*` result raises rather than returning `None`.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateGoal": None}}) + + with pytest.raises(GraphQLError, match="create goal failed"): + ops._mutate( + "mutation {}", {}, root_field="CreateGoal", action="create goal" + ) + + @pytest.mark.asyncio + async def test_async_raises_on_zero_id(self) -> None: + """The async `_mutate` applies the same checks.""" + client = AsyncMock() + ops = AsyncGraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateGoal": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="create goal failed"): + await ops._mutate( + "mutation {}", {}, root_field="CreateGoal", action="create goal" + ) + class TestCreateNote: """Tests for `_create_note`.""" @@ -334,49 +416,120 @@ def test_create_note_raises_on_failure(self) -> None: ops._create_note("text") +class TestNotesInput: + """Tests for the sync and async `_notes_input`.""" + + def test_none_returns_empty_without_request(self) -> None: + """`None` notes add no input fields and create no pad.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + + assert ops._notes_input(None) == {} + client.post.assert_not_called() + + def test_text_creates_pad(self) -> None: + """Notes text creates a pad and enables collaboration mode.""" + client = Mock() + ops = GraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"CreateNote": {"success": True, "data": "pad-1"}}} + ) + + assert ops._notes_input("hello") == { + "notesId": "pad-1", + "collaborationEnabled": True, + } + + @pytest.mark.asyncio + async def test_async_text_creates_pad(self) -> None: + """The async variant creates a pad the same way.""" + client = AsyncMock() + ops = AsyncGraphQLOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"CreateNote": {"success": True, "data": "pad-2"}}} + ) + + assert await ops._notes_input("hello") == { + "notesId": "pad-2", + "collaborationEnabled": True, + } + assert await ops._notes_input(None) == {} + client.post.assert_called_once() + + class TestTimestampHelpers: - """Tests for `_to_timestamp` and `_now_timestamp`.""" + """Tests for the module-level timestamp and due-date helpers.""" def test_naive_datetime_treated_as_utc(self) -> None: """A naive datetime is treated as UTC.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) naive = datetime(2024, 1, 1, 0, 0, 0) aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) - assert ops._to_timestamp(naive) == aware.timestamp() + assert to_utc_datetime(naive) == aware + assert to_timestamp(naive) == aware.timestamp() def test_aware_datetime_preserved(self) -> None: """A timezone-aware datetime converts directly.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) - assert ops._to_timestamp(aware) == aware.timestamp() + assert to_utc_datetime(aware) is aware + assert to_timestamp(aware) == aware.timestamp() def test_date_becomes_midnight_utc(self) -> None: """A `date` becomes 00:00 UTC of that day.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) - day = date(2024, 1, 1) - expected = datetime(2024, 1, 1, tzinfo=UTC).timestamp() + expected = datetime(2024, 1, 1, tzinfo=UTC) - assert ops._to_timestamp(day) == expected + assert to_utc_datetime(date(2024, 1, 1)) == expected + assert to_timestamp(date(2024, 1, 1)) == expected.timestamp() - def test_numbers_pass_through_as_float(self) -> None: - """A float/int is returned as-is (as a float).""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + def test_numbers(self) -> None: + """Numbers pass through `to_timestamp` and convert in `to_utc_datetime`.""" + assert to_timestamp(1700000000) == 1700000000.0 + assert to_timestamp(1700000000.5) == 1700000000.5 + assert to_utc_datetime(1700000000) == datetime( + 2023, 11, 14, 22, 13, 20, tzinfo=UTC + ) - assert ops._to_timestamp(1700000000) == 1700000000.0 - assert ops._to_timestamp(1700000000.5) == 1700000000.5 + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("2026-09-21", datetime(2026, 9, 21, tzinfo=UTC)), + ("2026-09-21T10:30:00", datetime(2026, 9, 21, 10, 30, tzinfo=UTC)), + ("2026-09-21T10:30:00+02:00", datetime(2026, 9, 21, 8, 30, tzinfo=UTC)), + ], + ) + def test_iso_strings(self, value: str, expected: datetime) -> None: + """ISO 8601 strings parse like datetimes; no offset means UTC.""" + assert to_utc_datetime(value) == expected + assert to_timestamp(value) == expected.timestamp() + + def test_invalid_string_raises(self) -> None: + """A string that is not ISO 8601 raises `ValueError`.""" + with pytest.raises(ValueError): + to_timestamp("next tuesday") def test_now_timestamp_is_close_to_now(self) -> None: - """`_now_timestamp` is close to the current time.""" - client = Mock() - ops = GraphQLOperations(client, GRAPHQL_URL) + """`now_timestamp` is close to the current time.""" + assert abs(now_timestamp() - datetime.now(tz=UTC).timestamp()) < 5 + + def test_default_due_date_counts_from_utc_today(self) -> None: + """`default_due_date` adds days to today's UTC date.""" + expected = datetime.now(tz=UTC).date() + timedelta(days=7) + + assert default_due_date(7) == expected - assert abs(ops._now_timestamp() - datetime.now(tz=UTC).timestamp()) < 5 + +class TestCompact: + """Tests for `compact`.""" + + def test_drops_only_none(self) -> None: + """`None` values are dropped; falsy non-`None` values are kept.""" + assert compact(a=None, b=0, c=False, d="", e="x") == { + "b": 0, + "c": False, + "d": "", + "e": "x", + } class TestNotesHelpers: @@ -428,6 +581,28 @@ def test_fetches_and_caches(self) -> None: assert ops.user_id == 42 client.post.assert_called_once() + def test_shared_cache_fetches_once(self) -> None: + """Operations sharing a `UserIdCache` fetch the user id only once.""" + client = Mock() + cache = UserIdCache() + first = GraphQLOperations(client, GRAPHQL_URL, cache) + second = GraphQLOperations(client, GRAPHQL_URL, cache) + client.post.return_value = _response( + {"data": {"getAuthenticatedUserId": {"id": 42}}} + ) + + assert first.user_id == 42 + assert second.user_id == 42 + client.post.assert_called_once() + + def test_is_a_v1_abstract_operations(self) -> None: + """v2 operations reuse v1's `AbstractOperations` helpers.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + assert isinstance(ops, AbstractOperations) + with pytest.raises(ValueError, match="Cannot specify both"): + ops._validate_mutual_exclusion(1, 2, "meeting_id", "user_id") + class TestAsyncUserId: """Tests for the async `user_id`/`get_user_id`.""" @@ -462,3 +637,25 @@ def test_user_id_setter(self) -> None: ops.user_id = 99 assert ops.user_id == 99 + + @pytest.mark.asyncio + async def test_concurrent_lookups_share_one_fetch(self) -> None: + """Concurrent `get_user_id()` calls on a shared cache fetch only once.""" + client = AsyncMock() + cache = UserIdCache() + first = AsyncGraphQLOperations(client, GRAPHQL_URL, cache) + second = AsyncGraphQLOperations(client, GRAPHQL_URL, cache) + + async def slow_post(*_args: object, **_kwargs: object) -> Mock: + await asyncio.sleep(0.01) + return _response({"data": {"getAuthenticatedUserId": {"id": 7}}}) + + client.post.side_effect = slow_post + + results = await asyncio.gather( + first.get_user_id(), second.get_user_id(), first.get_user_id() + ) + + assert results == [7, 7, 7] + assert second.user_id == 7 + client.post.assert_called_once() diff --git a/tests/test_v2_client_wiring.py b/tests/test_v2_client_wiring.py index 68e4556..843a25c 100644 --- a/tests/test_v2_client_wiring.py +++ b/tests/test_v2_client_wiring.py @@ -2,11 +2,36 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch from bloomy import AsyncClient, Client from bloomy.v1 import V1, AsyncV1 -from bloomy.v2 import V2, AsyncV2 +from bloomy.v2 import V2, AsyncV2, default_graphql_url + + +def _v2_operations(namespace: V2 | AsyncV2) -> list[Any]: + return [ + namespace.user, + namespace.meeting, + namespace.issue, + namespace.headline, + namespace.todo, + namespace.goal, + namespace.milestone, + namespace.metric, + ] + + +class TestDefaultGraphqlUrl: + """Tests for `default_graphql_url`.""" + + def test_keeps_scheme_and_host(self) -> None: + """The path of the REST base URL is replaced with `/graphql/`.""" + assert ( + default_graphql_url("https://staging.example.com:8443/api/v1") + == "https://staging.example.com:8443/graphql/" + ) class TestClientV1V2Wiring: @@ -75,6 +100,25 @@ def test_v1_and_v2_share_the_same_http_client(self) -> None: assert client.v1.user._client is client._client assert client.v2.user._client is client._client + def test_v2_operations_share_one_user_id_cache(self) -> None: + """Every `client.v2.*` object resolves the current user through one cache.""" + with patch("bloomy.client.httpx.Client"): + client = Client(api_key="test-key") + + caches = {id(ops._user_id_cache) for ops in _v2_operations(client.v2)} + assert len(caches) == 1 + + client.v2.user._user_id_cache.user_id = 42 + assert client.v2.metric.user_id == 42 + + def test_separate_clients_do_not_share_the_cache(self) -> None: + """Each client has its own current-user cache.""" + with patch("bloomy.client.httpx.Client"): + first = Client(api_key="test-key") + second = Client(api_key="other-key") + + assert first.v2.issue._user_id_cache is not second.v2.issue._user_id_cache + class TestAsyncClientV1V2Wiring: """Tests for `AsyncClient.v1`/`AsyncClient.v2` and the top-level aliases.""" @@ -123,3 +167,14 @@ def test_v1_and_v2_share_the_same_http_client(self) -> None: assert client.v1.user._client is client._client assert client.v2.user._client is client._client + + def test_v2_operations_share_one_user_id_cache(self) -> None: + """Every async `client.v2.*` object shares one current-user cache.""" + with patch("bloomy.async_client.httpx.AsyncClient"): + client = AsyncClient(api_key="test-key") + + caches = {id(ops._user_id_cache) for ops in _v2_operations(client.v2)} + assert len(caches) == 1 + + client.v2.user.user_id = 42 + assert client.v2.todo.user_id == 42 diff --git a/tests/test_v2_goal.py b/tests/test_v2_goal.py index 2d587e9..9c47591 100644 --- a/tests/test_v2_goal.py +++ b/tests/test_v2_goal.py @@ -2,12 +2,13 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, Mock import pytest from bloomy.exceptions import GraphQLError +from bloomy.v2.base import MILESTONES_CONNECTION from bloomy.v2.models import Goal, GoalStatus from bloomy.v2.operations.goal import AsyncGoalOperations, GoalOperations @@ -157,9 +158,13 @@ def test_list_both_ids_raises(self) -> None: client = Mock() ops = GoalOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): ops.list(meeting_id=349524, user_id=1305290) + assert client.post.call_count == 0 + def test_create_defaults(self) -> None: """`create()` defaults the due date to 90 days out and attaches the meeting.""" client = Mock() @@ -252,6 +257,8 @@ def test_create_with_milestones_dicts_and_tuples(self) -> None: milestones=[ {"title": "M1", "due_date": 1767225600}, ("M2", 1767225600, True), + {"title": "M3", "due_date": date(2026, 1, 1), "completed": True}, + ("M4", datetime(2026, 1, 1, tzinfo=UTC)), ], ) @@ -260,6 +267,8 @@ def test_create_with_milestones_dicts_and_tuples(self) -> None: assert milestones == [ {"title": "M1", "dueDate": 1767225600.0, "completed": False}, {"title": "M2", "dueDate": 1767225600.0, "completed": True}, + {"title": "M3", "dueDate": 1767225600.0, "completed": True}, + {"title": "M4", "dueDate": 1767225600.0, "completed": False}, ] def test_create_defaults_user_id_to_current_user(self) -> None: @@ -419,7 +428,61 @@ def test_list_by_meeting_with_null_meeting_returns_empty(self) -> None: assert result == [] - def test_transform_goal_with_missing_milestones_and_meetings(self) -> None: + def test_details_with_null_milestones_and_meetings(self) -> None: + """`null` `milestones`/`meetings` connections validate as empty lists.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + node = {**GOAL_NODE, "milestones": None, "meetings": None} + client.post.return_value = _response({"data": {"goal": node}}) + + result = ops.details(5265048) + + assert result.milestones == [] + assert result.meetings == [] + + def test_details_query_selects_milestones_like_milestone_list(self) -> None: + """The goal query filters and orders milestones as `milestone.list()` does.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"goal": GOAL_NODE}}) + + ops.details(5265048) + + sent_query = client.post.call_args.kwargs["json"]["query"] + assert MILESTONES_CONNECTION in sent_query + assert "dateDeleted: { eq: null }" in MILESTONES_CONNECTION + + def test_update_notes_only(self) -> None: + """`update(notes=...)` alone is a valid update and sends only the pad.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-5"}}}), + _response({"data": {"EditGoal": {"id": 5265048}}}), + _response({"data": {"goal": GOAL_NODE}}), + ] + + ops.update(5265048, notes="Only notes") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "goalId": 5265048, + "notesId": "pad-5", + "collaborationEnabled": True, + } + + def test_update_raises_when_edit_goal_returns_null(self) -> None: + """A `null` `EditGoal` result raises `GraphQLError` without a re-read.""" + client = Mock() + ops = GoalOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditGoal": None}}) + + with pytest.raises(GraphQLError, match="update goal failed"): + ops.update(5265048, title="New title") + + assert client.post.call_count == 1 + + def test_details_without_milestones_and_meetings(self) -> None: """A goal payload without `milestones`/`meetings` keys yields empty lists.""" client = Mock() ops = GoalOperations(client, GRAPHQL_URL) @@ -521,9 +584,41 @@ async def test_list_both_ids_raises(self) -> None: client = AsyncMock() ops = AsyncGoalOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): await ops.list(meeting_id=349524, user_id=1305290) + assert client.post.call_count == 0 + + @pytest.mark.asyncio + async def test_list_by_user_include_archived(self) -> None: + """`list(user_id=..., include_archived=True)` skips the auth lookup.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"user": {"goals": {"nodes": [GOAL_NODE]}}}} + ) + + result = await ops.list(user_id=1305290, include_archived=True) + + assert [goal.id for goal in result] == [5265048] + assert client.post.call_count == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290, "where": None} + + @pytest.mark.asyncio + async def test_update_raises_when_edit_goal_returns_null(self) -> None: + """A `null` `EditGoal` result raises `GraphQLError` without a re-read.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"EditGoal": None}}) + + with pytest.raises(GraphQLError, match="restore goal failed"): + await ops.restore(5265048) + + assert client.post.call_count == 1 + @pytest.mark.asyncio async def test_list_by_meeting(self) -> None: """`list(meeting_id=...)` queries `meeting(id){ goals }`.""" diff --git a/tests/test_v2_headline.py b/tests/test_v2_headline.py index 05d22dc..09ca2cb 100644 --- a/tests/test_v2_headline.py +++ b/tests/test_v2_headline.py @@ -87,7 +87,9 @@ def test_list_both_meeting_and_user_raises(self) -> None: client = Mock() ops = HeadlineOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): ops.list(meeting_id=349524, user_id=1305290) def test_list_by_meeting_open_only(self) -> None: @@ -154,26 +156,27 @@ def test_list_by_meeting_dedupes_and_orders_by_creation_date(self) -> None: assert [headline.id for headline in result] == [1, 3] - def test_list_by_user_filters_archived_client_side(self) -> None: - """`list(user_id=...)` drops archived headlines client-side by default. + def test_list_by_user_filters_archived_server_side(self) -> None: + """`list(user_id=...)` filters archived headlines out with `where`. - The root `headlines(userId)` connection includes archived headlines - with no server-side filter applied here, so `list()` filters them out - itself unless `include_archived=True`. + The root `headlines(userId)` connection includes archived headlines, + so `list()` sends `archived: {eq: false}` unless `include_archived`. """ client = Mock() ops = HeadlineOperations(client, GRAPHQL_URL) - archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} client.post.return_value = _response( - {"data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}}} + {"data": {"headlines": {"nodes": [HEADLINE_NODE]}}} ) result = ops.list(user_id=1305290) - assert len(result) == 1 - assert result[0].id == 987654 - variables = client.post.call_args.kwargs["json"]["variables"] - assert variables == {"userId": 1305290} + assert [headline.id for headline in result] == [987654] + payload = client.post.call_args.kwargs["json"] + assert payload["variables"] == { + "userId": 1305290, + "where": {"archived": {"eq": False}}, + } + assert "headlines(userId: $userId, where: $where" in payload["query"] def test_list_by_user_include_archived_keeps_all(self) -> None: """`list(user_id=..., include_archived=True)` keeps archived headlines.""" @@ -187,6 +190,22 @@ def test_list_by_user_include_archived_keeps_all(self) -> None: result = ops.list(user_id=1305290, include_archived=True) assert {headline.id for headline in result} == {987654, 999} + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290, "where": None} + + def test_list_by_user_orders_by_creation_date(self) -> None: + """User-list headlines come back ordered by creation date.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + newer = {**HEADLINE_NODE, "id": 3, "dateCreated": 300} + older = {**HEADLINE_NODE, "id": 1, "dateCreated": 100} + client.post.return_value = _response( + {"data": {"headlines": {"nodes": [newer, older]}}} + ) + + result = ops.list(user_id=1305290) + + assert [headline.id for headline in result] == [1, 3] def test_list_defaults_to_current_user(self) -> None: """`list()` with neither id given fetches headlines for the current user.""" @@ -201,7 +220,7 @@ def test_list_defaults_to_current_user(self) -> None: assert len(result) == 1 variables = client.post.call_args_list[1].kwargs["json"]["variables"] - assert variables == {"userId": 1305290} + assert variables == {"userId": 1305290, "where": {"archived": {"eq": False}}} def test_create_without_notes(self) -> None: """`create()` without notes skips `CreateNote` and reads back details.""" @@ -308,9 +327,9 @@ def test_update_user_id_sends_assignee(self) -> None: def test_update_raises_graphql_error_on_top_level_error(self) -> None: """`update()` raises `GraphQLError` when the response carries `errors`. - Unlike `EditIssue`, `EditHeadline` returns a plain `IdModel { id }` - with no `success`/`message` fields, so failures surface only through - the top-level GraphQL `errors` array. + `EditHeadline` returns a plain `IdModel { id }` with no + `success`/`message` fields, so failures surface through the top-level + GraphQL `errors` array. """ client = Mock() ops = HeadlineOperations(client, GRAPHQL_URL) @@ -321,6 +340,28 @@ def test_update_raises_graphql_error_on_top_level_error(self) -> None: with pytest.raises(GraphQLError, match="headline not found"): ops.update(987654, title="New title") + def test_update_raises_when_edit_headline_is_null(self) -> None: + """`update()` raises `GraphQLError`, without re-reading, on a null result.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditHeadline": None}}) + + with pytest.raises(GraphQLError, match="update headline failed"): + ops.update(987654, title="New title") + + assert client.post.call_count == 1 + + def test_archive_raises_when_edit_headline_id_is_zero(self) -> None: + """`archive()` raises `GraphQLError` when `EditHeadline` returns id `0`.""" + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditHeadline": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="archive headline failed"): + ops.archive(987654) + + assert client.post.call_count == 1 + def test_archive(self) -> None: """`archive()` sends `archived: true`.""" client = Mock() @@ -599,27 +640,25 @@ async def test_list_both_meeting_and_user_raises(self) -> None: client = AsyncMock() ops = AsyncHeadlineOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): await ops.list(meeting_id=349524, user_id=1305290) @pytest.mark.asyncio - async def test_list_by_user_filters_archived_client_side(self) -> None: - """`list(user_id=...)` drops archived headlines client-side by default.""" + async def test_list_by_user_filters_archived_server_side(self) -> None: + """`list(user_id=...)` filters archived headlines out with `where`.""" client = AsyncMock() ops = AsyncHeadlineOperations(client, GRAPHQL_URL) - archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} - response = MagicMock() - response.status_code = 200 - response.json.return_value = { - "data": {"headlines": {"nodes": [HEADLINE_NODE, archived_node]}} - } - response.raise_for_status = MagicMock() - client.post.return_value = response + client.post.return_value = _async_response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE]}}} + ) result = await ops.list(user_id=1305290) - assert len(result) == 1 - assert result[0].id == 987654 + assert [headline.id for headline in result] == [987654] + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290, "where": {"archived": {"eq": False}}} @pytest.mark.asyncio async def test_update_no_fields_raises(self) -> None: @@ -689,6 +728,10 @@ def details() -> MagicMock: await ops.restore(987654) assert client.post.call_count == 4 + archive_input = client.post.call_args_list[0].kwargs["json"]["variables"] + restore_input = client.post.call_args_list[2].kwargs["json"]["variables"] + assert archive_input == {"input": {"headlineId": 987654, "archived": True}} + assert restore_input == {"input": {"headlineId": 987654, "archived": False}} @pytest.mark.asyncio async def test_details_with_null_assignee(self) -> None: @@ -824,6 +867,8 @@ async def test_list_by_user_include_archived_keeps_all(self) -> None: result = await ops.list(user_id=1305290, include_archived=True) assert {headline.id for headline in result} == {987654, 999} + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables == {"userId": 1305290, "where": None} @pytest.mark.asyncio async def test_list_defaults_to_current_user(self) -> None: @@ -839,7 +884,7 @@ async def test_list_defaults_to_current_user(self) -> None: assert len(result) == 1 variables = client.post.call_args_list[1].kwargs["json"]["variables"] - assert variables == {"userId": 1305290} + assert variables == {"userId": 1305290, "where": {"archived": {"eq": False}}} @pytest.mark.asyncio async def test_create_without_notes(self) -> None: @@ -972,3 +1017,51 @@ async def test_update_raises_graphql_error_on_top_level_error(self) -> None: with pytest.raises(GraphQLError, match="headline not found"): await ops.update(987654, title="New title") + + @pytest.mark.asyncio + async def test_update_raises_when_edit_headline_is_null(self) -> None: + """`update()` raises `GraphQLError`, without re-reading, on a null result.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"EditHeadline": None}}) + + with pytest.raises(GraphQLError, match="update headline failed"): + await ops.update(987654, title="New title") + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_restore_raises_when_edit_headline_id_is_zero(self) -> None: + """`restore()` raises `GraphQLError` when `EditHeadline` returns id `0`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"EditHeadline": {"id": 0}}} + ) + + with pytest.raises(GraphQLError, match="restore headline failed"): + await ops.restore(987654) + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_update_all_fields_together(self) -> None: + """`update()` with every field set sends all of them at once.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"CreateNote": {"success": True, "data": "p"}}}), + _async_response({"data": {"EditHeadline": {"id": 987654}}}), + _async_response({"data": {"headline": HEADLINE_NODE}}), + ] + + await ops.update(987654, title="New title", user_id=42, notes="notes") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "headlineId": 987654, + "title": "New title", + "assignee": 42, + "notesId": "p", + "collaborationEnabled": True, + } diff --git a/tests/test_v2_issue.py b/tests/test_v2_issue.py index dca066b..d602b52 100644 --- a/tests/test_v2_issue.py +++ b/tests/test_v2_issue.py @@ -2,12 +2,13 @@ from __future__ import annotations +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock import pytest from bloomy.exceptions import GraphQLError -from bloomy.v2.models import Issue +from bloomy.v2.models import Issue, MeetingRef from bloomy.v2.operations.issue import AsyncIssueOperations, IssueOperations GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" @@ -33,6 +34,9 @@ "meeting": {"id": 349524, "name": "v2 API"}, } +# List queries select the meeting once on the parent, not on each node. +LIST_NODE = {key: value for key, value in ISSUE_NODE.items() if key != "meeting"} + def _response(json_data: object) -> Mock: response = Mock() @@ -42,6 +46,12 @@ def _response(json_data: object) -> Mock: return response +def _list_response(**connections: list[dict[str, Any]]) -> Mock: + meeting: dict[str, Any] = {"id": 349524, "name": "v2 API"} + meeting.update({name: {"nodes": nodes} for name, nodes in connections.items()}) + return _response({"data": {"meeting": meeting}}) + + class TestIssueOperationsSync: """Tests for the sync `IssueOperations`.""" @@ -109,9 +119,7 @@ def test_list_short_term_default_where(self) -> None: """`list()` filters short-term, open, non-sent-away issues by default.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - client.post.return_value = _response( - {"data": {"meeting": {"issues": {"nodes": [ISSUE_NODE]}}}} - ) + client.post.return_value = _list_response(issues=[LIST_NODE]) result = ops.list(349524) @@ -119,6 +127,7 @@ def test_list_short_term_default_where(self) -> None: assert client.post.call_count == 1 variables = client.post.call_args.kwargs["json"]["variables"] assert variables["meetingId"] == 349524 + assert variables["longTerm"] is False assert variables["includeSolved"] is False assert variables["includeArchived"] is False assert variables["where"] == { @@ -129,39 +138,61 @@ def test_list_short_term_default_where(self) -> None: } def test_list_long_term_uses_long_term_issues_connection(self) -> None: - """`list(long_term=True)` queries `longTermIssues`, not `issues`. + """`list(long_term=True)` selects `longTermIssues` instead of `issues`. `meeting.issues` drops archived issues server-side, and the API stores - long-term issues as archived, so `issues` never returns them. + long-term issues as archived, so `issues` never returns them. Both + connections are in one document, switched by `$longTerm`. """ client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - client.post.return_value = _response( - {"data": {"meeting": {"longTermIssues": {"nodes": [ISSUE_NODE]}}}} - ) + client.post.return_value = _list_response(longTermIssues=[LIST_NODE]) - result = ops.list(349524, long_term=True) + result = ops.list( + 349524, long_term=True, include_solved=True, include_archived=True + ) assert len(result) == 1 + assert client.post.call_count == 1 sent_query = client.post.call_args.kwargs["json"]["query"] - assert "longTermIssues" in sent_query + assert "@skip(if: $longTerm)" in sent_query + assert "@include(if: $longTerm)" in sent_query variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["longTerm"] is True + assert variables["includeSolved"] is False + assert variables["includeArchived"] is False assert variables["where"] == {"and": [{"addToDepartmentPlan": {"eq": True}}]} + def test_list_meeting_ref_comes_from_parent_meeting(self) -> None: + """Each listed issue's `meeting` is built from the parent `meeting`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _list_response( + issues=[LIST_NODE, {**LIST_NODE, "id": 2}] + ) + + result = ops.list(349524) + + assert [issue.meeting for issue in result] == [ + MeetingRef.model_validate({"id": 349524, "name": "v2 API"}) + ] * 2 + assert result[0].meeting is not result[1].meeting + + def test_list_unknown_meeting_returns_empty(self) -> None: + """A `null` `meeting` (unknown or invisible id) yields an empty list.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + assert ops.list(999999999) == [] + def test_list_include_solved_merges_in_one_request(self) -> None: """`include_solved=True` merges `recentlySolvedIssues` in a single request.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - solved_node = {**ISSUE_NODE, "id": 999, "completed": True} - client.post.return_value = _response( - { - "data": { - "meeting": { - "issues": {"nodes": [ISSUE_NODE]}, - "recentlySolvedIssues": {"nodes": [solved_node]}, - } - } - } + solved_node = {**LIST_NODE, "id": 999, "completed": True} + client.post.return_value = _list_response( + issues=[LIST_NODE], recentlySolvedIssues=[solved_node] ) result = ops.list(349524, include_solved=True) @@ -176,16 +207,9 @@ def test_list_include_archived_merges_in_one_request(self) -> None: """`include_archived=True` merges `archivedIssues` in a single request.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - archived_node = {**ISSUE_NODE, "id": 999, "archived": True} - client.post.return_value = _response( - { - "data": { - "meeting": { - "issues": {"nodes": []}, - "archivedIssues": {"nodes": [archived_node]}, - } - } - } + archived_node = {**LIST_NODE, "id": 999, "archived": True} + client.post.return_value = _list_response( + issues=[], archivedIssues=[archived_node] ) result = ops.list(349524, include_archived=True) @@ -198,19 +222,13 @@ def test_list_dedupes_and_orders_by_creation_date(self) -> None: """A node returned by more than one connection is deduped and re-sorted.""" client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - newer = {**ISSUE_NODE, "id": 3, "dateCreated": 300} - older = {**ISSUE_NODE, "id": 1, "dateCreated": 100} - duplicate_of_older = {**ISSUE_NODE, "id": 1, "dateCreated": 100} - client.post.return_value = _response( - { - "data": { - "meeting": { - "issues": {"nodes": [newer, older]}, - "recentlySolvedIssues": {"nodes": [duplicate_of_older]}, - "archivedIssues": {"nodes": []}, - } - } - } + newer = {**LIST_NODE, "id": 3, "dateCreated": 300} + older = {**LIST_NODE, "id": 1, "dateCreated": 100} + duplicate_of_older = {**LIST_NODE, "id": 1, "dateCreated": 100} + client.post.return_value = _list_response( + issues=[newer, older], + recentlySolvedIssues=[duplicate_of_older], + archivedIssues=[], ) result = ops.list(349524, include_solved=True, include_archived=True) @@ -218,17 +236,21 @@ def test_list_dedupes_and_orders_by_creation_date(self) -> None: assert [issue.id for issue in result] == [1, 3] def test_create_without_notes(self) -> None: - """`create()` without notes skips `CreateNote` and reads back details.""" + """`create()` without notes sends only `CreateIssue`, with no re-read. + + `CreateIssue` returns the full issue, so its result is validated directly. + """ client = Mock() ops = IssueOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"CreateIssue": {"id": 28471978}}}), - _response({"data": {"issue": ISSUE_NODE}}), - ] + client.post.return_value = _response({"data": {"CreateIssue": ISSUE_NODE}}) result = ops.create(349524, "SDK v2 test issue", user_id=1305290) + assert client.post.call_count == 1 assert result.id == 28471978 + assert result.meeting is not None and result.meeting.id == 349524 + assert result.owner is not None and result.owner.id == 1305290 + assert result.notes == "some notes" create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert create_variables == { "input": { @@ -245,12 +267,12 @@ def test_create_with_notes_calls_create_note_first(self) -> None: ops = IssueOperations(client, GRAPHQL_URL) client.post.side_effect = [ _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), - _response({"data": {"CreateIssue": {"id": 28471978}}}), - _response({"data": {"issue": ISSUE_NODE}}), + _response({"data": {"CreateIssue": ISSUE_NODE}}), ] ops.create(349524, "SDK v2 test issue", user_id=1305290, notes="hello") + assert client.post.call_count == 2 create_issue_variables = client.post.call_args_list[1].kwargs["json"][ "variables" ] @@ -263,8 +285,7 @@ def test_create_defaults_user_id_to_current_user(self) -> None: ops = IssueOperations(client, GRAPHQL_URL) client.post.side_effect = [ _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), - _response({"data": {"CreateIssue": {"id": 28471978}}}), - _response({"data": {"issue": ISSUE_NODE}}), + _response({"data": {"CreateIssue": ISSUE_NODE}}), ] ops.create(349524, "SDK v2 test issue") @@ -287,6 +308,15 @@ def test_create_raises_when_create_issue_id_is_zero(self) -> None: assert client.post.call_count == 1 + def test_create_raises_when_create_issue_is_null(self) -> None: + """`create()` raises `GraphQLError` when `CreateIssue` returns `null`.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateIssue": None}}) + + with pytest.raises(GraphQLError, match="create issue failed"): + ops.create(349524, "SDK v2 test issue", user_id=1305290) + def test_update_no_fields_raises(self) -> None: """`update()` with no fields raises `ValueError`.""" client = Mock() @@ -310,6 +340,55 @@ def test_update_title(self) -> None: edit_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert edit_variables == {"input": {"id": 28471978, "title": "New title"}} + def test_update_all_fields_and_notes(self) -> None: + """`update()` maps every field to its `EditIssue` input name.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-7"}}}), + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.update( + 28471978, + title="T", + user_id=5, + notes="N", + long_term=True, + meeting_id=7, + ) + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "id": 28471978, + "title": "T", + "assigneeId": 5, + "addToDepartmentPlan": True, + "meetingId": 7, + "notesId": "pad-7", + "collaborationEnabled": True, + } + + def test_update_notes_only(self) -> None: + """`update(notes=...)` alone is a valid update.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-8"}}}), + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + ops.update(28471978, notes="only notes") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "id": 28471978, + "notesId": "pad-8", + "collaborationEnabled": True, + } + def test_update_raises_graphql_error_on_failure(self) -> None: """`update()` raises `GraphQLError` when `EditIssue` reports failure.""" client = Mock() @@ -450,26 +529,35 @@ async def test_list_include_solved_and_archived_merges_in_one_request( """`list(include_solved=True, include_archived=True)` sends one request.""" client = AsyncMock() ops = AsyncIssueOperations(client, GRAPHQL_URL) - solved_node = {**ISSUE_NODE, "id": 2, "completed": True} - archived_node = {**ISSUE_NODE, "id": 3, "archived": True} - response = MagicMock() - response.status_code = 200 - response.json.return_value = { - "data": { - "meeting": { - "issues": {"nodes": [ISSUE_NODE]}, - "recentlySolvedIssues": {"nodes": [solved_node]}, - "archivedIssues": {"nodes": [archived_node]}, - } - } - } - response.raise_for_status = MagicMock() - client.post.return_value = response + solved_node = {**LIST_NODE, "id": 2, "completed": True} + archived_node = {**LIST_NODE, "id": 3, "archived": True} + client.post.return_value = _list_response( + issues=[LIST_NODE], + recentlySolvedIssues=[solved_node], + archivedIssues=[archived_node], + ) result = await ops.list(349524, include_solved=True, include_archived=True) assert client.post.call_count == 1 assert {issue.id for issue in result} == {ISSUE_NODE["id"], 2, 3} + assert all( + issue.meeting is not None and issue.meeting.id == 349524 for issue in result + ) + + @pytest.mark.asyncio + async def test_list_long_term(self) -> None: + """`list(long_term=True)` switches the connection via `$longTerm`.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + client.post.return_value = _list_response(longTermIssues=[LIST_NODE]) + + result = await ops.list(349524, long_term=True, include_solved=True) + + assert [issue.id for issue in result] == [ISSUE_NODE["id"]] + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["longTerm"] is True + assert variables["includeSolved"] is False @pytest.mark.asyncio async def test_update_no_fields_raises(self) -> None: @@ -495,20 +583,16 @@ async def test_create_with_notes(self) -> None: create_response = MagicMock() create_response.status_code = 200 - create_response.json.return_value = {"data": {"CreateIssue": {"id": 28471978}}} + create_response.json.return_value = {"data": {"CreateIssue": ISSUE_NODE}} create_response.raise_for_status = MagicMock() - details_response = MagicMock() - details_response.status_code = 200 - details_response.json.return_value = {"data": {"issue": ISSUE_NODE}} - details_response.raise_for_status = MagicMock() - - client.post.side_effect = [note_response, create_response, details_response] + client.post.side_effect = [note_response, create_response] result = await ops.create( 349524, "SDK v2 test issue", user_id=1305290, notes="hello" ) + assert client.post.call_count == 2 assert result.id == 28471978 create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] assert create_variables["input"]["notesId"] == "pad-99" @@ -552,3 +636,50 @@ def details() -> MagicMock: await ops.restore(28471978) assert client.post.call_count == 8 + edit_inputs = [ + call.kwargs["json"]["variables"]["input"] + for call in client.post.call_args_list[::2] + ] + assert edit_inputs[0]["completed"] is True + assert isinstance(edit_inputs[0]["completedTimestamp"], float) + assert edit_inputs[1:] == [ + {"id": 28471978, "completed": False, "completedTimestamp": None}, + {"id": 28471978, "archived": True}, + {"id": 28471978, "archived": False}, + ] + + @pytest.mark.asyncio + async def test_update_sends_fields_and_notes(self) -> None: + """The async `update()` sends the given fields plus the new pad id.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateNote": {"success": True, "data": "pad-3"}}}), + _response({"data": {"EditIssue": {"success": True, "message": None}}}), + _response({"data": {"issue": ISSUE_NODE}}), + ] + + await ops.update(28471978, title="T", notes="N") + + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == { + "id": 28471978, + "title": "T", + "notesId": "pad-3", + "collaborationEnabled": True, + } + + @pytest.mark.asyncio + async def test_create_defaults_user_id_to_current_user(self) -> None: + """The async `create()` fetches the authenticated user id first.""" + client = AsyncMock() + ops = AsyncIssueOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response({"data": {"CreateIssue": ISSUE_NODE}}), + ] + + await ops.create(349524, "SDK v2 test issue") + + create_input = client.post.call_args_list[1].kwargs["json"]["variables"] + assert create_input["input"]["ownerId"] == 1305290 diff --git a/tests/test_v2_meeting.py b/tests/test_v2_meeting.py index ccf10b8..6a46925 100644 --- a/tests/test_v2_meeting.py +++ b/tests/test_v2_meeting.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, Mock import pytest @@ -130,10 +131,78 @@ def test_attendees_returns_users(self) -> None: assert result[0].id == 1305290 assert result[0].email == "fran@example.com" + def test_list_created_date_is_utc(self) -> None: + """`list()` converts `createdTimestamp` to an aware UTC datetime.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"user": {"meetingsListLookup": {"nodes": [MEETING_LIST_NODE]}}}} + ) + + result = ops.list(user_id=1305290) + + assert result[0].created_date == datetime.fromtimestamp(1790273635, tz=UTC) + + def test_details_null_attendees_is_empty(self) -> None: + """`details()` returns no attendees when the connection is `null`.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"meeting": {**MEETING_DETAILS_DATA, "attendees": None}}} + ) + + result = ops.details(349524) + + assert result.attendees == [] + + def test_attendee_without_user_has_no_email(self) -> None: + """An attendee whose nested `user` is `null` gets `email=None`.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + node = {**ATTENDEE_NODE, "user": None} + client.post.return_value = _response( + {"data": {"meeting": {"attendees": {"nodes": [node]}}}} + ) + + result = ops.attendees(349524) + + assert result[0].email is None + assert result[0].full_name == "Fran Orozco" + + def test_attendees_unknown_meeting_returns_empty(self) -> None: + """`attendees()` returns `[]` when `meeting` is `null`.""" + client = Mock() + ops = MeetingOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"meeting": None}}) + + assert ops.attendees(999999999) == [] + class TestMeetingOperationsAsync: """Tests for the async `AsyncMeetingOperations`.""" + @pytest.mark.asyncio + async def test_list_defaults_to_current_user(self) -> None: + """`list()` with no `user_id` fetches the authenticated user first.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), + _response( + { + "data": { + "user": {"meetingsListLookup": {"nodes": [MEETING_LIST_NODE]}} + } + } + ), + ] + + result = await ops.list() + + assert [meeting.id for meeting in result] == [349524] + variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert variables == {"userId": 1305290} + @pytest.mark.asyncio async def test_list_explicit_user_id(self) -> None: """`list(user_id=...)` skips the current-user lookup.""" @@ -199,3 +268,17 @@ async def test_attendees_returns_users(self) -> None: assert len(result) == 1 assert result[0].id == 1305290 + + @pytest.mark.asyncio + async def test_attendee_without_user_has_no_email(self) -> None: + """An attendee whose nested `user` is `null` gets `email=None`.""" + client = AsyncMock() + ops = AsyncMeetingOperations(client, GRAPHQL_URL) + node = {**ATTENDEE_NODE, "user": None} + client.post.return_value = _response( + {"data": {"meeting": {"attendees": {"nodes": [node]}}}} + ) + + result = await ops.attendees(349524) + + assert result[0].email is None diff --git a/tests/test_v2_metric.py b/tests/test_v2_metric.py index 86d417d..3bf0126 100644 --- a/tests/test_v2_metric.py +++ b/tests/test_v2_metric.py @@ -2,12 +2,13 @@ from __future__ import annotations +from datetime import date from unittest.mock import AsyncMock, MagicMock, Mock import pytest from bloomy.exceptions import GraphQLError -from bloomy.v2.models import Metric +from bloomy.v2.models import Metric, MetricFrequency, MetricRule, MetricUnit from bloomy.v2.operations.metric import AsyncMetricOperations, MetricOperations GRAPHQL_URL = "https://app.bloomgrowth.com/graphql/" @@ -22,7 +23,6 @@ "minGoalValue": "100.00000", "maxGoalValue": None, "archived": False, - "metricType": "METRIC", "notesId": None, "notesText": "", "localHtml": None, @@ -32,26 +32,6 @@ "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, } -DIVIDER_NODE = { - "measurableId": 0, - "title": None, - "units": "NONE", - "rule": "GREATER_THAN", - "frequency": "WEEKLY", - "singleGoalValue": None, - "minGoalValue": None, - "maxGoalValue": None, - "archived": False, - "metricType": "DIVIDER", - "notesId": "0", - "notesText": "", - "localHtml": None, - "collaborationEnabled": False, - "dateCreated": 1790278038, - "isExternallySynced": False, - "assignee": None, -} - SCORE_NODE = { "id": 499964609, "value": "123.00000", @@ -60,6 +40,18 @@ "measurableId": 2036155, } +DUPLICATE_SCORE_ERROR = { + "errors": [ + { + "message": "Unexpected Execution Error", + "extensions": { + "message": "A score for this measurable and date already exists." + }, + } + ], + "data": {"CreateMetricScore": None}, +} + EMPTY_SCORE_NODE = { "id": 499964591, "value": "", @@ -111,12 +103,12 @@ def test_details_raises_when_metric_is_null(self) -> None: with pytest.raises(GraphQLError, match="Metric 999999999 not found"): ops.details(999999999) - def test_list_meeting_filters_divider_and_sets_where(self) -> None: - """`list(meeting_id=...)` filters DIVIDER rows and archived metrics.""" + def test_list_meeting_filters_dividers_server_side(self) -> None: + """`list(meeting_id=...)` excludes dividers and archived metrics via `where`.""" client = Mock() ops = MetricOperations(client, GRAPHQL_URL) client.post.return_value = _response( - {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE, DIVIDER_NODE]}}}} + {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE]}}}} ) result = ops.list(meeting_id=349524) @@ -150,7 +142,9 @@ def test_list_both_meeting_and_user_raises(self) -> None: client = Mock() ops = MetricOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): ops.list(meeting_id=349524, user_id=1305290) def test_list_defaults_to_current_user(self) -> None: @@ -511,7 +505,7 @@ def test_update_score_not_found_after_write_raises(self) -> None: _response({"data": {"metric": {"scoresNonPaginated": []}}}), ] - with pytest.raises(GraphQLError, match="not found"): + with pytest.raises(GraphQLError, match="Metric score 999 not found"): ops.update_score(2036155, 999, value=1) def test_clear_score(self) -> None: @@ -715,6 +709,185 @@ def test_set_score_daily_duplicate_no_existing_score_reraises(self) -> None: assert client.post.call_count == 2 + def test_list_user_with_frequency(self) -> None: + """`list(user_id=..., frequency=...)` queries `user.metrics` directly.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"user": {"metrics": {"nodes": [METRIC_NODE]}}}} + ) + + result = ops.list(user_id=42, frequency=MetricFrequency.MONTHLY) + + assert [metric.id for metric in result] == [2036155] + assert client.post.call_count == 1 + payload = client.post.call_args.kwargs["json"] + assert "user(id: $userId)" in payload["query"] + assert payload["variables"] == { + "userId": 42, + "where": { + "and": [ + {"archived": {"eq": False}}, + {"metricType": {"eq": "METRIC"}}, + {"frequency": {"eq": "MONTHLY"}}, + ] + }, + } + + def test_create_sends_enum_values_as_strings(self) -> None: + """`create()` sends `units`/`rule`/`frequency` enums by value.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"CreateMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.create( + 349524, + "SDK v2 test metric", + user_id=1305290, + goal="7.5", + units=MetricUnit.DOLLAR, + rule=MetricRule.LESS_THAN, + frequency=MetricFrequency.DAILY, + ) + + create_input = client.post.call_args_list[0].kwargs["json"]["variables"][ + "input" + ] + assert create_input["units"] == "DOLLAR" + assert create_input["rule"] == "LESS_THAN" + assert create_input["frequency"] == "DAILY" + assert create_input["singleGoalValue"] == "7.5" + + def test_update_between_range_goal(self) -> None: + """`update(rule=BETWEEN, min_goal=..., max_goal=...)` sends the range goal.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetric": {"id": 2036155}}}), + _response({"data": {"metric": METRIC_NODE}}), + ] + + ops.update(2036155, rule=MetricRule.BETWEEN, min_goal=1, max_goal=2.5) + + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == { + "metricId": 2036155, + "rule": "BETWEEN", + "minGoalValue": "1", + "maxGoalValue": "2.5", + } + + def test_update_raises_when_edit_metric_is_null(self) -> None: + """A `null` `EditMetric` result raises instead of re-reading the metric.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditMetric": None}}) + + with pytest.raises(GraphQLError, match="update metric failed"): + ops.update(2036155, title="New title") + + assert client.post.call_count == 1 + + def test_archive_raises_when_edit_metric_id_is_zero(self) -> None: + """An `EditMetric` result with id `0` raises instead of re-reading.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditMetric": {"id": 0}}}) + + with pytest.raises(GraphQLError, match="archive metric failed"): + ops.archive(2036155) + + assert client.post.call_count == 1 + + def test_update_score_notes_only_rereads_by_id(self) -> None: + """`update_score(notes=...)` sends only `notesText` and re-reads by id.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"EditMetricScore": {"id": 499964609}}}), + _response({"data": {"metric": {"scoresNonPaginated": [SCORE_NODE]}}}), + ] + + result = ops.update_score(2036155, 499964609, notes="probe note") + + assert result.notes == "probe note" + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 499964609, "notesText": "probe note"} + read_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert read_vars == {"metricId": 2036155, "where": {"id": {"eq": 499964609}}} + + def test_clear_score_raises_when_edit_is_null(self) -> None: + """A `null` `EditMetricScore` result raises instead of re-reading.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"EditMetricScore": None}}) + + with pytest.raises(GraphQLError, match="clear score failed"): + ops.clear_score(2036155, 499964609) + + assert client.post.call_count == 1 + + def test_scores_date_bounds_are_utc_midnight(self) -> None: + """`scores(start=date, end=date)` sends 00:00 UTC unix seconds.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"metric": {"scoresNonPaginated": []}}} + ) + + ops.scores(2036155, start=date(2026, 9, 21), end=date(2026, 9, 22)) + + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == { + "and": [ + {"timestamp": {"gte": 1789948800.0}}, + {"timestamp": {"lte": 1790035200.0}}, + ] + } + + def test_set_score_daily_duplicate_picks_same_utc_day(self) -> None: + """The DAILY fallback searches +/- 2 days and edits the same-UTC-day score.""" + client = Mock() + ops = MetricOperations(client, GRAPHQL_URL) + ts = 1790208001 # 2026-09-24 00:00:01 UTC + day_before = {**SCORE_NODE, "id": 7, "value": "", "timestamp": ts - 86400} + same_day = {**SCORE_NODE, "id": 8, "value": "", "timestamp": ts + 3600} + client.post.side_effect = [ + _response(DUPLICATE_SCORE_ERROR), + _response( + {"data": {"metric": {"scoresNonPaginated": [day_before, same_day]}}} + ), + _response({"data": {"EditMetricScore": {"id": 8}}}), + _response( + { + "data": { + "metric": { + "scoresNonPaginated": [{**same_day, "value": "3.00000"}] + } + } + } + ), + ] + + result = ops.set_score(2036158, 3, ts) + + assert result.id == 8 + lookup_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert lookup_vars == { + "metricId": 2036158, + "where": { + "and": [ + {"timestamp": {"gte": ts - 172800.0}}, + {"timestamp": {"lte": ts + 172800.0}}, + ] + }, + } + edit_vars = client.post.call_args_list[2].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 8, "value": "3"} + class TestMetricOperationsAsync: """Tests for the async `AsyncMetricOperations`.""" @@ -747,7 +920,9 @@ async def test_list_both_meeting_and_user_raises(self) -> None: client = AsyncMock() ops = AsyncMetricOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises( + ValueError, match="Cannot specify both meeting_id and user_id" + ): await ops.list(meeting_id=349524, user_id=1305290) @pytest.mark.asyncio @@ -944,12 +1119,12 @@ async def test_clear_score(self) -> None: assert result.value is None @pytest.mark.asyncio - async def test_list_meeting_filters_divider_and_sets_where(self) -> None: - """`list(meeting_id=...)` queries `meeting(id){ metrics }`, filters dividers.""" + async def test_list_meeting_filters_dividers_server_side(self) -> None: + """`list(meeting_id=...)` excludes dividers and archived metrics via `where`.""" client = AsyncMock() ops = AsyncMetricOperations(client, GRAPHQL_URL) client.post.return_value = _async_response( - {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE, DIVIDER_NODE]}}}} + {"data": {"meeting": {"metrics": {"nodes": [METRIC_NODE]}}}} ) result = await ops.list(meeting_id=349524) @@ -1294,7 +1469,7 @@ async def test_update_score_not_found_after_write_raises(self) -> None: _async_response({"data": {"metric": {"scoresNonPaginated": []}}}), ] - with pytest.raises(GraphQLError, match="not found"): + with pytest.raises(GraphQLError, match="Metric score 999 not found"): await ops.update_score(2036155, 999, value=1) @pytest.mark.asyncio @@ -1332,3 +1507,94 @@ async def test_details_notes_and_dates(self) -> None: assert result.notes == "Metric notes" assert result.notes_id == "pad-1" assert result.created_date.tzinfo is not None + + @pytest.mark.asyncio + async def test_list_user_with_frequency(self) -> None: + """`list(user_id=..., frequency=...)` queries `user.metrics` directly.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"user": {"metrics": {"nodes": [METRIC_NODE]}}}} + ) + + result = await ops.list(user_id=42, frequency=MetricFrequency.MONTHLY) + + assert [metric.id for metric in result] == [2036155] + assert client.post.call_count == 1 + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["userId"] == 42 + assert {"frequency": {"eq": "MONTHLY"}} in variables["where"]["and"] + + @pytest.mark.asyncio + async def test_update_raises_when_edit_metric_is_null(self) -> None: + """A `null` `EditMetric` result raises instead of re-reading the metric.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"EditMetric": None}}) + + with pytest.raises(GraphQLError, match="update metric failed"): + await ops.update(2036155, title="New title") + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_update_score_notes_only_rereads_by_id(self) -> None: + """`update_score(notes=...)` sends only `notesText` and re-reads by id.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _async_response({"data": {"EditMetricScore": {"id": 499964609}}}), + _async_response({"data": {"metric": {"scoresNonPaginated": [SCORE_NODE]}}}), + ] + + result = await ops.update_score(2036155, 499964609, notes="probe note") + + assert result.notes == "probe note" + edit_vars = client.post.call_args_list[0].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 499964609, "notesText": "probe note"} + read_vars = client.post.call_args_list[1].kwargs["json"]["variables"] + assert read_vars == {"metricId": 2036155, "where": {"id": {"eq": 499964609}}} + + @pytest.mark.asyncio + async def test_clear_score_raises_when_edit_is_null(self) -> None: + """A `null` `EditMetricScore` result raises instead of re-reading.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"EditMetricScore": None}}) + + with pytest.raises(GraphQLError, match="clear score failed"): + await ops.clear_score(2036155, 499964609) + + assert client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_set_score_daily_duplicate_picks_same_utc_day(self) -> None: + """The DAILY fallback edits the score on the same UTC day as `timestamp`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + ts = 1790208001 # 2026-09-24 00:00:01 UTC + day_before = {**SCORE_NODE, "id": 7, "value": "", "timestamp": ts - 86400} + same_day = {**SCORE_NODE, "id": 8, "value": "", "timestamp": ts + 3600} + client.post.side_effect = [ + _async_response(DUPLICATE_SCORE_ERROR), + _async_response( + {"data": {"metric": {"scoresNonPaginated": [day_before, same_day]}}} + ), + _async_response({"data": {"EditMetricScore": {"id": 8}}}), + _async_response( + { + "data": { + "metric": { + "scoresNonPaginated": [{**same_day, "value": "3.00000"}] + } + } + } + ), + ] + + result = await ops.set_score(2036158, 3, ts) + + assert result.id == 8 + assert result.value == 3.0 + edit_vars = client.post.call_args_list[2].kwargs["json"]["variables"] + assert edit_vars["input"] == {"id": 8, "value": "3"} diff --git a/tests/test_v2_milestone.py b/tests/test_v2_milestone.py index e85731e..e3ec776 100644 --- a/tests/test_v2_milestone.py +++ b/tests/test_v2_milestone.py @@ -8,6 +8,7 @@ import pytest from bloomy.exceptions import GraphQLError +from bloomy.v2.base import MILESTONES_CONNECTION from bloomy.v2.models import Milestone from bloomy.v2.operations.milestone import AsyncMilestoneOperations, MilestoneOperations @@ -59,7 +60,8 @@ def test_list(self) -> None: assert result[0].goal_id == 5265048 variables = client.post.call_args.kwargs["json"]["variables"] assert variables == {"goalId": 5265048} - # Transform: unix-seconds timestamp becomes a UTC-aware datetime. + assert MILESTONES_CONNECTION in client.post.call_args.kwargs["json"]["query"] + # Unix-seconds timestamps become UTC-aware datetimes. assert result[0].due_date.tzinfo is not None def test_list_with_null_goal_returns_empty(self) -> None: @@ -230,7 +232,7 @@ def test_update_wrong_goal_id_raises_without_writing(self) -> None: {"data": {"goal": {"milestones": {"nodes": []}}}} ) - with pytest.raises(GraphQLError, match="not found"): + with pytest.raises(GraphQLError, match="Milestone 6440354 not found"): ops.update(6440354, goal_id=999999, title="Updated") assert client.post.call_count == 1 @@ -259,9 +261,25 @@ def test_complete(self) -> None: assert result.completed is True assert client.post.call_count == 3 + verify_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert verify_variables == {"goalId": 5265048, "milestoneId": 6440354} edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] assert edit_input == {"milestoneId": 6440354, "completed": True} + def test_update_raises_when_edit_milestone_returns_null(self) -> None: + """A `null` `EditMilestone` result raises `GraphQLError` without a re-read.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.side_effect = [ + _response({"data": {"goal": {"milestones": {"nodes": [MILESTONE_NODE]}}}}), + _response({"data": {"EditMilestone": None}}), + ] + + with pytest.raises(GraphQLError, match="update milestone failed"): + ops.update(6440354, goal_id=5265048, title="Updated") + + assert client.post.call_count == 2 + def test_complete_wrong_goal_id_raises_without_writing(self) -> None: """A `goal_id` that does not own `milestone_id` raises before any write.""" client = Mock() @@ -270,7 +288,7 @@ def test_complete_wrong_goal_id_raises_without_writing(self) -> None: {"data": {"goal": {"milestones": {"nodes": []}}}} ) - with pytest.raises(GraphQLError, match="not found"): + with pytest.raises(GraphQLError, match="Milestone 6440354 not found"): ops.complete(6440354, goal_id=999999) assert client.post.call_count == 1 @@ -500,6 +518,8 @@ async def test_complete(self) -> None: assert result.completed is True assert client.post.call_count == 3 + edit_input = client.post.call_args_list[1].kwargs["json"]["variables"]["input"] + assert edit_input == {"milestoneId": 6440354, "completed": True} @pytest.mark.asyncio async def test_complete_wrong_goal_id_raises_without_writing(self) -> None: diff --git a/tests/test_v2_models.py b/tests/test_v2_models.py new file mode 100644 index 0000000..a9f7c91 --- /dev/null +++ b/tests/test_v2_models.py @@ -0,0 +1,196 @@ +"""Tests for the v2 (GraphQL) model base behaviour.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from bloomy.v2.models import ( + Goal, + Headline, + Issue, + Meeting, + Metric, + MetricScore, + User, +) + +NOTES = { + "notesId": "pad-1", + "notesText": "pad text\n", + "localHtml": None, + "collaborationEnabled": True, +} + + +class TestGqlBaseModel: + """Tests for alias/name validation and the notes fill.""" + + def test_alias_takes_precedence_over_field_name(self) -> None: + """The aliased key wins when a raw key is also named like the field. + + On `meeting(id){ metrics }` the raw `id` is a meeting-link id, not the + metric id (`measurableId`). + """ + metric = Metric.model_validate( + { + "id": 111, + "measurableId": 2036155, + "units": "NONE", + "rule": "GREATER_THAN", + "frequency": "WEEKLY", + "dateCreated": 1790000000, + } + ) + + assert metric.id == 2036155 + + def test_fills_notes_from_pad_text(self) -> None: + """`notes` comes from `notesText` when collaboration is enabled.""" + headline = Headline.model_validate( + {"id": 1, "dateCreated": 1790000000, **NOTES} + ) + + assert headline.notes == "pad text" + assert headline.notes_id == "pad-1" + + def test_fills_notes_from_local_html(self) -> None: + """Without collaboration, `notes` is the tag-stripped `localHtml`.""" + headline = Headline.model_validate( + { + "id": 1, + "dateCreated": 1790000000, + **NOTES, + "collaborationEnabled": False, + "localHtml": "

a & b

", + } + ) + + assert headline.notes == "a & b" + + def test_datetimes_are_utc(self) -> None: + """Unix-seconds timestamps become aware UTC datetimes.""" + headline = Headline.model_validate({"id": 1, "dateCreated": 0}) + + assert headline.created_date == datetime(1970, 1, 1, tzinfo=UTC) + + def test_model_dump_round_trips(self) -> None: + """`model_validate(model_dump())` rebuilds an equal model.""" + issue = Issue.model_validate( + { + "id": 1, + "addToDepartmentPlan": True, + "archived": True, + "dateCreated": 1790000000, + "assignee": {"id": 3, "fullName": "Fran Orozco"}, + **NOTES, + } + ) + score = MetricScore.model_validate( + {"id": 5, "measurableId": 9, "value": "12.5", "notesText": "note"} + ) + + assert Issue.model_validate(issue.model_dump()) == issue + assert Issue.model_validate(issue.model_dump(by_alias=True)) == issue + assert MetricScore.model_validate(score.model_dump()) == score + + def test_assignment_keeps_working(self) -> None: + """Field assignment is still validated by field name.""" + headline = Headline.model_validate({"id": 1, "dateCreated": 0, **NOTES}) + headline.archived = True + + assert headline.archived is True + assert headline.notes == "pad text" + + +class TestConnections: + """Tests for nested `{nodes}` connections.""" + + def test_meeting_attendees_unwrap_nodes_and_nested_email(self) -> None: + """`attendees { nodes }` validates directly, reading `user { email }`.""" + meeting = Meeting.model_validate( + { + "id": 349524, + "createdTimestamp": 1790000000, + "attendees": { + "nodes": [ + {"id": 1, "fullName": "A", "user": {"email": "a@x.com"}}, + {"id": 2, "fullName": "B", "user": None}, + ] + }, + } + ) + + assert [user.email for user in meeting.attendees] == ["a@x.com", None] + + def test_goal_connections(self) -> None: + """`milestones`/`meetings` connections unwrap, and `null` becomes `[]`.""" + goal = Goal.model_validate( + { + "id": 5, + "dateCreated": 1790000000, + "milestones": None, + "meetings": {"nodes": [{"id": 349524, "name": "v2 API"}]}, + } + ) + + assert goal.milestones == [] + assert [meeting.id for meeting in goal.meetings] == [349524] + + def test_connection_accepts_plain_list(self) -> None: + """A connection field given as a plain list validates unchanged.""" + goal = Goal.model_validate( + { + "id": 5, + "dateCreated": 1790000000, + "meetings": [{"id": 349524, "name": "v2 API"}], + } + ) + + assert [meeting.name for meeting in goal.meetings] == ["v2 API"] + + def test_user_email_flat(self) -> None: + """A flat `email` (from `user(id)`) is read as-is.""" + assert User.model_validate({"id": 1, "email": "a@x.com"}).email == "a@x.com" + + +class TestIssueLongTerm: + """Tests for the `Issue` long-term/archived rule.""" + + def test_long_term_issue_is_not_archived(self) -> None: + """The API's `archived: true` on a long-term issue is not reported.""" + issue = Issue.model_validate( + { + "id": 1, + "dateCreated": 1790000000, + "archived": True, + "addToDepartmentPlan": True, + } + ) + + assert issue.long_term is True + assert issue.archived is False + + def test_short_term_archived_issue_is_archived(self) -> None: + """A short-term archived issue stays archived.""" + issue = Issue.model_validate( + { + "id": 1, + "dateCreated": 1790000000, + "archived": True, + "addToDepartmentPlan": False, + } + ) + + assert issue.archived is True + + +class TestMetricScoreNotes: + """Tests for `MetricScore.notes`, read from the score's own `notesText`.""" + + def test_notes_text(self) -> None: + """`notesText` becomes `notes`, and an empty one becomes `None`.""" + base = {"id": 1, "measurableId": 2, "timestamp": 1790000000} + + assert MetricScore.model_validate({**base, "notesText": "n"}).notes == "n" + assert MetricScore.model_validate({**base, "notesText": ""}).notes is None + assert MetricScore.model_validate(base).notes is None diff --git a/tests/test_v2_todo.py b/tests/test_v2_todo.py index 0660d12..e5ea95e 100644 --- a/tests/test_v2_todo.py +++ b/tests/test_v2_todo.py @@ -135,51 +135,77 @@ def test_list_both_meeting_and_user_raises(self) -> None: client = Mock() ops = TodoOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises(ValueError, match="Cannot specify both meeting_id and"): ops.list(meeting_id=349524, user_id=1305290) + client.post.assert_not_called() - def test_list_by_meeting_default_where(self) -> None: - """`list(meeting_id=...)` excludes completed and archived to-dos by default.""" + def test_list_by_meeting_default_reads_active_todos(self) -> None: + """`list(meeting_id=...)` reads `todosActives`, filtering out completed.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) client.post.return_value = _response( - {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + {"data": {"meeting": {"todosActives": {"nodes": [TODO_NODE]}}}} ) result = ops.list(meeting_id=349524) - assert len(result) == 1 - variables = client.post.call_args.kwargs["json"]["variables"] - assert variables["meetingId"] == 349524 - assert variables["where"] == { - "and": [{"completed": {"eq": False}}, {"archived": {"eq": False}}] + assert [todo.id for todo in result] == [555001] + assert result[0].notes == "some notes" + payload = client.post.call_args.kwargs["json"] + assert payload["variables"] == { + "meetingId": 349524, + "where": {"completed": {"eq": False}}, + "includeArchived": False, } + assert "@skip(if: $includeArchived)" in payload["query"] + assert "@include(if: $includeArchived)" in payload["query"] def test_list_by_meeting_include_completed_and_archived(self) -> None: - """`include_completed=True, include_archived=True` sends no `where` filter.""" + """`include_completed` and `include_archived` read every to-do, unfiltered.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) client.post.return_value = _response( {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} ) - ops.list(meeting_id=349524, include_completed=True, include_archived=True) + result = ops.list( + meeting_id=349524, include_completed=True, include_archived=True + ) + assert [todo.id for todo in result] == [555001] variables = client.post.call_args.kwargs["json"]["variables"] assert variables["where"] is None + assert variables["includeArchived"] is True def test_list_by_meeting_include_completed_only(self) -> None: - """`include_completed=True` alone still filters out archived to-dos.""" + """`include_completed=True` alone reads `todosActives` with no filter.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) client.post.return_value = _response( - {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + {"data": {"meeting": {"todosActives": {"nodes": [TODO_NODE]}}}} ) ops.list(meeting_id=349524, include_completed=True) variables = client.post.call_args.kwargs["json"]["variables"] - assert variables["where"] == {"and": [{"archived": {"eq": False}}]} + assert variables["where"] is None + assert variables["includeArchived"] is False + + def test_list_by_meeting_include_archived_only(self) -> None: + """`include_archived=True` alone reads `todos`, filtering out completed.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + archived = {**TODO_NODE, "id": 555002, "archived": True} + client.post.return_value = _response( + {"data": {"meeting": {"todos": {"nodes": [TODO_NODE, archived]}}}} + ) + + result = ops.list(meeting_id=349524, include_archived=True) + + assert [todo.archived for todo in result] == [False, True] + variables = client.post.call_args.kwargs["json"]["variables"] + assert variables["where"] == {"completed": {"eq": False}} + assert variables["includeArchived"] is True def test_list_by_meeting_missing_meeting_returns_empty(self) -> None: """A `null` `meeting` (e.g. bad id) produces an empty list, not an error.""" @@ -216,7 +242,10 @@ def test_list_by_user_defaults_to_current_user(self) -> None: assert len(result) == 1 list_variables = client.post.call_args_list[1].kwargs["json"]["variables"] - assert list_variables["userId"] == 1305290 + assert list_variables == { + "userId": 1305290, + "where": {"completed": {"eq": False}}, + } def test_list_by_user_explicit_id(self) -> None: """`list(user_id=...)` queries the root `todos(userId)` connection.""" @@ -234,13 +263,11 @@ def test_list_by_user_explicit_id(self) -> None: sent_query = client.post.call_args.kwargs["json"]["query"] assert "todos(userId" in sent_query - def test_list_by_user_include_archived_where_still_sent(self) -> None: - """`list(user_id=..., include_archived=True)` still builds a `where`. + def test_list_by_user_ignores_include_archived(self) -> None: + """`include_archived` sends nothing extra when listing by `user_id`. - The API ignores the `archived` clause server-side for the - user-scoped connection (see `list()`'s docstring), but the client - builds `where` the same way regardless of scope: only the - `completed` clause survives here. + `todos(userId)` excludes archived to-dos server-side, so the query has + no include-archived variant. """ client = Mock() ops = TodoOperations(client, GRAPHQL_URL) @@ -248,10 +275,10 @@ def test_list_by_user_include_archived_where_still_sent(self) -> None: {"data": {"todos": {"nodes": [TODO_NODE]}}} ) - ops.list(user_id=1305290, include_archived=True) + ops.list(user_id=1305290, include_completed=True, include_archived=True) variables = client.post.call_args.kwargs["json"]["variables"] - assert variables["where"] == {"and": [{"completed": {"eq": False}}]} + assert variables == {"userId": 1305290, "where": None} def test_list_by_user_missing_todos_returns_empty(self) -> None: """A missing `todos` connection produces an empty list, not an error.""" @@ -269,8 +296,7 @@ def test_create_without_notes_defaults_due_date_and_user(self) -> None: ops = TodoOperations(client, GRAPHQL_URL) client.post.side_effect = [ _response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), - _response({"data": {"CreateTodo": {"id": 555001}}}), - _response({"data": {"todo": TODO_NODE}}), + _response({"data": {"CreateTodo": TODO_NODE}}), ] result = ops.create("SDK v2 test to-do", meeting_id=349524) @@ -289,39 +315,68 @@ def test_create_without_notes_defaults_due_date_and_user(self) -> None: assert due_date.date() == expected_date assert due_date.time() == time(0, 0) + def test_create_returns_mutation_result_without_rereading(self) -> None: + """`create()` validates the `CreateTodo` result instead of re-reading.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateTodo": TODO_NODE}}) + + result = ops.create("SDK v2 test to-do", meeting_id=349524, user_id=1305290) + + assert client.post.call_count == 1 + assert result.title == "SDK v2 test to-do" + assert result.owner is not None and result.owner.full_name == "Fran Orozco" + assert result.meeting is not None and result.meeting.name == "v2 API" + assert result.notes == "some notes" + sent_query = client.post.call_args.kwargs["json"]["query"] + assert "CreateTodo(input: $input)" in sent_query + assert "assignee { id fullName }" in sent_query + assert "meeting { id name }" in sent_query + assert "notesText" in sent_query + def test_create_personal_todo_sends_null_meeting_id(self) -> None: """`create()` without `meeting_id` sends `meetingRecurrenceId: null`.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"CreateTodo": {"id": 555001}}}), - _response({"data": {"todo": TODO_NODE}}), - ] + client.post.return_value = _response( + {"data": {"CreateTodo": {**TODO_NODE, "meeting": None}}} + ) - ops.create("Personal to-do", user_id=1305290) + result = ops.create("Personal to-do", user_id=1305290) - create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert result.meeting is None + create_variables = client.post.call_args.kwargs["json"]["variables"] assert create_variables["input"]["meetingRecurrenceId"] is None def test_create_raises_when_create_todo_id_is_zero(self) -> None: """`create()` raises `GraphQLError` when `CreateTodo` returns id `0`.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) - client.post.return_value = _response({"data": {"CreateTodo": {"id": 0}}}) + client.post.return_value = _response( + {"data": {"CreateTodo": {**TODO_NODE, "id": 0}}} + ) - with pytest.raises(GraphQLError, match="create todo failed"): + with pytest.raises(GraphQLError, match="create todo failed: no id returned"): ops.create("SDK v2 test to-do", user_id=1305290) assert client.post.call_count == 1 + def test_create_raises_when_create_todo_is_null(self) -> None: + """`create()` raises `GraphQLError` when `CreateTodo` is `null`.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"CreateTodo": None}}) + + with pytest.raises( + GraphQLError, match="create todo failed: no result returned" + ): + ops.create("SDK v2 test to-do", user_id=1305290) + def test_create_with_explicit_due_date(self) -> None: """`create(due_date=...)` converts the given date to a unix timestamp.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"CreateTodo": {"id": 555001}}}), - _response({"data": {"todo": TODO_NODE}}), - ] + client.post.return_value = _response({"data": {"CreateTodo": TODO_NODE}}) ops.create( "SDK v2 test to-do", @@ -338,10 +393,7 @@ def test_create_with_explicit_datetime_due_date_preserves_time(self) -> None: """`create(due_date=)` preserves the time-of-day.""" client = Mock() ops = TodoOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _response({"data": {"CreateTodo": {"id": 555001}}}), - _response({"data": {"todo": TODO_NODE}}), - ] + client.post.return_value = _response({"data": {"CreateTodo": TODO_NODE}}) due = datetime(2026, 10, 1, 15, 30, tzinfo=UTC) ops.create( @@ -357,14 +409,14 @@ def test_create_with_notes_calls_create_note_first(self) -> None: ops = TodoOperations(client, GRAPHQL_URL) client.post.side_effect = [ _response({"data": {"CreateNote": {"success": True, "data": "pad-99"}}}), - _response({"data": {"CreateTodo": {"id": 555001}}}), - _response({"data": {"todo": TODO_NODE}}), + _response({"data": {"CreateTodo": TODO_NODE}}), ] ops.create( "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" ) + assert client.post.call_count == 2 note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert note_variables == {"text": "hello"} create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] @@ -701,41 +753,55 @@ async def test_list_both_meeting_and_user_raises(self) -> None: client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) - with pytest.raises(ValueError, match="not both"): + with pytest.raises(ValueError, match="Cannot specify both meeting_id and"): await ops.list(meeting_id=349524, user_id=1305290) client.post.assert_not_called() @pytest.mark.asyncio - async def test_list_by_meeting_default_where(self) -> None: - """`list(meeting_id=...)` excludes completed and archived to-dos by default.""" + async def test_list_by_meeting_default_reads_active_todos(self) -> None: + """`list(meeting_id=...)` reads `todosActives`, filtering out completed.""" client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) client.post.return_value = _async_response( - {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} + {"data": {"meeting": {"todosActives": {"nodes": [TODO_NODE]}}}} ) result = await ops.list(meeting_id=349524) - assert len(result) == 1 + assert [todo.id for todo in result] == [555001] variables = client.post.call_args.kwargs["json"]["variables"] - assert variables["meetingId"] == 349524 - assert variables["where"] == { - "and": [{"completed": {"eq": False}}, {"archived": {"eq": False}}] + assert variables == { + "meetingId": 349524, + "where": {"completed": {"eq": False}}, + "includeArchived": False, } @pytest.mark.asyncio async def test_list_by_meeting_include_completed_and_archived(self) -> None: - """`include_completed=True, include_archived=True` sends no `where` filter.""" + """`include_completed` and `include_archived` read every to-do, unfiltered.""" client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) client.post.return_value = _async_response( {"data": {"meeting": {"todos": {"nodes": [TODO_NODE]}}}} ) - await ops.list(meeting_id=349524, include_completed=True, include_archived=True) + result = await ops.list( + meeting_id=349524, include_completed=True, include_archived=True + ) + assert [todo.id for todo in result] == [555001] variables = client.post.call_args.kwargs["json"]["variables"] assert variables["where"] is None + assert variables["includeArchived"] is True + + @pytest.mark.asyncio + async def test_list_by_meeting_missing_meeting_returns_empty(self) -> None: + """A `null` `meeting` (e.g. bad id) produces an empty list, not an error.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"meeting": None}}) + + assert await ops.list(meeting_id=999999) == [] @pytest.mark.asyncio async def test_list_raises_graphql_error_on_200_with_errors(self) -> None: @@ -800,13 +866,14 @@ async def test_create_without_notes_defaults_due_date_and_user(self) -> None: ops = AsyncTodoOperations(client, GRAPHQL_URL) client.post.side_effect = [ _async_response({"data": {"getAuthenticatedUserId": {"id": 1305290}}}), - _async_response({"data": {"CreateTodo": {"id": 555001}}}), - _async_response({"data": {"todo": TODO_NODE}}), + _async_response({"data": {"CreateTodo": TODO_NODE}}), ] result = await ops.create("SDK v2 test to-do", meeting_id=349524) assert result.id == 555001 + assert result.notes == "some notes" + assert client.post.call_count == 2 create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] input_ = create_variables["input"] assert input_["title"] == "SDK v2 test to-do" @@ -824,14 +891,14 @@ async def test_create_personal_todo_sends_null_meeting_id(self) -> None: """`create()` without `meeting_id` sends `meetingRecurrenceId: null`.""" client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _async_response({"data": {"CreateTodo": {"id": 555001}}}), - _async_response({"data": {"todo": TODO_NODE}}), - ] + client.post.return_value = _async_response( + {"data": {"CreateTodo": {**TODO_NODE, "meeting": None}}} + ) - await ops.create("Personal to-do", user_id=1305290) + result = await ops.create("Personal to-do", user_id=1305290) - create_variables = client.post.call_args_list[0].kwargs["json"]["variables"] + assert result.meeting is None + create_variables = client.post.call_args.kwargs["json"]["variables"] assert create_variables["input"]["meetingRecurrenceId"] is None @pytest.mark.asyncio @@ -839,22 +906,33 @@ async def test_create_raises_when_create_todo_id_is_zero(self) -> None: """`create()` raises `GraphQLError` when `CreateTodo` returns id `0`.""" client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) - client.post.return_value = _async_response({"data": {"CreateTodo": {"id": 0}}}) + client.post.return_value = _async_response( + {"data": {"CreateTodo": {**TODO_NODE, "id": 0}}} + ) - with pytest.raises(GraphQLError, match="create todo failed"): + with pytest.raises(GraphQLError, match="create todo failed: no id returned"): await ops.create("SDK v2 test to-do", user_id=1305290) assert client.post.call_count == 1 + @pytest.mark.asyncio + async def test_create_raises_when_create_todo_is_null(self) -> None: + """`create()` raises `GraphQLError` when `CreateTodo` is `null`.""" + client = AsyncMock() + ops = AsyncTodoOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response({"data": {"CreateTodo": None}}) + + with pytest.raises( + GraphQLError, match="create todo failed: no result returned" + ): + await ops.create("SDK v2 test to-do", user_id=1305290) + @pytest.mark.asyncio async def test_create_with_explicit_due_date(self) -> None: """`create(due_date=...)` converts the given date to a unix timestamp.""" client = AsyncMock() ops = AsyncTodoOperations(client, GRAPHQL_URL) - client.post.side_effect = [ - _async_response({"data": {"CreateTodo": {"id": 555001}}}), - _async_response({"data": {"todo": TODO_NODE}}), - ] + client.post.return_value = _async_response({"data": {"CreateTodo": TODO_NODE}}) await ops.create( "SDK v2 test to-do", @@ -876,8 +954,7 @@ async def test_create_with_notes(self) -> None: _async_response( {"data": {"CreateNote": {"success": True, "data": "pad-99"}}} ), - _async_response({"data": {"CreateTodo": {"id": 555001}}}), - _async_response({"data": {"todo": TODO_NODE}}), + _async_response({"data": {"CreateTodo": TODO_NODE}}), ] result = await ops.create( @@ -885,6 +962,7 @@ async def test_create_with_notes(self) -> None: ) assert result.id == 555001 + assert client.post.call_count == 2 note_variables = client.post.call_args_list[0].kwargs["json"]["variables"] assert note_variables == {"text": "hello"} create_variables = client.post.call_args_list[1].kwargs["json"]["variables"] diff --git a/tests/test_v2_user.py b/tests/test_v2_user.py index 266c82f..917ab76 100644 --- a/tests/test_v2_user.py +++ b/tests/test_v2_user.py @@ -89,6 +89,14 @@ def test_list_returns_all_users(self) -> None: assert result[0].id == 1305290 assert result[1].full_name == "Scout Bloom" + def test_list_null_connection_returns_empty(self) -> None: + """`list()` returns `[]` when the `users` connection is `null`.""" + client = Mock() + ops = UserOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"users": None}}) + + assert ops.list() == [] + class TestUserOperationsAsync: """Tests for the async `AsyncUserOperations`.""" @@ -146,3 +154,15 @@ async def test_list_returns_all_users(self) -> None: assert len(result) == 1 assert result[0].id == 1305290 + + @pytest.mark.asyncio + async def test_details_explicit_user_id(self) -> None: + """`details(user_id=...)` skips the current-user lookup.""" + client = AsyncMock() + ops = AsyncUserOperations(client, GRAPHQL_URL) + client.post.return_value = _response({"data": {"user": USER_NODE}}) + + result = await ops.details(user_id=1305290) + + assert result.email == "fran@example.com" + client.post.assert_called_once()