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. 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 30cb0fd..fcfdef7 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,22 @@ 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 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`)**: - 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/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..e3b0161 --- /dev/null +++ b/docs/api/v2/milestone.md @@ -0,0 +1,121 @@ +# 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 keyword-only: verified before anything is + # written, and needed again to re-read the milestone afterward) + updated = client.v2.milestone.update( + milestone.id, goal_id=milestone.goal_id, title="Draft spec v2" + ) + + # Mark it complete + 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) + ``` + +=== "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, goal_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` (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 `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 + (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..0c9cf23 --- /dev/null +++ b/docs/guide/v2-graphql-api.md @@ -0,0 +1,360 @@ +# 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` (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` + +| 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. | + +## 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`, +`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 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 + +`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()`). +- 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. 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 + +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..c32e643 100644 --- a/src/bloomy/async_client.py +++ b/src/bloomy/async_client.py @@ -21,6 +21,8 @@ AsyncTodoOperations, AsyncUserOperations, ) + from .v1 import AsyncV1 + from .v2 import AsyncV2 class AsyncClient: @@ -29,6 +31,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 +70,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 +78,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 +93,14 @@ 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: + graphql_url = default_graphql_url(base_url) + self._graphql_url = graphql_url + self._client = httpx.AsyncClient( base_url=base_url, headers={ @@ -93,24 +110,21 @@ def __init__( timeout=timeout, ) - # 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 - ) + # 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..5f94171 100644 --- a/src/bloomy/client.py +++ b/src/bloomy/client.py @@ -8,13 +8,8 @@ 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, default_graphql_url if TYPE_CHECKING: from typing import Any @@ -25,6 +20,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 +36,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 +50,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 +59,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 +81,10 @@ def __init__( self._api_key = self.configuration.api_key self._base_url = base_url + if graphql_url is None: + graphql_url = default_graphql_url(base_url) + self._graphql_url = graphql_url + # Initialize HTTP client self._client = httpx.Client( base_url=base_url, @@ -83,14 +96,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..54c3e95 --- /dev/null +++ b/src/bloomy/v2/__init__.py @@ -0,0 +1,141 @@ +"""v2: the Bloom Growth GraphQL API. + +Exposed as `client.v2` / `AsyncClient().v2`. See `bloomy.v2.base` for the +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, + 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 + + +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: + """Initialize the v2 namespace. + + Args: + client: The sync HTTP client to use for GraphQL requests. + graphql_url: The absolute URL of the GraphQL endpoint. + + """ + 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`. Every operations object shares one cached current-user id. + """ + + 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. + + """ + 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__ = [ + "V2", + "AsyncV2", + "Goal", + "GoalStatus", + "Headline", + "Issue", + "Meeting", + "MeetingListItem", + "MeetingRef", + "Metric", + "MetricFrequency", + "MetricRule", + "MetricScore", + "MetricUnit", + "Milestone", + "Todo", + "User", + "UserRef", + "default_graphql_url", +] diff --git a/src/bloomy/v2/base.py b/src/bloomy/v2/base.py new file mode 100644 index 0000000..1b0a3f4 --- /dev/null +++ b/src/bloomy/v2/base.py @@ -0,0 +1,594 @@ +"""Transport and shared helpers for the Bloomy v2 (GraphQL) API. + +`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, 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 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) {{ + CreateNote(text: $text) {{ + data + {MUTATION_RESULT_FIELDS} + }} +}} +""" + +_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. + + 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 the `NOTES_FIELDS` projection. + + 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. + + 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 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. + + 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. + + Returns: + The merged nodes with each id appearing once (first occurrence + 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["dateCreated"], node["id"])) + + +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 + + +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 leading to the connection. + + Returns: + The connection's nodes, or an empty list if any level is missing or + `null`. + + """ + 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 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, 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. + + """ + super().__init__(client) + self._graphql_url = graphql_url + self._user_id_cache = user_id_cache or UserIdCache() + + 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. + + 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. + + 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 + ) -> 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 error + messages (e.g. `"create issue"`). + + Returns: + `result`, unchanged. + + Raises: + GraphQLError: If `result` is missing, has a falsy `success`, or + has an `id` of `0`. + + """ + if not result: + raise GraphQLError(f"{action} failed: no result returned") + 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 + ] + message = ( + result.get("message") + or "; ".join(filter(None, detail_messages)) + or f"{action} failed" + ) + raise GraphQLError(message, errors=error_details) + if "id" in result and not result["id"]: + raise GraphQLError(f"{action} failed: no id returned") + return result + + @staticmethod + def _one( + data: dict[str, Any], *path: str, label: str, entity_id: int + ) -> dict[str, Any]: + """Read the single entity at `path`, or raise if it is missing. + + 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. + *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 the entity is missing, `null`, or an empty list. + + """ + 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 cast("dict[str, Any]", found) + + +class GraphQLOperations(AbstractGraphQLOperations): + """Sync base class for v2 (GraphQL) operation classes.""" + + _client: httpx.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. + + """ + 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 + ) -> 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 _mutate( + self, + query: str, + variables: dict[str, Any] | None, + *, + root_field: str, + action: str, + ) -> dict[str, Any]: + """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 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) + 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 (escaped for Etherpad here). + + Returns: + The new pad's id, for use as `notesId` on a create/edit input. + + """ + result = self._mutate( + _CREATE_NOTE_MUTATION, + {"text": prepare_note_text(text)}, + root_field="CreateNote", + action="create note", + ) + return str(result["data"]) + + 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": 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: + """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. + + """ + 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 user_id + + @user_id.setter + def user_id(self, value: int) -> None: + """Set the cached user ID.""" + self._user_id_cache.user_id = value + + async def get_user_id(self) -> int: + """Get the current user's ID, fetching it once per client if needed. + + Returns: + The user ID of the authenticated user. + + """ + 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 + ) -> 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 _mutate( + self, + query: str, + variables: dict[str, Any] | None, + *, + root_field: str, + action: str, + ) -> dict[str, Any]: + """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 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]`). + + """ + data = await self._execute(query, variables) + 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 (escaped for Etherpad here). + + Returns: + The new pad's id, for use as `notesId` on a create/edit input. + + """ + result = await self._mutate( + _CREATE_NOTE_MUTATION, + {"text": prepare_note_text(text)}, + root_field="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 new file mode 100644 index 0000000..449f922 --- /dev/null +++ b/src/bloomy/v2/models.py @@ -0,0 +1,363 @@ +"""Pydantic models for the Bloomy v2 (GraphQL) API. + +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 datetime +from enum import StrEnum +from typing import Annotated, Any, cast + +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 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 [] + 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. +type GqlDatetime = Annotated[datetime, BeforeValidator(_parse_gql_datetime)] +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. + + 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 + `User` for the full `user`/`users` query result. + """ + + id: int + full_name: str | None = Field(default=None, alias="fullName") + + +class MeetingRef(GqlBaseModel): + """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(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 = Field( + default=None, + validation_alias=AliasChoices("email", AliasPath("user", "email")), + ) + avatar: str | None = None + + +class MeetingListItem(GqlBaseModel): + """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(GqlBaseModel): + """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: Connection[User] = Field(default_factory=list) + + +class Issue(GqlBaseModel): + """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") + + @model_validator(mode="before") + @classmethod + def _long_term_is_not_archived(cls, data: Any) -> Any: + """Report a long-term issue as not archived. + + 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 + 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(GqlBaseModel): + """Model for a to-do, from the `todo(id)` query and the to-do list 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(GqlBaseModel): + """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(GqlBaseModel): + """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: Connection[Milestone] = Field(default_factory=list) + meetings: Connection[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(GqlBaseModel): + """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(GqlBaseModel): + """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: Annotated[str | None, BeforeValidator(lambda value: value or None)] = Field( + default=None, validation_alias="notesText" + ) 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..155bba7 --- /dev/null +++ b/src/bloomy/v2/operations/goal.py @@ -0,0 +1,615 @@ +"""Goal (rock) operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ...exceptions import GraphQLError +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 +title +status +dueDate +archived +archivedTimestamp +dateCreated +{NOTES_FIELDS} +{USER_REF_FIELDS} +meetings {{ + nodes {{ + id + name + }} +}} +{MILESTONES_CONNECTION} +""" + +_GOALS_CONNECTION = f""" +goals(where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_GOAL_FIELDS} + }} +}} +""" + + +class GoalOperationsMixin: + """GraphQL documents, inputs, and response parsing shared by goal operations.""" + + _GOAL_DETAILS_QUERY = f""" + query($id: Long!) {{ + goal(id: $id) {{ + {_GOAL_FIELDS} + }} + }} + """ + + _GOAL_MEETING_LIST_QUERY = f""" + query($meetingId: Long!, $where: GoalQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + {_GOALS_CONNECTION} + }} + }} + """ + + # 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_CONNECTION} + }} + }} + """ + + _GOAL_CREATE_MUTATION = """ + mutation($input: GoalCreateModelInput!) { + CreateGoal(input: $input) { + id + } + } + """ + + _GOAL_EDIT_MUTATION = """ + mutation($input: GoalEditModelInput!) { + EditGoal(input: $input) { + id + } + } + """ + + @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: + The query document and its variables. + + """ + 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} + + @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: + The goals, ordered by due date. + + """ + nodes = [ + *dig_nodes(data, "meeting", "goals"), + *dig_nodes(data, "user", "goals"), + ] + return [Goal.model_validate(node) for node in nodes] + + @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": ...}` + (`completed` optional) or a `(title, due_date)` / + `(title, due_date, completed)` tuple. + + Returns: + The input fields. + + """ + if isinstance(item, dict): + 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, + } + + @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: + The input fields, with the due date defaulting to 90 days out. + + """ + 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} + ], + } + 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, + ) + + +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', ...), ...] + ``` + + """ # 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. + + 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 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: TimeInput | 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 + 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, + goal_id: int, + *, + title: str | None = None, + status: GoalStatus | str | None = None, + due_date: TimeInput | 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', ...) + ``` + + """ + 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") + 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 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: + 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. + + """ + 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" + ) + 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`. + + """ + 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) + + +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. + + """ # 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. + + 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 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: TimeInput | 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() + 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, + goal_id: int, + *, + title: str | None = None, + status: GoalStatus | str | None = None, + due_date: TimeInput | 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. + + """ + 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") + 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 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: + 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. + + """ + 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" + ) + 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`. + + """ + 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 new file mode 100644 index 0000000..383b955 --- /dev/null +++ b/src/bloomy/v2/operations/headline.py @@ -0,0 +1,489 @@ +"""Headline operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import ( + MEETING_REF_FIELDS, + NOTES_FIELDS, + USER_REF_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + compact, + dig_nodes, + merge_nodes, +) +from ..models import Headline + +_HEADLINE_FIELDS = f""" +id +title +recurrenceId +archived +archivedTimestamp +dateCreated +{NOTES_FIELDS} +{USER_REF_FIELDS} +{MEETING_REF_FIELDS} +""" + + +class HeadlineOperationsMixin: + """GraphQL documents, inputs, and response parsing shared by headline operations.""" + + _HEADLINE_DETAILS_QUERY = f""" + query($id: Long!) {{ + headline(id: $id) {{ + {_HEADLINE_FIELDS} + }} + }} + """ + + # `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) {{ + headlines(order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + archivedHeadlines(order: [{{ dateCreated: ASC }}]) + @include(if: $includeArchived) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + }} + }} + """ + + _HEADLINE_USER_LIST_QUERY = f""" + query($userId: Long!, $where: HeadlineQueryModelFilterInput) {{ + headlines(userId: $userId, where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_HEADLINE_FIELDS} + }} + }} + }} + """ + + _HEADLINE_CREATE_MUTATION = """ + mutation($input: HeadlineCreateModelInput!) { + CreateHeadline(input: $input) { + id + } + } + """ + + _HEADLINE_EDIT_MUTATION = """ + mutation($input: HeadlineEditModelInput!) { + EditHeadline(input: $input) { + id + } + } + """ + + @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: + 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 compact(title=title, assignee=user_id) + + +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 Headline.model_validate( + self._one(data, "headline", label="Headline", entity_id=headline_id) + ) + + 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', ...), ...] + ``` + + """ # 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._headline_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._headlines_from(self._execute(query, variables)) + + 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_ = self._headline_create_input( + meeting_id=meeting_id, title=title, user_id=user_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, + 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', ...) + ``` + + """ + 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") + 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. + + Args: + headline_id: The ID of the headline to archive. + + Returns: + The updated `Headline`. + + """ + return self._edit(headline_id, {"archived": True}, action="archive headline") + + 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`. + + """ + 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, **fields}}, + root_field="EditHeadline", + action=action, + ) + 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 Headline.model_validate( + self._one(data, "headline", label="Headline", entity_id=headline_id) + ) + + 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. + + """ # 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._headline_list_request( + meeting_id, user_id, include_archived=include_archived + ) + return self._headlines_from(await self._execute(query, variables)) + + 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_ = self._headline_create_input( + meeting_id=meeting_id, title=title, user_id=user_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, + 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. + + """ + 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") + 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. + + Args: + headline_id: The ID of the headline to archive. + + Returns: + The updated `Headline`. + + """ + return await self._edit( + headline_id, {"archived": True}, action="archive headline" + ) + + 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`. + + """ + 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, **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 new file mode 100644 index 0000000..d74f059 --- /dev/null +++ b/src/bloomy/v2/operations/issue.py @@ -0,0 +1,625 @@ +"""Issue operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import ( + MEETING_REF_FIELDS, + MUTATION_RESULT_FIELDS, + NOTES_FIELDS, + USER_REF_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + compact, + dig_nodes, + merge_nodes, + now_timestamp, +) +from ..models import Issue + +_ISSUE_FIELDS = f""" +id +title +recurrenceId +addToDepartmentPlan +completed +completedTimestamp +archived +archivedTimestamp +dateCreated +priorityVoteRank +numStarVotes +issueNumber +{NOTES_FIELDS} +{USER_REF_FIELDS} +""" + +_ISSUE_LIST_CONNECTIONS = ( + "issues", + "longTermIssues", + "recentlySolvedIssues", + "archivedIssues", +) + + +class IssueOperationsMixin: + """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} + }} + }} + """ + + # `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) {{ + 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} + }} + }} + recentlySolvedIssues(where: $where, order: [{{ dateCreated: ASC }}]) + @include(if: $includeSolved) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + archivedIssues(where: $where, order: [{{ dateCreated: ASC }}]) + @include(if: $includeArchived) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + }} + """ + + _ISSUE_CREATE_MUTATION = f""" + mutation($input: IssueCreateModelInput!) {{ + CreateIssue(input: $input) {{ + {_ISSUE_FIELDS} + {MEETING_REF_FIELDS} + }} + }} + """ + + _ISSUE_EDIT_MUTATION = f""" + mutation($input: IssueEditModelInput!) {{ + EditIssue(input: $input) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + @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: + The query variables. + + """ + conditions: list[dict[str, Any]] = [{"addToDepartmentPlan": {"eq": long_term}}] + if not long_term: + # Drops issues sent to another meeting, as the web app's open list does. + conditions.append({"sentToIssueMeetingName": {"eq": None}}) + return { + "meetingId": meeting_id, + "where": {"and": conditions}, + "longTerm": long_term, + "includeSolved": include_solved and not long_term, + "includeArchived": include_archived and not long_term, + } + + @staticmethod + def _issues_from(data: dict[str, Any]) -> list[Issue]: + """Validate the issues of an `_ISSUE_LIST_QUERY` response. + + Every connection is keyed by the issue's recurrence, so each issue's + meeting ref is the parent meeting's. + + Returns: + 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. + + """ + return { + "title": title, + "ownerId": user_id, + "recurrenceId": meeting_id, + "addToDepartmentPlan": long_term, + } + + @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): + """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 Issue.model_validate( + self._one(data, "issue", label="Issue", entity_id=issue_id) + ) + + 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', ...), ...] + ``` + + """ + variables = self._issue_list_variables( + meeting_id, + long_term=long_term, + include_solved=include_solved, + include_archived=include_archived, + ) + return self._issues_from(self._execute(self._ISSUE_LIST_QUERY, variables)) + + 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_ = 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, + 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', ...) + ``` + + """ + fields = self._issue_edit_fields( + title=title, user_id=user_id, long_term=long_term, meeting_id=meeting_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. + + 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`. + + """ + return self._edit( + issue_id, + {"completed": True, "completedTimestamp": now_timestamp()}, + action="solve issue", + ) + + 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`. + + """ + return self._edit( + issue_id, + {"completed": False, "completedTimestamp": None}, + action="reopen issue", + ) + + def archive(self, issue_id: int) -> Issue: + """Archive an issue. + + Args: + issue_id: The ID of the issue to archive. + + Returns: + The updated `Issue`. + + """ + return self._edit(issue_id, {"archived": True}, action="archive issue") + + 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`. + + """ + 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, **fields}}, + root_field="EditIssue", + action=action, + ) + 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 Issue.model_validate( + self._one(data, "issue", label="Issue", entity_id=issue_id) + ) + + 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. + + """ + variables = self._issue_list_variables( + meeting_id, + long_term=long_term, + include_solved=include_solved, + include_archived=include_archived, + ) + return self._issues_from(await self._execute(self._ISSUE_LIST_QUERY, variables)) + + 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_ = 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, + 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. + + """ + fields = self._issue_edit_fields( + title=title, user_id=user_id, long_term=long_term, meeting_id=meeting_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. + + 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`. + + """ + return await self._edit( + issue_id, + {"completed": True, "completedTimestamp": now_timestamp()}, + action="solve issue", + ) + + 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`. + + """ + return await self._edit( + issue_id, + {"completed": False, "completedTimestamp": None}, + action="reopen issue", + ) + + 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`. + + """ + return await self._edit(issue_id, {"archived": True}, action="archive issue") + + 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`. + + """ + 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, **fields}}, + root_field="EditIssue", + action=action, + ) + 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..2256e55 --- /dev/null +++ b/src/bloomy/v2/operations/meeting.py @@ -0,0 +1,188 @@ +"""Meeting operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins + +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: + """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. + _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 + } + } + } + } + """ + + _MEETING_DETAILS_QUERY = f""" + query($id: Long!) {{ + meeting(id: $id) {{ + id + name + orgId + meetingType + createdTimestamp + archived + {_ATTENDEES_FIELD} + }} + }} + """ + + _MEETING_ATTENDEES_QUERY = f""" + query($id: Long!) {{ + meeting(id: $id) {{ + {_ATTENDEES_FIELD} + }} + }} + """ + + +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}) + 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. + + 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 Meeting.model_validate( + self._one(data, "meeting", label="Meeting", entity_id=meeting_id) + ) + + 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}) + return [ + User.model_validate(node) + for node in dig_nodes(data, "meeting", "attendees") + ] + + +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}) + 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. + + 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 Meeting.model_validate( + self._one(data, "meeting", label="Meeting", entity_id=meeting_id) + ) + + 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}) + 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 new file mode 100644 index 0000000..9d2f2a8 --- /dev/null +++ b/src/bloomy/v2/operations/metric.py @@ -0,0 +1,1163 @@ +"""Metric (KPI/scorecard) operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ...exceptions import GraphQLError +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 + +# `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 +rule +frequency +singleGoalValue +minGoalValue +maxGoalValue +archived +dateCreated +isExternallySynced +{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: + """GraphQL documents, inputs, and response parsing shared by metric operations.""" + + _METRIC_DETAILS_QUERY = f""" + query($id: Long!) {{ + metric(id: $id) {{ + {_METRIC_FIELDS} + }} + }} + """ + + _METRIC_MEETING_LIST_QUERY = f""" + query($meetingId: Long!, $where: MetricQueryModelFilterInput) {{ + meeting(id: $meetingId) {{ + metrics(where: $where, order: [{{ indexInTable: ASC }}]) {{ + nodes {{ + {_METRIC_FIELDS} + }} + }} + }} + }} + """ + + _METRIC_USER_LIST_QUERY = f""" + query($userId: Long!, $where: MetricQueryModelFilterInput) {{ + user(id: $userId) {{ + metrics(where: $where, order: [{{ dateCreated: ASC }}]) {{ + nodes {{ + {_METRIC_FIELDS} + }} + }} + }} + }} + """ + + _METRIC_CREATE_MUTATION = """ + mutation($input: MetricCreateModelInput!) { + CreateMetric(input: $input) { + id + } + } + """ + + _METRIC_EDIT_MUTATION = """ + mutation($input: MetricEditModelInput!) { + EditMetric(input: $input) { + id + } + } + """ + + _METRIC_SCORES_QUERY = f""" + query($metricId: Long!, $where: MetricScoreQueryModelFilterInput) {{ + metric(id: $metricId) {{ + scoresNonPaginated(where: $where, order: [{{ timestamp: DESC }}]) {{ + {_METRIC_SCORE_FIELDS} + }} + }} + }} + """ + + _METRIC_SCORE_CREATE_MUTATION = """ + mutation($input: MetricScoreCreateModelInput!) { + CreateMetricScore(input: $input) { + id + } + } + """ + + _METRIC_SCORE_EDIT_MUTATION = """ + mutation($input: MetricScoreEditModelInput!) { + EditMetricScore(input: $input) { + id + } + } + """ + + @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: + The query document and its variables. + + """ + conditions: list[dict[str, Any]] = [ + {"archived": {"eq": False}}, + {"metricType": {"eq": "METRIC"}}, + ] + if frequency is not None: + conditions.append({"frequency": {"eq": str(frequency)}}) + 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 _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: + The metrics. + + """ + return [ + Metric.model_validate(node) + for owner in ("meeting", "user") + for node in dig_nodes(data, owner, "metrics") + ] + + @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 the goal fields match the rule. + + `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 there is none. + + """ + 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_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 metric fields that were given, apart from notes. + + `MetricEditModelInput` and `MetricCreateModelInput` name these fields + the same way, so `create()` reuses them. + + Returns: + The non-`None` input 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), + ) + + @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. + + 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: + The input fields. + + """ + return {"metricId": metric_id, "value": str(value), "timestamp": timestamp} + + @staticmethod + 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 query variables, with a `null` `where` when neither bound is + given. + + """ + 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 _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: + The first matching score (empty placeholders included), or `None`. + + """ + 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 + + @staticmethod + def _created_score_id(data: dict[str, Any], value: float | str) -> int: + """Read the new score's id from a `CreateMetricScore` response. + + Returns: + The score id. + + Raises: + GraphQLError: If the result is `null`, meaning `value` could not be + parsed as a decimal number. + + """ + 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"]) + + @staticmethod + def _is_duplicate_score_error(exc: GraphQLError) -> bool: + """Check whether `CreateMetricScore` failed because a DAILY score exists. + + `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: + `True` if any raw error's `extensions.message` says the score + already exists. + + """ + 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): + """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 Metric.model_validate( + self._one(data, "metric", label="Metric", entity_id=metric_id) + ) + + 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', ...), ...] + ``` + + """ # 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, + 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_ = 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, + 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', ...) + ``` + + """ + 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) + 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. + + 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`. + + """ + return self._edit(metric_id, {"archived": True}, action="archive metric") + + def scores( + self, + metric_id: int, + *, + start: TimeInput | None = None, + end: TimeInput | 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, ...), ...] + ``` + + """ + 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: TimeInput, + ) -> 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 = to_timestamp(timestamp) + try: + data = self._execute( + self._METRIC_SCORE_CREATE_MUTATION, + {"input": self._score_create_input(metric_id, value, ts)}, + ) + except GraphQLError as exc: + if not self._is_duplicate_score_error(exc): + raise + 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) + return self._read_score(metric_id, self._created_score_id(data, value)) + + 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, ...) + ``` + + """ + fields = compact(value=_text(value), notesText=notes) + if not fields: + raise ValueError("At least one of `value` or `notes` must be provided") + 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. + + 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`. + + """ + return self._edit_score( + metric_id, score_id, {"value": None}, action="clear score" + ) + + def _edit(self, metric_id: int, fields: dict[str, Any], *, action: str) -> Metric: + """Run `EditMetric` with `fields`, then re-read the metric. + + Returns: + The updated `Metric`. + + """ + self._mutate( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, **fields}}, + root_field="EditMetric", + action=action, + ) + return self.details(metric_id) + + 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) + + def _read_score(self, metric_id: int, score_id: int) -> MetricScore: + """Read a single score through its parent metric. + + Returns: + The `MetricScore` model instance. + + """ + data = self._execute( + 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, + ) + ) + + +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 Metric.model_validate( + self._one(data, "metric", label="Metric", entity_id=metric_id) + ) + + 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. + + """ # 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, + 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_ = 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, + 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`. + + """ + 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) + 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. + + 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`. + + """ + return await self._edit(metric_id, {"archived": True}, action="archive metric") + + async def scores( + self, + metric_id: int, + *, + start: TimeInput | None = None, + end: TimeInput | 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. + + """ + 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: TimeInput, + ) -> 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 = to_timestamp(timestamp) + try: + data = await self._execute( + self._METRIC_SCORE_CREATE_MUTATION, + {"input": self._score_create_input(metric_id, value, ts)}, + ) + except GraphQLError as exc: + if not self._is_duplicate_score_error(exc): + raise + 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) + return await self._read_score(metric_id, self._created_score_id(data, value)) + + 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. + + """ + fields = compact(value=_text(value), notesText=notes) + if not fields: + raise ValueError("At least one of `value` or `notes` must be provided") + 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. + + 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`. + + """ + return await self._edit_score( + metric_id, score_id, {"value": None}, action="clear score" + ) + + 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 updated `Metric`. + + """ + await self._mutate( + self._METRIC_EDIT_MUTATION, + {"input": {"metricId": metric_id, **fields}}, + root_field="EditMetric", + action=action, + ) + return await self.details(metric_id) + + 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. + + 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 `MetricScore` model instance. + + """ + data = await self._execute( + 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, + ) + ) diff --git a/src/bloomy/v2/operations/milestone.py b/src/bloomy/v2/operations/milestone.py new file mode 100644 index 0000000..7fe146b --- /dev/null +++ b/src/bloomy/v2/operations/milestone.py @@ -0,0 +1,431 @@ +"""Milestone operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from ..base import ( + MILESTONE_FIELDS, + MILESTONES_CONNECTION, + MUTATION_RESULT_FIELDS, + AsyncGraphQLOperations, + GraphQLOperations, + TimeInput, + compact, + dig_nodes, + to_timestamp, +) +from ..models import Milestone + + +class MilestoneOperationsMixin: + """GraphQL documents, inputs, and parsing shared by milestone operations.""" + + _MILESTONE_LIST_QUERY = f""" + query($goalId: Long!) {{ + goal(id: $goalId) {{ + {MILESTONES_CONNECTION} + }} + }} + """ + + _MILESTONE_BY_ID_QUERY = f""" + query($goalId: Long!, $milestoneId: Long!) {{ + goal(id: $goalId) {{ + milestones(where: {{ id: {{ eq: $milestoneId }} }}) {{ + nodes {{ + {MILESTONE_FIELDS} + }} + }} + }} + }} + """ + + _MILESTONE_CREATE_MUTATION = """ + mutation($input: MilestoneCreateModelInput!) { + CreateMilestone(input: $input) { + id + } + } + """ + + _MILESTONE_EDIT_MUTATION = """ + mutation($input: MilestoneEditModelInput!) { + EditMilestone(input: $input) { + id + } + } + """ + + _MILESTONE_DELETE_MUTATION = f""" + mutation($milestoneId: Long!) {{ + DeleteMilestone(milestoneId: $milestoneId) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + @staticmethod + def _milestones_from(data: dict[str, Any]) -> list[Milestone]: + """Validate the milestones of a `_MILESTONE_LIST_QUERY` response. + + Returns: + The milestones, ordered by due date (empty for an unknown goal). + + """ + 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, 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. + + """ + + 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', ...), ...] + ``` + + """ + return self._milestones_from( + self._execute(self._MILESTONE_LIST_QUERY, {"goalId": goal_id}) + ) + + def create( + self, + goal_id: int, + title: str, + due_date: TimeInput, + 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_ = self._milestone_create_input( + goal_id=goal_id, title=title, due_date=due_date, completed=completed + ) + 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, + milestone_id: int, + *, + goal_id: int, + title: str | 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 (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, goal_id=5265048, title="Draft spec v2") + # Returns: Milestone(id=1, title='Draft spec v2', ...) + ``` + + """ + 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") + # Checks that the milestone belongs to `goal_id` before writing. + self._get(goal_id, milestone_id) + 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: + """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 (see the class + `Note`). + + Returns: + The updated `Milestone`. + + """ + return self.update(milestone_id, goal_id=goal_id, completed=True) + + 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._mutate( + self._MILESTONE_DELETE_MUTATION, + {"milestoneId": milestone_id}, + root_field="DeleteMilestone", + action="delete milestone", + ) + + def _get(self, goal_id: int, milestone_id: int) -> Milestone: + """Read a single milestone through its parent goal. + + Returns: + 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}, + ) + return Milestone.model_validate( + self._one( + data, "goal", "milestones", label="Milestone", entity_id=milestone_id + ) + ) + + +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. + + """ + 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: TimeInput, + 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_ = self._milestone_create_input( + goal_id=goal_id, title=title, due_date=due_date, completed=completed + ) + 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, + milestone_id: int, + *, + goal_id: int, + title: str | 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 (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. + + """ + 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") + # Checks that the milestone belongs to `goal_id` before writing. + await self._get(goal_id, milestone_id) + 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: + """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 (see the class + `Note`). + + Returns: + The updated `Milestone`. + + """ + 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. + + Args: + milestone_id: The ID of the milestone to delete. + + """ + await self._mutate( + self._MILESTONE_DELETE_MUTATION, + {"milestoneId": milestone_id}, + root_field="DeleteMilestone", + action="delete milestone", + ) + + async def _get(self, goal_id: int, milestone_id: int) -> Milestone: + """Read a single milestone through its parent goal. + + Returns: + 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}, + ) + return Milestone.model_validate( + self._one( + data, "goal", "milestones", label="Milestone", entity_id=milestone_id + ) + ) diff --git a/src/bloomy/v2/operations/todo.py b/src/bloomy/v2/operations/todo.py new file mode 100644 index 0000000..3550dbd --- /dev/null +++ b/src/bloomy/v2/operations/todo.py @@ -0,0 +1,604 @@ +"""To-do operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins +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, + now_timestamp, + to_timestamp, +) +from ..models import Todo + +_TODO_FIELDS = f""" +id +title +dueDate +completed +completedTimestamp +archived +archivedTimestamp +dateCreated +{NOTES_FIELDS} +{USER_REF_FIELDS} +{MEETING_REF_FIELDS} +""" + + +class TodoOperationsMixin: + """GraphQL documents, inputs, and response parsing shared by to-do operations.""" + + _TODO_DETAILS_QUERY = f""" + query($id: Long!) {{ + todo(id: $id) {{ + {_TODO_FIELDS} + }} + }} + """ + + # `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) {{ + todosActives(where: $where, order: [{{ dueDate: ASC }}]) + @skip(if: $includeArchived) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + todos(where: $where, order: [{{ dueDate: ASC }}]) + @include(if: $includeArchived) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + }} + }} + """ + + _TODO_USER_LIST_QUERY = f""" + query($userId: Long!, $where: TodoQueryModelFilterInput) {{ + todos(userId: $userId, where: $where, order: [{{ dueDate: ASC }}]) {{ + nodes {{ + {_TODO_FIELDS} + }} + }} + }} + """ + + _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) {{ + {MUTATION_RESULT_FIELDS} + }} + }} + """ + + @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: + The query document and its variables. + + """ + 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. + + Each response carries exactly one of the three connections read here. + + 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: + The input fields, with the due date defaulting to 7 days from + today. + + """ + return { + "title": title, + "assigneeId": user_id, + "meetingRecurrenceId": meeting_id, + "dueDate": to_timestamp( + default_due_date(7) if due_date is None else due_date + ), + } + + @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: + The non-`None` input fields. + + """ + return compact( + title=title, + dueDate=None if due_date is None else to_timestamp(due_date), + assigneeId=user_id, + ) + + +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 Todo.model_validate( + self._one(data, "todo", label="Todo", entity_id=todo_id) + ) + + 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', ...), ...] + ``` + + """ # 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._todo_list_request( + meeting_id, + user_id, + include_completed=include_completed, + include_archived=include_archived, + ) + 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: TimeInput | 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 + 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: TimeInput | 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', ...) + ``` + + """ + 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") + 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. + + Args: + todo_id: The ID of the to-do to complete. + + Returns: + The updated `Todo`. + + """ + return self._edit( + todo_id, {"completedTimestamp": now_timestamp()}, action="complete todo" + ) + + def reopen(self, todo_id: int) -> Todo: + """Reopen a completed to-do. + + Args: + todo_id: The ID of the to-do to reopen. + + Returns: + The updated `Todo`. + + """ + return self._edit(todo_id, {"completedTimestamp": None}, action="reopen todo") + + 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`. + + """ + return self._edit( + todo_id, {"archivedTimestamp": now_timestamp()}, action="archive todo" + ) + + def restore(self, todo_id: int) -> Todo: + """Restore an archived to-do. + + Args: + todo_id: The ID of the to-do to restore. + + Returns: + The updated `Todo`. + + """ + 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, **fields}}, + root_field="EditTodo", + action=action, + ) + 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 Todo.model_validate( + self._one(data, "todo", label="Todo", entity_id=todo_id) + ) + + 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. + + """ # 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._todo_list_request( + meeting_id, + user_id, + include_completed=include_completed, + include_archived=include_archived, + ) + 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: TimeInput | 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() + 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: TimeInput | 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. + + """ + 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") + 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. + + Args: + todo_id: The ID of the to-do to complete. + + Returns: + The updated `Todo`. + + """ + return await self._edit( + todo_id, {"completedTimestamp": now_timestamp()}, action="complete todo" + ) + + async def reopen(self, todo_id: int) -> Todo: + """Reopen a completed to-do. + + Args: + todo_id: The ID of the to-do to reopen. + + Returns: + The updated `Todo`. + + """ + return await self._edit( + todo_id, {"completedTimestamp": None}, action="reopen todo" + ) + + 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`. + + """ + return await self._edit( + todo_id, {"archivedTimestamp": now_timestamp()}, action="archive todo" + ) + + async def restore(self, todo_id: int) -> Todo: + """Restore an archived to-do. + + Args: + todo_id: The ID of the to-do to restore. + + Returns: + The updated `Todo`. + + """ + 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, **fields}}, + root_field="EditTodo", + action=action, + ) + 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..fc09be0 --- /dev/null +++ b/src/bloomy/v2/operations/user.py @@ -0,0 +1,106 @@ +"""User operations for the Bloomy v2 (GraphQL) API.""" + +from __future__ import annotations + +import builtins + +from ..base import AsyncGraphQLOperations, GraphQLOperations, dig_nodes +from ..models import User + +_USER_FIELDS = "id firstName lastName fullName email avatar" + + +class UserOperationsMixin: + """GraphQL documents shared by user operations.""" + + _USER_DETAILS_QUERY = f""" + query($id: Long!) {{ + user(id: $id) {{ + {_USER_FIELDS} + }} + }} + """ + + _USER_LIST_QUERY = f""" + query {{ + users(order: [{{ fullName: ASC }}]) {{ + nodes {{ + {_USER_FIELDS} + }} + }} + }} + """ + + +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 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. + + 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 [User.model_validate(node) for node in dig_nodes(data, "users")] + + +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 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. + + Returns: + A list of `User` model instances, ordered by full name. + + """ + data = await self._execute(self._USER_LIST_QUERY) + 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 new file mode 100644 index 0000000..0bd83a6 --- /dev/null +++ b/tests/test_integration_v2_goal_milestone.py @@ -0,0 +1,290 @@ +"""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, goal_id=created.id, title="Second milestone (renamed)" + ) + assert renamed.title == "Second milestone (renamed)" + + completed_milestone = client.v2.milestone.complete( + new_milestone.id, goal_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="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: + """`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, goal_id=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, goal_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, goal_id=1) diff --git a/tests/test_integration_v2_headline.py b/tests/test_integration_v2_headline.py new file mode 100644 index 0000000..1f33218 --- /dev/null +++ b/tests/test_integration_v2_headline.py @@ -0,0 +1,211 @@ +"""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) + + 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: + # 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="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: + """`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..aec721e --- /dev/null +++ b/tests/test_integration_v2_metric.py @@ -0,0 +1,358 @@ +"""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="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: + """`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..db539eb --- /dev/null +++ b/tests/test_integration_v2_todo.py @@ -0,0 +1,293 @@ +"""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_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="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: + """`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..1644ca2 --- /dev/null +++ b/tests/test_v2_base.py @@ -0,0 +1,661 @@ +"""Tests for the v2 (GraphQL) transport and shared helpers.""" + +from __future__ import annotations + +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/" + + +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_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. + + 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 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([], []) == [] + + +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}] + + @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 TestOne: + """Tests for `_one`.""" + + def test_returns_entity_when_present(self) -> None: + """A present, non-null entity is returned unchanged.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + 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 such key at all raises `GraphQLError`.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="Issue 999 not found"): + 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`.""" + ops = GraphQLOperations(Mock(), GRAPHQL_URL) + + with pytest.raises(GraphQLError, match="Meeting 999999999 not found"): + 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}]}}} + + result = ops._one(data, "goal", "milestones", label="Milestone", entity_id=5) + + assert result == {"id": 5} + + 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}]}} + + result = ops._one( + data, "metric", "scoresNonPaginated", label="Score", entity_id=7 + ) + + assert result == {"id": 7} + + @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="Milestone 5 not found"): + ops._one(data, "goal", "milestones", label="Milestone", entity_id=5) + + +class TestCheckMutationResult: + """Tests for `_check_mutation_result` (shared by `_mutate`).""" + + 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`.""" + ops = GraphQLOperations(Mock(), 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.""" + ops = GraphQLOperations(Mock(), 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_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({"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 TestMutate: + """Tests for the sync and async `_mutate`.""" + + 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._mutate( + "mutation {}", {"input": {}}, root_field="EditIssue", action="update" + ) + + assert result == {"success": True, "message": None} + + 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( + { + "data": { + "EditIssue": { + "success": False, + "message": "nope", + "errorDetails": None, + } + } + } + ) + + with pytest.raises(GraphQLError, match="nope"): + 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`.""" + + 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 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 the module-level timestamp and due-date helpers.""" + + def test_naive_datetime_treated_as_utc(self) -> None: + """A naive datetime is treated as UTC.""" + naive = datetime(2024, 1, 1, 0, 0, 0) + aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + + 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.""" + aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + + 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.""" + expected = datetime(2024, 1, 1, tzinfo=UTC) + + assert to_utc_datetime(date(2024, 1, 1)) == expected + assert to_timestamp(date(2024, 1, 1)) == expected.timestamp() + + 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 + ) + + @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.""" + 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 + + +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: + """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() + + 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`.""" + + 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 + + @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 new file mode 100644 index 0000000..843a25c --- /dev/null +++ b/tests/test_v2_client_wiring.py @@ -0,0 +1,180 @@ +"""Tests for the v1/v2 namespace wiring on `Client`/`AsyncClient`.""" + +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, 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: + """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 + + 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.""" + + 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 + + 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 new file mode 100644 index 0000000..9c47591 --- /dev/null +++ b/tests/test_v2_goal.py @@ -0,0 +1,808 @@ +"""Tests for the v2 (GraphQL) goal operations.""" + +from __future__ import annotations + +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 + +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_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() + 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="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() + 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_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() + 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), + {"title": "M3", "due_date": date(2026, 1, 1), "completed": True}, + ("M4", datetime(2026, 1, 1, tzinfo=UTC)), + ], + ) + + 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}, + {"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: + """`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_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) + 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_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`.""" + client = AsyncMock() + ops = AsyncGoalOperations(client, GRAPHQL_URL) + + 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 }`.""" + 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_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`.""" + 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..09ca2cb --- /dev/null +++ b/tests/test_v2_headline.py @@ -0,0 +1,1067 @@ +"""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_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() + ops = HeadlineOperations(client, GRAPHQL_URL) + + 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: + """`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, "includeArchived": False} + + 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 = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + archived_node = {**HEADLINE_NODE, "id": 999, "archived": True} + 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 == 1 + assert {headline.id for headline in result} == {987654, 999} + 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_server_side(self) -> None: + """`list(user_id=...)` filters archived headlines out with `where`. + + The root `headlines(userId)` connection includes archived headlines, + so `list()` sends `archived: {eq: false}` unless `include_archived`. + """ + client = Mock() + ops = HeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE]}}} + ) + + result = ops.list(user_id=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.""" + 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} + 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.""" + 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, "where": {"archived": {"eq": False}}} + + 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_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() + 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`. + + `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) + 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_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() + 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.return_value = _response( + { + "data": { + "meeting": { + "headlines": {"nodes": [HEADLINE_NODE]}, + "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="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_server_side(self) -> None: + """`list(user_id=...)` filters archived headlines out with `where`.""" + client = AsyncMock() + ops = AsyncHeadlineOperations(client, GRAPHQL_URL) + client.post.return_value = _async_response( + {"data": {"headlines": {"nodes": [HEADLINE_NODE]}}} + ) + + result = await ops.list(user_id=1305290) + + 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: + """`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 + 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: + """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_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.""" + 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, "includeArchived": False} + + @pytest.mark.asyncio + 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.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 == 1 + assert {headline.id for headline in result} == {987654, 999} + 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: + """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} + 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: + """`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, "where": {"archived": {"eq": False}}} + + @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") + + @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 new file mode 100644 index 0000000..d602b52 --- /dev/null +++ b/tests/test_v2_issue.py @@ -0,0 +1,685 @@ +"""Tests for the v2 (GraphQL) issue operations.""" + +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, MeetingRef +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"}, +} + +# 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() + response.status_code = 200 + response.json.return_value = json_data + response.raise_for_status = 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`.""" + + 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 + + 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"), + [ + (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 = _list_response(issues=[LIST_NODE]) + + 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["longTerm"] is False + assert variables["includeSolved"] is False + assert variables["includeArchived"] is False + 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)` 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. Both + connections are in one document, switched by `$longTerm`. + """ + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + client.post.return_value = _list_response(longTermIssues=[LIST_NODE]) + + 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 "@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 = {**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) + + 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_merges_in_one_request(self) -> None: + """`include_archived=True` merges `archivedIssues` in a single request.""" + client = Mock() + ops = IssueOperations(client, GRAPHQL_URL) + 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) + + 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 = {**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) + + assert [issue.id for issue in result] == [1, 3] + + def test_create_without_notes(self) -> None: + """`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.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": { + "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": 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" + ] + 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": 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_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_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() + 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_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() + 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_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 = {**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: + """`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": ISSUE_NODE}} + create_response.raise_for_status = MagicMock() + + 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" + + @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 + 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 new file mode 100644 index 0000000..6a46925 --- /dev/null +++ b/tests/test_v2_meeting.py @@ -0,0 +1,284 @@ +"""Tests for the v2 (GraphQL) meeting 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 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_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() + 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" + + 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.""" + 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_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.""" + 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 + + @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 new file mode 100644 index 0000000..3bf0126 --- /dev/null +++ b/tests/test_v2_metric.py @@ -0,0 +1,1600 @@ +"""Tests for the v2 (GraphQL) metric operations.""" + +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, MetricFrequency, MetricRule, MetricUnit +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, + "notesId": None, + "notesText": "", + "localHtml": None, + "collaborationEnabled": False, + "dateCreated": 1790278038, + "isExternallySynced": False, + "assignee": {"id": 1305290, "fullName": "Fran Orozco"}, +} + +SCORE_NODE = { + "id": 499964609, + "value": "123.00000", + "timestamp": 1789862400, + "notesText": "probe note", + "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": "", + "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_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_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]}}}} + ) + + 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="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: + """`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_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() + 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_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() + 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="Metric score 999 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 + + 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`.""" + + @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_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`.""" + client = AsyncMock() + ops = AsyncMetricOperations(client, GRAPHQL_URL) + + 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_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_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.""" + 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_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]}}}} + ) + + 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_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.""" + 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="Metric score 999 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 + + @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 new file mode 100644 index 0000000..e3ec776 --- /dev/null +++ b/tests/test_v2_milestone.py @@ -0,0 +1,570 @@ +"""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.base import MILESTONES_CONNECTION +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} + 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: + """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_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`, before any request.""" + client = Mock() + ops = MilestoneOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + 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( + { + "data": { + "goal": { + "milestones": { + "nodes": [ + { + **MILESTONE_NODE, + "dueDate": 1767312000, + "completed": True, + } + ] + } + } + } + } + ), + ] + + result = ops.update( + 6440354, goal_id=5265048, due_date=1767312000, completed=True + ) + + assert result.completed is True + assert client.post.call_count == 3 + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "milestoneId": 6440354, + "dueDate": 1767312000.0, + "completed": True, + } + } + + def test_update(self) -> None: + """`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( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "title": "Updated"}] + } + } + } + } + ), + ] + + result = ops.update(6440354, goal_id=5265048, title="Updated") + + assert result.title == "Updated" + 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[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="Milestone 6440354 not found"): + ops.update(6440354, goal_id=999999, title="Updated") + + assert client.post.call_count == 1 + + def test_complete(self) -> None: + """`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( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "completed": True}] + } + } + } + } + ), + ] + + result = ops.complete(6440354, goal_id=5265048) + + 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() + ops = MilestoneOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"goal": {"milestones": {"nodes": []}}}} + ) + + with pytest.raises(GraphQLError, match="Milestone 6440354 not found"): + ops.complete(6440354, goal_id=999999) + + assert client.post.call_count == 1 + + def test_get_raises_when_not_found(self) -> None: + """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, goal_id=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_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`, before any request.""" + client = AsyncMock() + ops = AsyncMilestoneOperations(client, GRAPHQL_URL) + + with pytest.raises(ValueError, match="At least one field"): + await ops.update(6440354, goal_id=5265048) + + assert client.post.call_count == 0 + + @pytest.mark.asyncio + async def test_update(self) -> None: + """`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( + { + "data": { + "goal": { + "milestones": { + "nodes": [ + { + **MILESTONE_NODE, + "title": "Updated", + "dueDate": 1767312000, + "completed": True, + } + ] + } + } + } + } + ), + ] + + result = await ops.update( + 6440354, + goal_id=5265048, + title="Updated", + due_date=1767312000, + completed=True, + ) + + assert result.title == "Updated" + assert result.completed is True + assert client.post.call_count == 3 + edit_variables = client.post.call_args_list[1].kwargs["json"]["variables"] + assert edit_variables == { + "input": { + "milestoneId": 6440354, + "title": "Updated", + "dueDate": 1767312000.0, + "completed": True, + } + } + + @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: + """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, goal_id=5265048) + + @pytest.mark.asyncio + async def test_complete(self) -> None: + """`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( + { + "data": { + "goal": { + "milestones": { + "nodes": [{**MILESTONE_NODE, "completed": True}] + } + } + } + } + ), + ] + + result = await ops.complete(6440354, goal_id=5265048) + + 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: + """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: + """`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_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 new file mode 100644 index 0000000..e5ea95e --- /dev/null +++ b/tests/test_v2_todo.py @@ -0,0 +1,1135 @@ +"""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_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() + ops = TodoOperations(client, GRAPHQL_URL) + + 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_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": {"todosActives": {"nodes": [TODO_NODE]}}}} + ) + + result = ops.list(meeting_id=349524) + + 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` 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]}}}} + ) + + 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 reads `todosActives` with no filter.""" + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"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"] 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.""" + 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, + "where": {"completed": {"eq": False}}, + } + + 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_ignores_include_archived(self) -> None: + """`include_archived` sends nothing extra when listing by `user_id`. + + `todos(userId)` excludes archived to-dos server-side, so the query has + no include-archived variant. + """ + client = Mock() + ops = TodoOperations(client, GRAPHQL_URL) + client.post.return_value = _response( + {"data": {"todos": {"nodes": [TODO_NODE]}}} + ) + + ops.list(user_id=1305290, include_completed=True, include_archived=True) + + variables = client.post.call_args.kwargs["json"]["variables"] + 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.""" + 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": 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_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.return_value = _response( + {"data": {"CreateTodo": {**TODO_NODE, "meeting": None}}} + ) + + result = ops.create("Personal to-do", user_id=1305290) + + 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": {**TODO_NODE, "id": 0}}} + ) + + 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.return_value = _response({"data": {"CreateTodo": 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.return_value = _response({"data": {"CreateTodo": 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": 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"] + 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_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.""" + 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="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_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": {"todosActives": {"nodes": [TODO_NODE]}}}} + ) + + result = await ops.list(meeting_id=349524) + + assert [todo.id for todo in result] == [555001] + variables = client.post.call_args.kwargs["json"]["variables"] + 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` 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]}}}} + ) + + 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: + """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": 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" + 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.return_value = _async_response( + {"data": {"CreateTodo": {**TODO_NODE, "meeting": None}}} + ) + + result = await ops.create("Personal to-do", user_id=1305290) + + assert result.meeting is None + create_variables = client.post.call_args.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": {**TODO_NODE, "id": 0}}} + ) + + 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.return_value = _async_response({"data": {"CreateTodo": 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": TODO_NODE}}), + ] + + result = await ops.create( + "SDK v2 test to-do", meeting_id=349524, user_id=1305290, notes="hello" + ) + + 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"] + 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..917ab76 --- /dev/null +++ b/tests/test_v2_user.py @@ -0,0 +1,168 @@ +"""Tests for the v2 (GraphQL) user operations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from bloomy.exceptions import GraphQLError +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_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() + 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" + + 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`.""" + + @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_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.""" + 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 + + @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()