diff --git a/PROGRESS.md b/PROGRESS.md index bc15cbd..8a3b5db 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -42,6 +42,7 @@ - [x] File Reminders tools: get_file_reminder, set_file_reminder, remove_file_reminder (2026-04-22) - [x] Forms tools: 25 tools covering forms, questions, options, shares, submissions CRUD + export (2026-04-23) - [x] Circles (Teams) tools: 14 tools — list/CRUD circles, member add/remove/level, search, join/leave (2026-04-24) +- [x] Cospend tools: 16 tools — projects (7), members (4), bills (5) — shared expense tracking (2026-04-26) ### In Progress @@ -49,7 +50,7 @@ (none) ### Next Up -- Weather Status (fully OCS). Tables, Polls, Notes, Deck, Bookmarks, Photos skipped — API not OCS or OCS incomplete. +- Cospend follow-ups (Shares: 5 share types; Categories/Payment modes/Currencies; Statistics export). Weather Status (fully OCS, bundled). Tables, Polls, Notes, Deck, Bookmarks, Photos skipped — API not OCS or OCS incomplete. ## Phases @@ -98,7 +99,8 @@ | File Reminders | 3 | 20 | | Forms | 25 | 34 | | Circles | 14 | 31 | -| **Total** | **141** | **836** | +| Cospend | 16 | 35 | +| **Total** | **157** | **871** | Files shows 10, but one (`upload_file_from_path`) is only registered when -`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 140 tools. +`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 156 tools. diff --git a/README.md b/README.md index 3e552e4..6f390a4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ A 141st tool, `upload_file_from_path`, is registered only when the operator sets | [Collectives](#collectives) | list, pages, create, trash, restore | OCS | | [Forms](#forms) | CRUD forms, questions, options, shares, submissions + export | OCS | | [Circles (Teams)](#circles-teams) | list, CRUD, members (add/remove/promote), join/leave, search | OCS | +| [Cospend](#cospend) | shared expense tracking — projects, members, bills | OCS | | [Unified Search](#unified-search) | list providers, search across apps | OCS | | [App Management](#app-management) | list, info, enable, disable apps | OCS | @@ -414,6 +415,29 @@ call; the body is streamed in chunks rather than loaded into memory. | `delete_circle` | destructive | Delete a circle | | `remove_circle_member` | destructive | Kick a member | +### Cospend + +Shared expense tracking ("who paid for what"). Requires the [Cospend](https://apps.nextcloud.com/apps/cospend) app to be installed and enabled. All routes are OCS at `/ocs/v2.php/apps/cospend/api/v1/`. + +| Tool | Permission | Description | +|------|-----------|-------------| +| `list_cospend_projects` | read | List projects the user can access | +| `get_cospend_project` | read | Get full project info (members, balance, shares, settings) | +| `get_cospend_project_statistics` | read | Per-member spending stats (paid/spent/balance) with filters | +| `get_cospend_project_settlement` | read | Suggested reimbursement transactions to settle a project | +| `list_cospend_members` | read | List members of a project | +| `list_cospend_bills` | read | List bills with filters (payer, category, search, pagination) | +| `get_cospend_bill` | read | Get a single bill | +| `create_cospend_project` | write | Create a project (caller becomes ADMIN) | +| `update_cospend_project` | write | Update project name, currency, sort, archive, etc. | +| `create_cospend_member` | write | Add a member (free-form name or linked to a Nextcloud user) | +| `update_cospend_member` | write | Update name/weight/color/activated/userid | +| `create_cospend_bill` | write | Create a bill (defaults date to today if neither date nor timestamp set) | +| `update_cospend_bill` | write | Update any bill field | +| `delete_cospend_project` | destructive | Delete a project and all its data | +| `delete_cospend_member` | destructive | Delete (or soft-disable if member has bills) | +| `delete_cospend_bill` | destructive | Delete a bill (default: trash; pass `move_to_trash=False` to purge) | + ### Unified Search | Tool | Permission | Description | diff --git a/src/nc_mcp_server/server.py b/src/nc_mcp_server/server.py index d473158..501c285 100644 --- a/src/nc_mcp_server/server.py +++ b/src/nc_mcp_server/server.py @@ -15,6 +15,7 @@ collectives, comments, contacts, + cospend, files, forms, mail, @@ -65,6 +66,7 @@ def create_server(config: Config | None = None) -> FastMCP: collectives.register(mcp) comments.register(mcp) contacts.register(mcp) + cospend.register(mcp) files.register(mcp) forms.register(mcp) mail.register(mcp) diff --git a/src/nc_mcp_server/tools/cospend.py b/src/nc_mcp_server/tools/cospend.py new file mode 100644 index 0000000..e76a98e --- /dev/null +++ b/src/nc_mcp_server/tools/cospend.py @@ -0,0 +1,654 @@ +"""Cospend tools — OCS API for shared expense tracking (projects, members, bills).""" + +import json +from datetime import UTC, datetime +from typing import Any +from urllib.parse import quote as url_quote + +from mcp.server.fastmcp import FastMCP + +from ..annotations import ADDITIVE, ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY +from ..permissions import PermissionLevel, require_permission +from ..state import get_client + +API_BASE = "apps/cospend/api/v1" + + +def _body(**kwargs: Any) -> dict[str, Any]: + """Build a JSON body dict, dropping any None values.""" + return {k: v for k, v in kwargs.items() if v is not None} + + +def _pid(project_id: str) -> str: + """URL-encode a project id for use in a path segment. + + Cospend allows project ids with spaces (and other characters) in `id` since + the slug is user-supplied and only `/` is rejected. Without encoding, any + such id breaks URL parsing on subsequent calls. + """ + return url_quote(project_id, safe="") + + +def _register_project_reads(mcp: FastMCP) -> None: + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def list_cospend_projects() -> str: + """List Cospend (shared expense) projects the current user can access. + + Returns: + JSON array of full project objects. Each entry includes id (string + slug, used as projectId in other tools), name, userid (owner), + email, lastchanged, deletiondisabled, archived_ts, currencyname, + categorysort/paymentmodesort, and `myaccesslevel` (1=VIEWER, + 2=PARTICIPANT, 3=MAINTAINER, 4=ADMIN). Also embeds members, + balance, shares, currencies, categories, paymentmodes for each + project, and counters nb_bills / total_spent / nb_trashbin_bills. + """ + client = get_client() + data = await client.ocs_get(f"{API_BASE}/projects") + return json.dumps(data) + + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def get_cospend_project(project_id: str) -> str: + """Get full info for a single Cospend project (members, balance, shares, …). + + Requires VIEWER access on the project. + + Args: + project_id: String project id (slug). + + Returns: + JSON object with the same shape as one entry from + list_cospend_projects. Use `members` for member ids/names and + `balance` for the per-member balance map. + """ + client = get_client() + data = await client.ocs_get(f"{API_BASE}/projects/{_pid(project_id)}") + return json.dumps(data) + + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def get_cospend_project_statistics( + project_id: str, + ts_min: int | None = None, + ts_max: int | None = None, + payment_mode_id: int | None = None, + category_id: int | None = None, + amount_min: float | None = None, + amount_max: float | None = None, + currency_id: int | None = None, + payer_id: int | None = None, + show_disabled: bool = True, + ) -> str: + """Per-member spending statistics for a Cospend project. + + Requires VIEWER access. All filters are optional and AND-combined. + + Args: + project_id: String project id. + ts_min: Only include bills with timestamp >= ts_min (Unix seconds). + ts_max: Only include bills with timestamp <= ts_max. + payment_mode_id: Filter by payment mode. + category_id: Filter by category. + amount_min: Only include bills with amount >= amount_min. + amount_max: Only include bills with amount <= amount_max. + currency_id: Convert/filter by currency id. + payer_id: Only include bills paid by this member. + show_disabled: Include disabled members in the output. + + Returns: + JSON object with `stats` (list of {member, balance, paid, spent, + filtered_balance}), plus aggregates such as memberMonthlyStats / + categoryStats depending on filters. + """ + client = get_client() + params = _body( + tsMin=ts_min, + tsMax=ts_max, + paymentModeId=payment_mode_id, + categoryId=category_id, + amountMin=amount_min, + amountMax=amount_max, + currencyId=currency_id, + payerId=payer_id, + ) + params["showDisabled"] = "1" if show_disabled else "0" + data = await client.ocs_get(f"{API_BASE}/projects/{_pid(project_id)}/statistics", params=params) + return json.dumps(data) + + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def get_cospend_project_settlement( + project_id: str, + centered_on: int | None = None, + max_timestamp: int | None = None, + ) -> str: + """Suggested reimbursement transactions to settle a Cospend project. + + Requires VIEWER access. + + Args: + project_id: String project id. + centered_on: Member id to center the plan on. All suggested + transactions will involve this member (e.g. "everyone pays Alice"). + max_timestamp: Settle up to this date (Unix seconds). Member + balances will be zero at this date and bills after it are + ignored. + + Returns: + JSON object with `transactions` (list of {from, to, amount} — + from/to are member ids) and `balances` (map of member-id → + current balance). + """ + client = get_client() + params = _body(centeredOn=centered_on, maxTimestamp=max_timestamp) + data = await client.ocs_get( + f"{API_BASE}/projects/{_pid(project_id)}/settlement", + params=params or None, + ) + return json.dumps(data) + + +def _register_member_reads(mcp: FastMCP) -> None: + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def list_cospend_members(project_id: str, last_changed: int | None = None) -> str: + """List members of a Cospend project. + + Requires VIEWER access. + + Args: + project_id: String project id. + last_changed: If provided, only return members modified after this + Unix timestamp (used for incremental sync by clients). + + Returns: + JSON array of members. Each entry has id (integer member id, used + as memberId in other tools), name, weight (share weight, default + 1), activated (bool — false means soft-disabled but kept for bill + history), userid (linked Nextcloud user id, or null for free-form + members), color (RGB dict), lastchanged. + """ + client = get_client() + params = {"lastChanged": last_changed} if last_changed is not None else None + data = await client.ocs_get(f"{API_BASE}/projects/{_pid(project_id)}/members", params=params) + return json.dumps(data) + + +def _register_bill_reads(mcp: FastMCP) -> None: + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def list_cospend_bills( + project_id: str, + offset: int | None = None, + limit: int | None = None, + reverse: bool = False, + last_changed: int | None = None, + payer_id: int | None = None, + category_id: int | None = None, + payment_mode_id: int | None = None, + include_bill_id: int | None = None, + search_term: str | None = None, + deleted: int = 0, + ) -> str: + """List bills (expenses) in a Cospend project. + + Requires VIEWER access. + + Args: + project_id: String project id. + offset: Skip the first N bills (server orders by date desc by default). + limit: Max bills to return. Omit for no limit. + reverse: If True, oldest-first instead of newest-first. + last_changed: Only return bills modified after this Unix timestamp. + payer_id: Filter to bills paid by this member. + category_id: Filter by category. + payment_mode_id: Filter by payment mode. + include_bill_id: Force-include this bill id in the result even if + it would be paginated out (used to keep a focused bill in view). + search_term: Substring match (case-insensitive) on bill `what`, + `comment`, or amount±1. REQUIRES `limit` to be set — Cospend + silently ignores the search and returns unfiltered results + when no limit is provided, so this tool raises ValueError if + `search_term` is given without `limit`. Pass any limit + (e.g. 1000) to apply the filter. + deleted: 0 = live bills (default), 1 = trashed bills only. + + Returns: + JSON object with `bills` (list of bill dicts — see get_cospend_bill + for fields), `nb_bills` (project-wide bill count under the + payer/category/payment-mode/deleted filters; NOT affected by + `search_term` even when search filters `bills`), `allBillIds` + (full id list before pagination), `timestamp` (server response time). + """ + if search_term is not None and limit is None: + raise ValueError( + "limit is required when search_term is set (Cospend silently ignores the search otherwise)" + ) + client = get_client() + params = _body( + offset=offset, + limit=limit, + lastChanged=last_changed, + payerId=payer_id, + categoryId=category_id, + paymentModeId=payment_mode_id, + includeBillId=include_bill_id, + searchTerm=search_term, + ) + params["deleted"] = deleted + params["reverse"] = "true" if reverse else "false" + data = await client.ocs_get(f"{API_BASE}/projects/{_pid(project_id)}/bills", params=params) + return json.dumps(data) + + @mcp.tool(annotations=READONLY) + @require_permission(PermissionLevel.READ) + async def get_cospend_bill(project_id: str, bill_id: int) -> str: + """Get a single Cospend bill (expense) by id. Requires VIEWER access. + + Args: + project_id: String project id. + bill_id: Integer bill id. + + Returns: + JSON bill object: id, what (description), amount, payer_id, + owers (list of member dicts who share the cost), owerIds (id list), + date (YYYY-MM-DD), timestamp (Unix seconds), comment, categoryid, + paymentmodeid, repeat ("n"=none, "d"=daily, "w"=weekly, + "b"=biweekly, "s"=semi-monthly, "m"=monthly, "y"=yearly), + repeatfreq, repeatallactive, repeatuntil, deleted (0=live, + 1=in trash), lastchanged. + """ + client = get_client() + data = await client.ocs_get(f"{API_BASE}/projects/{_pid(project_id)}/bills/{bill_id}") + return json.dumps(data) + + +def _register_project_writes(mcp: FastMCP) -> None: + @mcp.tool(annotations=ADDITIVE) + @require_permission(PermissionLevel.WRITE) + async def create_cospend_project(project_id: str, name: str) -> str: + """Create a new Cospend project. The caller becomes ADMIN of it. + + Args: + project_id: Desired string id (slug). If the id is already taken, + the server appends a digit to make it unique — check the `id` + field of the returned project for the actual id assigned. + name: Display name (does not have to be unique). + + Returns: + JSON of the new project (full info — same shape as + get_cospend_project), including the resolved id and the default + categories and payment modes that the server seeds. + """ + client = get_client() + data = await client.ocs_post_json( + f"{API_BASE}/projects", + json_data={"id": project_id, "name": name}, + ) + return json.dumps(data) + + @mcp.tool(annotations=ADDITIVE_IDEMPOTENT) + @require_permission(PermissionLevel.WRITE) + async def update_cospend_project( + project_id: str, + name: str | None = None, + auto_export: str | None = None, + currency_name: str | None = None, + deletion_disabled: bool | None = None, + category_sort: str | None = None, + payment_mode_sort: str | None = None, + archived_ts: int | None = None, + ) -> str: + """Update a Cospend project's settings. Requires ADMIN access. + + Pass only the fields you want to change; omit the rest. + + Args: + project_id: String project id. + name: New display name. + auto_export: Periodic CSV auto-export frequency. Same code set as + bill `repeat`: "n"=none (default), "d"=daily, "w"=weekly, + "b"=biweekly, "s"=semi-monthly, "m"=monthly, "y"=yearly. + currency_name: Main currency name (free-form string, e.g. "EUR"). + Pass empty string to clear. + deletion_disabled: When set, delete_cospend_bill returns HTTP 403 + ("project deletion is disabled"). delete_cospend_project is + NOT gated by this flag and still succeeds. Useful as a guard + against accidental bill removal in shared projects. + category_sort: Default category ordering. "a"=alphabetical (default), + "m"=manual (custom `order` field), "u"=most used, + "r"=recently used. + payment_mode_sort: Same options as category_sort, for payment modes. + archived_ts: Archive control with three special values. + - 0 → archive now (server records the current Unix timestamp). + - -1 → unarchive (clears the field). + - any other int → archive at that exact Unix timestamp. + Note: 0 ARCHIVES the project (it does not unarchive). + + Returns: + JSON {"project_id": ..., "updated": true} — the OCS endpoint + returns no body, so this is a synthetic confirmation. + """ + client = get_client() + body = _body( + name=name, + autoExport=auto_export, + currencyName=currency_name, + deletionDisabled=deletion_disabled, + categorySort=category_sort, + paymentModeSort=payment_mode_sort, + archivedTs=archived_ts, + ) + await client.ocs_put_json(f"{API_BASE}/projects/{_pid(project_id)}", json_data=body) + return json.dumps({"project_id": project_id, "updated": True}) + + +def _register_member_writes(mcp: FastMCP) -> None: + @mcp.tool(annotations=ADDITIVE) + @require_permission(PermissionLevel.WRITE) + async def create_cospend_member( + project_id: str, + name: str, + user_id: str | None = None, + weight: float = 1.0, + active: bool = True, + color: str | None = None, + ) -> str: + """Add a member to a Cospend project. Requires MAINTAINER access. + + Args: + project_id: String project id. + name: Display name of the new member. + user_id: Link this member to a Nextcloud user id (optional). If set, + the member can see the project in their Cospend UI. + weight: Share weight (default 1.0). A weight-2 member counts double + in even splits. + active: If False, the member is created soft-disabled (rare — + usually create active and disable later via update_cospend_member). + color: Hex color like "#aabbcc". Omit to let the server pick. + + Returns: + JSON of the new member: id (use as memberId in other tools), name, + weight, activated, userid, color (RGB dict), lastchanged. + """ + client = get_client() + body = _body(name=name, weight=weight, active=1 if active else 0, userId=user_id, color=color) + data = await client.ocs_post_json(f"{API_BASE}/projects/{_pid(project_id)}/members", json_data=body) + return json.dumps(data) + + @mcp.tool(annotations=ADDITIVE_IDEMPOTENT) + @require_permission(PermissionLevel.WRITE) + async def update_cospend_member( + project_id: str, + member_id: int, + name: str | None = None, + weight: float | None = None, + activated: bool | None = None, + color: str | None = None, + user_id: str | None = None, + ) -> str: + """Update a Cospend member. Requires MAINTAINER access. + + Pass only the fields you want to change. + + IMPORTANT: Setting `activated=False` on a member who has no bills will + permanently delete them, not just disable them. To always keep a + recoverable record, ensure the member has at least one associated bill + first, or use delete_cospend_member which is unambiguous. + + Args: + project_id: String project id. + member_id: Integer member id (from list_cospend_members). + name: New display name. + weight: New share weight. + activated: True = active, False = disabled (or deleted if no bills). + color: New hex color (with or without leading "#"). Pass empty + string to clear the color (server picks one on next display). + user_id: Link/unlink to a Nextcloud user id. Pass empty string to + unlink. + + Returns: + JSON of the updated member, or null if the member was deleted as + described above. + """ + client = get_client() + body = _body(name=name, weight=weight, activated=activated, color=color, userId=user_id) + data = await client.ocs_put_json( + f"{API_BASE}/projects/{_pid(project_id)}/members/{member_id}", + json_data=body, + ) + return json.dumps(data) + + +def _register_bill_writes(mcp: FastMCP) -> None: + @mcp.tool(annotations=ADDITIVE) + @require_permission(PermissionLevel.WRITE) + async def create_cospend_bill( + project_id: str, + what: str, + amount: float, + payer: int, + payed_for: list[int], + date: str | None = None, + timestamp: int | None = None, + comment: str | None = None, + category_id: int | None = None, + payment_mode_id: int | None = None, + repeat: str = "n", + repeat_freq: int | None = None, + repeat_until: str | None = None, + repeat_all_active: int = 0, + ) -> str: + """Create a bill (expense) in a Cospend project. Requires PARTICIPANT access. + + Args: + project_id: String project id. + what: Short description of the expense (e.g. "Pizza"). + amount: Total amount paid. + payer: Member id of the person who paid. + payed_for: Non-empty list of member ids who share the cost. + Cospend requires at least one ower per bill — passing [] raises + ValueError (the server would reject it on create and silently + no-op on update, so we reject it up front for both). + date: Date string "YYYY-MM-DD". Defaults to today (UTC) if both + date and timestamp are omitted; the underlying API requires one. + timestamp: Alternative to date — Unix seconds. If both are set, the + server uses timestamp. + comment: Free-form longer note. + category_id: Category id (see project's `categories`). Omit for + uncategorized (id=0). + payment_mode_id: Payment mode id (see project's `paymentmodes`). + Omit for unset (id=0). + repeat: Repetition mode: "n"=no repeat (default), "d"=daily, + "w"=weekly, "b"=biweekly, "s"=semi-monthly, "m"=monthly, + "y"=yearly. + repeat_freq: Every N units (default 1). E.g. repeat="m", + repeat_freq=3 means quarterly. + repeat_until: Stop repeating after this date "YYYY-MM-DD". + repeat_all_active: 0 (default) = repeat with the same owers, + 1 = on each repetition use whoever is currently active. + + Returns: + JSON {"bill_id": } — the integer id of the new bill. + """ + if not payed_for: + raise ValueError("payed_for must be a non-empty list of member ids") + client = get_client() + if timestamp is None and date is None: + date = datetime.now(UTC).date().isoformat() + body = _body( + what=what, + amount=amount, + payer=payer, + payedFor=",".join(str(m) for m in payed_for), + repeat=repeat, + repeatAllActive=repeat_all_active, + timestamp=timestamp, + date=date, + comment=comment, + categoryId=category_id, + paymentModeId=payment_mode_id, + repeatFreq=repeat_freq, + repeatUntil=repeat_until, + ) + bill_id = await client.ocs_post_json(f"{API_BASE}/projects/{_pid(project_id)}/bills", json_data=body) + return json.dumps({"bill_id": bill_id}) + + @mcp.tool(annotations=ADDITIVE_IDEMPOTENT) + @require_permission(PermissionLevel.WRITE) + async def update_cospend_bill( + project_id: str, + bill_id: int, + what: str | None = None, + amount: float | None = None, + payer: int | None = None, + payed_for: list[int] | None = None, + date: str | None = None, + timestamp: int | None = None, + comment: str | None = None, + category_id: int | None = None, + payment_mode_id: int | None = None, + repeat: str | None = None, + repeat_freq: int | None = None, + repeat_until: str | None = None, + repeat_all_active: int | None = None, + deleted: int | None = None, + ) -> str: + """Update a Cospend bill. Requires PARTICIPANT access. + + Pass only fields you want to change. See create_cospend_bill for + field semantics. + + Args: + project_id: String project id. + bill_id: Integer bill id. + what: New description. + amount: New amount. + payer: New payer member id. + payed_for: New non-empty list of ower member ids (replaces, + doesn't merge). Passing [] raises ValueError — the server + no-ops silently on empty payedFor, which would look like a + successful update but leave owers unchanged. + date: New "YYYY-MM-DD" date. + timestamp: Alternative to date. + comment: New comment. + category_id: New category id (0 = uncategorized). + payment_mode_id: New payment mode id (0 = unset). + repeat: New repeat mode ("n", "d", "w", "b", "s", "m", "y"). + repeat_freq: New repeat frequency. + repeat_until: New stop date "YYYY-MM-DD". Pass empty string to + clear (repeat indefinitely). + repeat_all_active: New owers behavior on repeat. + deleted: 0 = restore from trash, 1 = move to trash. Use + delete_cospend_bill for trashing in normal flow. + + Returns: + JSON {"bill_id": } confirming the updated bill id. + """ + if payed_for is not None and not payed_for: + raise ValueError("payed_for must be a non-empty list of member ids") + client = get_client() + body = _body( + what=what, + amount=amount, + payer=payer, + payedFor=",".join(str(m) for m in payed_for) if payed_for is not None else None, + date=date, + timestamp=timestamp, + comment=comment, + categoryId=category_id, + paymentModeId=payment_mode_id, + repeat=repeat, + repeatFreq=repeat_freq, + repeatUntil=repeat_until, + repeatAllActive=repeat_all_active, + deleted=deleted, + ) + result = await client.ocs_put_json( + f"{API_BASE}/projects/{_pid(project_id)}/bills/{bill_id}", + json_data=body, + ) + return json.dumps({"bill_id": result}) + + +def _register_destructive_tools(mcp: FastMCP) -> None: + @mcp.tool(annotations=DESTRUCTIVE) + @require_permission(PermissionLevel.DESTRUCTIVE) + async def delete_cospend_project(project_id: str) -> str: + """Delete a Cospend project and all its members, bills, and shares. + + Requires ADMIN access on the project. The project-delete endpoint does + NOT honor `deletionDisabled` (only delete_cospend_bill is gated by + that flag), so this irrevocably removes everything regardless. + + Args: + project_id: String project id. + + Returns: + JSON {"project_id": ..., "message": "DELETED"}. + """ + client = get_client() + await client.ocs_delete(f"{API_BASE}/projects/{_pid(project_id)}") + return json.dumps({"project_id": project_id, "message": "DELETED"}) + + @mcp.tool(annotations=DESTRUCTIVE) + @require_permission(PermissionLevel.DESTRUCTIVE) + async def delete_cospend_member(project_id: str, member_id: int) -> str: + """Delete (or disable) a Cospend project member. Requires MAINTAINER access. + + Members with bills cannot be hard-deleted — they are soft-disabled + instead (activated=false) so existing bill history stays valid. Members + without any bill are permanently removed. + + Args: + project_id: String project id. + member_id: Integer member id. + + Returns: + JSON {"project_id": ..., "member_id": ..., "deleted": true}. + """ + client = get_client() + await client.ocs_delete(f"{API_BASE}/projects/{_pid(project_id)}/members/{member_id}") + return json.dumps({"project_id": project_id, "member_id": member_id, "deleted": True}) + + @mcp.tool(annotations=DESTRUCTIVE) + @require_permission(PermissionLevel.DESTRUCTIVE) + async def delete_cospend_bill( + project_id: str, + bill_id: int, + move_to_trash: bool = True, + ) -> str: + """Delete a Cospend bill. Requires PARTICIPANT access. + + Returns HTTP 403 ("project deletion is disabled") if the project has + `deletionDisabled` set. Use update_cospend_project to clear that flag + first if you need to delete bills. + + Args: + project_id: String project id. + bill_id: Integer bill id. + move_to_trash: If True (default), move to the project trash bin — + the bill can be restored later by setting deleted=0 via + update_cospend_bill. If False, hard-delete (irreversible). + + Returns: + JSON {"project_id": ..., "bill_id": ..., "moved_to_trash": }. + """ + client = get_client() + await client.ocs_delete( + f"{API_BASE}/projects/{_pid(project_id)}/bills/{bill_id}?moveToTrash={'true' if move_to_trash else 'false'}" + ) + return json.dumps({"project_id": project_id, "bill_id": bill_id, "moved_to_trash": move_to_trash}) + + +def register(mcp: FastMCP) -> None: + """Register Cospend tools with the MCP server.""" + _register_project_reads(mcp) + _register_member_reads(mcp) + _register_bill_reads(mcp) + _register_project_writes(mcp) + _register_member_writes(mcp) + _register_bill_writes(mcp) + _register_destructive_tools(mcp) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c55f0aa..5ed94a1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,6 +4,7 @@ import os from collections.abc import AsyncGenerator from pathlib import Path +from urllib.parse import quote import pytest from mcp.server.fastmcp import FastMCP @@ -218,6 +219,16 @@ async def _cleanup_circles(client: NextcloudClient) -> None: await client.ocs_delete(f"apps/circles/circles/{circle['id']}") +async def _cleanup_cospend(client: NextcloudClient) -> None: + with contextlib.suppress(Exception): + projects: list[dict[str, object]] = await client.ocs_get("apps/cospend/api/v1/projects") + for project in projects or []: + project_id = str(project.get("id", "")) + if project_id.startswith("mcp-test"): + with contextlib.suppress(Exception): + await client.ocs_delete(f"apps/cospend/api/v1/projects/{quote(project_id, safe='')}") + + async def _cleanup(client: NextcloudClient) -> None: """Remove all test artifacts from Nextcloud.""" await _cleanup_shares(client) @@ -232,3 +243,4 @@ async def _cleanup(client: NextcloudClient) -> None: await _cleanup_announcements(client) await _cleanup_forms(client) await _cleanup_circles(client) + await _cleanup_cospend(client) diff --git a/tests/integration/test_cospend.py b/tests/integration/test_cospend.py new file mode 100644 index 0000000..044ad14 --- /dev/null +++ b/tests/integration/test_cospend.py @@ -0,0 +1,615 @@ +"""Integration tests for Cospend tools against a real Nextcloud instance.""" + +import json +from typing import Any, ClassVar + +import niquests +import pytest +from mcp.server.fastmcp.exceptions import ToolError + +from nc_mcp_server.client import NextcloudError +from nc_mcp_server.config import Config + +from .conftest import McpTestHelper + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="session") +def _cospend_available(_cleanup_config: Config) -> bool: + """Probe whether the Cospend app is enabled on the target Nextcloud.""" + try: + resp = niquests.get( + f"{_cleanup_config.nextcloud_url}/ocs/v2.php/apps/cospend/api/v1/ping", + auth=(_cleanup_config.user, _cleanup_config.password), + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + timeout=5, + ) + except (OSError, niquests.exceptions.RequestException): + return False + return resp.ok + + +@pytest.fixture(autouse=True) +def _skip_if_no_cospend(_cospend_available: bool) -> None: + if not _cospend_available: + pytest.skip("Cospend app is not enabled on this Nextcloud instance") + + +async def _make_project(nc_mcp: McpTestHelper, project_id: str, name: str | None = None) -> dict[str, Any]: + return json.loads(await nc_mcp.call("create_cospend_project", project_id=project_id, name=name or project_id)) + + +async def _make_member(nc_mcp: McpTestHelper, project_id: str, name: str, **extra: Any) -> dict[str, Any]: + return json.loads(await nc_mcp.call("create_cospend_member", project_id=project_id, name=name, **extra)) + + +async def _make_bill( + nc_mcp: McpTestHelper, + project_id: str, + what: str, + amount: float, + payer: int, + payed_for: list[int], + **extra: Any, +) -> int: + result = json.loads( + await nc_mcp.call( + "create_cospend_bill", + project_id=project_id, + what=what, + amount=amount, + payer=payer, + payed_for=payed_for, + **extra, + ) + ) + return int(result["bill_id"]) + + +class TestProjectLifecycle: + @pytest.mark.asyncio + async def test_create_returns_full_project(self, nc_mcp: McpTestHelper) -> None: + project = await _make_project(nc_mcp, "mcp-test-create", "Create Test") + assert project["id"] == "mcp-test-create" + assert project["name"] == "Create Test" + assert isinstance(project["categories"], dict) + assert project["categories"] + assert isinstance(project["paymentmodes"], dict) + assert project["paymentmodes"] + + @pytest.mark.asyncio + async def test_create_with_taken_id_gets_suffix(self, nc_mcp: McpTestHelper) -> None: + first = await _make_project(nc_mcp, "mcp-test-dup", "First") + second = await _make_project(nc_mcp, "mcp-test-dup", "Second") + assert first["id"] == "mcp-test-dup" + assert second["id"] != first["id"] + assert second["id"].startswith("mcp-test-dup") + + @pytest.mark.asyncio + async def test_list_includes_created(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-list-a") + await _make_project(nc_mcp, "mcp-test-list-b") + projects = json.loads(await nc_mcp.call("list_cospend_projects")) + ids = [p["id"] for p in projects] + assert "mcp-test-list-a" in ids + assert "mcp-test-list-b" in ids + + @pytest.mark.asyncio + async def test_get_returns_project(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-get", "Get Me") + project = json.loads(await nc_mcp.call("get_cospend_project", project_id="mcp-test-get")) + assert project["id"] == "mcp-test-get" + assert project["name"] == "Get Me" + + @pytest.mark.asyncio + async def test_update_changes_name(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-update") + result = json.loads(await nc_mcp.call("update_cospend_project", project_id="mcp-test-update", name="Renamed")) + assert result == {"project_id": "mcp-test-update", "updated": True} + fetched = json.loads(await nc_mcp.call("get_cospend_project", project_id="mcp-test-update")) + assert fetched["name"] == "Renamed" + + @pytest.mark.asyncio + async def test_update_archived_ts_is_persisted(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-archive") + await nc_mcp.call( + "update_cospend_project", + project_id="mcp-test-archive", + archived_ts=1_700_000_000, + ) + fetched = json.loads(await nc_mcp.call("get_cospend_project", project_id="mcp-test-archive")) + assert fetched["archived_ts"] == 1_700_000_000 + + @pytest.mark.asyncio + async def test_archived_ts_special_values(self, nc_mcp: McpTestHelper) -> None: + """archived_ts=0 archives NOW (Cospend's ARCHIVED_TS_NOW), -1 unarchives (ARCHIVED_TS_UNSET). + + Documents the inverted-feeling sentinel values so we don't regress the docstring. + """ + pid = "mcp-test-archive-special" + await _make_project(nc_mcp, pid) + + # archived_ts=0 → archives at "now" (server records current Unix ts, not zero) + await nc_mcp.call("update_cospend_project", project_id=pid, archived_ts=0) + fetched = json.loads(await nc_mcp.call("get_cospend_project", project_id=pid)) + archived = fetched["archived_ts"] + assert isinstance(archived, int), "archived_ts=0 must produce an int (current ts), not unarchive" + assert archived > 0, "archived_ts=0 must record a positive current Unix timestamp" + + # archived_ts=-1 → unarchives (clears the field to None) + await nc_mcp.call("update_cospend_project", project_id=pid, archived_ts=-1) + fetched = json.loads(await nc_mcp.call("get_cospend_project", project_id=pid)) + assert fetched["archived_ts"] is None, "archived_ts=-1 should unarchive (clear the field)" + + @pytest.mark.asyncio + async def test_delete_removes_project(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-del") + result = json.loads(await nc_mcp.call("delete_cospend_project", project_id="mcp-test-del")) + assert result["project_id"] == "mcp-test-del" + assert result["message"] == "DELETED" + with pytest.raises((ToolError, NextcloudError)): + await nc_mcp.call("get_cospend_project", project_id="mcp-test-del") + + @pytest.mark.asyncio + async def test_get_nonexistent_raises(self, nc_mcp: McpTestHelper) -> None: + with pytest.raises((ToolError, NextcloudError)): + await nc_mcp.call("get_cospend_project", project_id="mcp-test-does-not-exist") + + @pytest.mark.asyncio + async def test_project_id_with_space_round_trips(self, nc_mcp: McpTestHelper) -> None: + """Cospend allows spaces in project ids — verify URL-encoding so subsequent ops don't 404.""" + pid = "mcp-test with space" + created = await _make_project(nc_mcp, pid, "Spaced") + assert created["id"] == pid + fetched = json.loads(await nc_mcp.call("get_cospend_project", project_id=pid)) + assert fetched["id"] == pid + member = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, member["id"], [member["id"], bob["id"]]) + bill = json.loads(await nc_mcp.call("get_cospend_bill", project_id=pid, bill_id=bill_id)) + assert bill["amount"] == 10.0 + await nc_mcp.call("delete_cospend_project", project_id=pid) + + +class TestMembers: + @pytest.mark.asyncio + async def test_create_returns_member(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-create") + member = await _make_member(nc_mcp, "mcp-test-mem-create", "Alice", weight=2.0) + assert member["name"] == "Alice" + assert member["weight"] == 2.0 + assert member["activated"] is True + assert isinstance(member["id"], int) + + @pytest.mark.asyncio + async def test_list_returns_created_members(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-list") + await _make_member(nc_mcp, "mcp-test-mem-list", "Alice") + await _make_member(nc_mcp, "mcp-test-mem-list", "Bob") + members = json.loads(await nc_mcp.call("list_cospend_members", project_id="mcp-test-mem-list")) + assert {m["name"] for m in members} == {"Alice", "Bob"} + + @pytest.mark.asyncio + async def test_create_with_color_persists_color(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-color") + member = await _make_member(nc_mcp, "mcp-test-mem-color", "Charlie", color="#1a2b3c") + assert member["color"] == {"r": 0x1A, "g": 0x2B, "b": 0x3C} + + @pytest.mark.asyncio + async def test_update_renames_member(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-rename") + member = await _make_member(nc_mcp, "mcp-test-mem-rename", "Old") + updated = json.loads( + await nc_mcp.call( + "update_cospend_member", + project_id="mcp-test-mem-rename", + member_id=member["id"], + name="New", + ) + ) + assert updated["name"] == "New" + + @pytest.mark.asyncio + async def test_update_weight_persists(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-weight") + member = await _make_member(nc_mcp, "mcp-test-mem-weight", "Heavy") + updated = json.loads( + await nc_mcp.call( + "update_cospend_member", + project_id="mcp-test-mem-weight", + member_id=member["id"], + weight=3.5, + ) + ) + assert updated["weight"] == 3.5 + + @pytest.mark.asyncio + async def test_update_activated_false_then_true_round_trip(self, nc_mcp: McpTestHelper) -> None: + """Soft-disable via update path, then re-enable. Catches the create/update field-name asymmetry + (create uses `active` int, update uses `activated` bool).""" + pid = "mcp-test-mem-toggle" + await _make_project(nc_mcp, pid) + member = await _make_member(nc_mcp, pid, "Toggle") + bob = await _make_member(nc_mcp, pid, "Bob") + # Member must own a bill so update(activated=False) soft-disables instead of deleting + await _make_bill(nc_mcp, pid, "Lunch", 10.0, payer=member["id"], payed_for=[member["id"], bob["id"]]) + + disabled = json.loads( + await nc_mcp.call( + "update_cospend_member", + project_id=pid, + member_id=member["id"], + activated=False, + ) + ) + assert disabled is not None, "member with bills should be returned as soft-disabled, not deleted" + assert disabled["activated"] is False + members = json.loads(await nc_mcp.call("list_cospend_members", project_id=pid)) + assert next(m for m in members if m["id"] == member["id"])["activated"] is False + + re_enabled = json.loads( + await nc_mcp.call( + "update_cospend_member", + project_id=pid, + member_id=member["id"], + activated=True, + ) + ) + assert re_enabled["activated"] is True + + @pytest.mark.asyncio + async def test_delete_removes_member_with_no_bills(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-mem-del") + member = await _make_member(nc_mcp, "mcp-test-mem-del", "Doomed") + result = json.loads( + await nc_mcp.call( + "delete_cospend_member", + project_id="mcp-test-mem-del", + member_id=member["id"], + ) + ) + assert result == {"project_id": "mcp-test-mem-del", "member_id": member["id"], "deleted": True} + members = json.loads(await nc_mcp.call("list_cospend_members", project_id="mcp-test-mem-del")) + assert all(m["id"] != member["id"] for m in members) + + @pytest.mark.asyncio + async def test_delete_member_with_bills_disables_them(self, nc_mcp: McpTestHelper) -> None: + """Members owning a bill cannot be hard-deleted; the API soft-disables them.""" + pid = "mcp-test-mem-disable" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 10.0, payer=alice["id"], payed_for=[alice["id"], bob["id"]]) + await nc_mcp.call("delete_cospend_member", project_id=pid, member_id=alice["id"]) + members = json.loads(await nc_mcp.call("list_cospend_members", project_id=pid)) + # alice is still in the list but disabled, so bill history stays valid + alice_after = next((m for m in members if m["id"] == alice["id"]), None) + assert alice_after is not None, "member with bills should be kept (soft-disabled), not removed" + assert alice_after["activated"] is False + + +class TestBills: + @pytest.mark.asyncio + async def test_create_returns_bill_id(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-create" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 20.0, payer=alice["id"], payed_for=[alice["id"], bob["id"]]) + assert isinstance(bill_id, int) + assert bill_id > 0 + + @pytest.mark.asyncio + async def test_get_returns_bill_fields(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-get" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill( + nc_mcp, + pid, + "Pizza", + 20.0, + payer=alice["id"], + payed_for=[alice["id"], bob["id"]], + comment="With pepperoni", + date="2026-04-26", + ) + bill = json.loads(await nc_mcp.call("get_cospend_bill", project_id=pid, bill_id=bill_id)) + assert bill["what"] == "Pizza" + assert bill["amount"] == 20.0 + assert bill["payer_id"] == alice["id"] + assert sorted(bill["owerIds"]) == sorted([alice["id"], bob["id"]]) + assert bill["comment"] == "With pepperoni" + assert bill["date"] == "2026-04-26" + + @pytest.mark.asyncio + async def test_list_includes_created_bill(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-list" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Cake", 5.0, alice["id"], [alice["id"], bob["id"]]) + await _make_bill(nc_mcp, pid, "Beer", 8.0, bob["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid)) + assert result["nb_bills"] == 2 + whats = sorted(b["what"] for b in result["bills"]) + assert whats == ["Beer", "Cake"] + + @pytest.mark.asyncio + async def test_list_search_term_filters(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-search" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 20.0, alice["id"], [alice["id"], bob["id"]]) + await _make_bill(nc_mcp, pid, "Sushi", 30.0, alice["id"], [alice["id"], bob["id"]]) + # Cospend quirk: search_term is only applied when limit is also set. + # nb_bills stays at the pre-search project total — only `bills` is filtered. + result = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid, search_term="izz", limit=10)) + whats = [b["what"] for b in result["bills"]] + assert whats == ["Pizza"] + assert result["nb_bills"] == 2 # search doesn't affect the count + + @pytest.mark.asyncio + async def test_list_search_term_without_limit_raises(self, nc_mcp: McpTestHelper) -> None: + """search_term without limit must fail fast — Cospend silently ignores it otherwise.""" + pid = "mcp-test-bill-searchnolim" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 20.0, alice["id"], [alice["id"]]) + with pytest.raises(ToolError, match="limit is required"): + await nc_mcp.call("list_cospend_bills", project_id=pid, search_term="Pizz") + + @pytest.mark.asyncio + async def test_list_payer_id_filters(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-payer" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Cake", 5.0, alice["id"], [alice["id"], bob["id"]]) + await _make_bill(nc_mcp, pid, "Beer", 8.0, bob["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid, payer_id=alice["id"])) + assert result["nb_bills"] == 1 + assert result["bills"][0]["what"] == "Cake" + + @pytest.mark.asyncio + async def test_update_changes_amount(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-update" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + result = json.loads( + await nc_mcp.call( + "update_cospend_bill", + project_id=pid, + bill_id=bill_id, + amount=15.0, + ) + ) + assert result == {"bill_id": bill_id} + bill = json.loads(await nc_mcp.call("get_cospend_bill", project_id=pid, bill_id=bill_id)) + assert bill["amount"] == 15.0 + + @pytest.mark.asyncio + async def test_update_changes_payed_for_replaces(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-payedfor" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + carol = await _make_member(nc_mcp, pid, "Carol") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + await nc_mcp.call( + "update_cospend_bill", + project_id=pid, + bill_id=bill_id, + payed_for=[bob["id"], carol["id"]], + ) + bill = json.loads(await nc_mcp.call("get_cospend_bill", project_id=pid, bill_id=bill_id)) + assert sorted(bill["owerIds"]) == sorted([bob["id"], carol["id"]]) + + @pytest.mark.asyncio + async def test_delete_with_trash_keeps_bill_recoverable(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-trash" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("delete_cospend_bill", project_id=pid, bill_id=bill_id)) + assert result == {"project_id": pid, "bill_id": bill_id, "moved_to_trash": True} + # live list should not include it + live = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid)) + assert live["nb_bills"] == 0 + # trashed list should include it + trashed = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid, deleted=1)) + assert trashed["nb_bills"] == 1 + assert trashed["bills"][0]["id"] == bill_id + + @pytest.mark.asyncio + async def test_delete_bill_blocked_when_deletion_disabled(self, nc_mcp: McpTestHelper) -> None: + """deletionDisabled is enforced by delete_cospend_bill — server returns HTTP 403. + + Counters the (incorrect) intuition that the flag is a frontend-only hint. + """ + pid = "mcp-test-bill-deletion-disabled" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + + await nc_mcp.call("update_cospend_project", project_id=pid, deletion_disabled=True) + + with pytest.raises((ToolError, NextcloudError)): + await nc_mcp.call("delete_cospend_bill", project_id=pid, bill_id=bill_id) + + # Bill is still alive + bill = json.loads(await nc_mcp.call("get_cospend_bill", project_id=pid, bill_id=bill_id)) + assert bill["deleted"] == 0 + + # delete_cospend_project is NOT gated by deletionDisabled — must succeed + result = json.loads(await nc_mcp.call("delete_cospend_project", project_id=pid)) + assert result["message"] == "DELETED" + + @pytest.mark.asyncio + async def test_delete_without_trash_purges_bill(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-bill-purge" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + await nc_mcp.call("delete_cospend_bill", project_id=pid, bill_id=bill_id, move_to_trash=False) + # neither live nor trashed lists should include it + live = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid)) + assert live["nb_bills"] == 0 + trashed = json.loads(await nc_mcp.call("list_cospend_bills", project_id=pid, deleted=1)) + assert trashed["nb_bills"] == 0 + + +class TestStatisticsAndSettlement: + @pytest.mark.asyncio + async def test_statistics_reflects_bill_split(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-stats" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 30.0, alice["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("get_cospend_project_statistics", project_id=pid)) + stats = {s["member"]["name"]: s for s in result["stats"]} + assert stats["Alice"]["paid"] == 30.0 + assert stats["Bob"]["paid"] == 0 + # both share the cost equally + assert round(stats["Alice"]["balance"], 6) == 15.0 + assert round(stats["Bob"]["balance"], 6) == -15.0 + + @pytest.mark.asyncio + async def test_settlement_proposes_transactions(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-settle" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 30.0, alice["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("get_cospend_project_settlement", project_id=pid)) + assert "transactions" in result + # Bob owes Alice 15 + assert any( + t["from"] == bob["id"] and t["to"] == alice["id"] and round(t["amount"], 6) == 15.0 + for t in result["transactions"] + ) + + @pytest.mark.asyncio + async def test_statistics_filter_by_payer(self, nc_mcp: McpTestHelper) -> None: + pid = "mcp-test-stats-filter" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + await _make_bill(nc_mcp, pid, "Pizza", 20.0, alice["id"], [alice["id"], bob["id"]]) + await _make_bill(nc_mcp, pid, "Beer", 10.0, bob["id"], [alice["id"], bob["id"]]) + result = json.loads(await nc_mcp.call("get_cospend_project_statistics", project_id=pid, payer_id=alice["id"])) + # filtered_balance reflects only the filtered slice (Alice's bills) + stats = {s["member"]["name"]: s for s in result["stats"]} + assert round(stats["Alice"]["filtered_balance"], 6) == 10.0 + assert round(stats["Bob"]["filtered_balance"], 6) == -10.0 + + +class TestPermissionGating: + """Verify that permission levels block higher-tier operations.""" + + @pytest.mark.asyncio + async def test_read_only_blocks_create_project(self, nc_mcp_read_only: McpTestHelper) -> None: + with pytest.raises(ToolError, match="permission"): + await nc_mcp_read_only.call("create_cospend_project", project_id="mcp-test-perm", name="Should Fail") + + @pytest.mark.asyncio + async def test_write_blocks_delete_project(self, nc_mcp: McpTestHelper, nc_mcp_write: McpTestHelper) -> None: + # Use the destructive helper to set up the project so we have one to attempt to delete + await _make_project(nc_mcp, "mcp-test-perm-del") + with pytest.raises(ToolError, match="permission"): + await nc_mcp_write.call("delete_cospend_project", project_id="mcp-test-perm-del") + + @pytest.mark.asyncio + async def test_write_blocks_delete_bill(self, nc_mcp: McpTestHelper, nc_mcp_write: McpTestHelper) -> None: + pid = "mcp-test-perm-bill" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "Pizza", 10.0, alice["id"], [alice["id"], bob["id"]]) + with pytest.raises(ToolError, match="permission"): + await nc_mcp_write.call("delete_cospend_bill", project_id=pid, bill_id=bill_id) + + @pytest.mark.asyncio + async def test_read_only_can_call_read_tools(self, nc_mcp_read_only: McpTestHelper) -> None: + # list_cospend_projects requires READ — must work under read-only + result = await nc_mcp_read_only.call("list_cospend_projects") + assert isinstance(json.loads(result), list) + + +class TestErrorHandling: + @pytest.mark.asyncio + async def test_get_member_list_for_nonexistent_project_raises(self, nc_mcp: McpTestHelper) -> None: + with pytest.raises((ToolError, NextcloudError)): + await nc_mcp.call("list_cospend_members", project_id="mcp-test-no-such") + + @pytest.mark.asyncio + async def test_get_bill_with_bad_id_raises(self, nc_mcp: McpTestHelper) -> None: + await _make_project(nc_mcp, "mcp-test-bad-bill") + with pytest.raises((ToolError, NextcloudError)): + await nc_mcp.call("get_cospend_bill", project_id="mcp-test-bad-bill", bill_id=999_999_999) + + @pytest.mark.asyncio + async def test_create_bill_with_empty_payed_for_rejected(self, nc_mcp: McpTestHelper) -> None: + """payed_for=[] is rejected client-side — server would 400, we fail fast with a clearer message.""" + pid = "mcp-test-bill-empty-create" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + with pytest.raises(ToolError, match="non-empty"): + await nc_mcp.call( + "create_cospend_bill", + project_id=pid, + what="X", + amount=1.0, + payer=alice["id"], + payed_for=[], + ) + + @pytest.mark.asyncio + async def test_update_bill_with_empty_payed_for_rejected(self, nc_mcp: McpTestHelper) -> None: + """payed_for=[] on update is rejected — server would silently no-op (200 OK with owers unchanged), + which would look like a successful update. We reject up front instead.""" + pid = "mcp-test-bill-empty-update" + await _make_project(nc_mcp, pid) + alice = await _make_member(nc_mcp, pid, "Alice") + bob = await _make_member(nc_mcp, pid, "Bob") + bill_id = await _make_bill(nc_mcp, pid, "X", 1.0, alice["id"], [alice["id"], bob["id"]]) + with pytest.raises(ToolError, match="non-empty"): + await nc_mcp.call("update_cospend_bill", project_id=pid, bill_id=bill_id, payed_for=[]) + + +class TestToolRegistration: + """Confirm all expected tools are registered (catches accidental drops).""" + + EXPECTED_TOOLS: ClassVar[set[str]] = { + "list_cospend_projects", + "get_cospend_project", + "create_cospend_project", + "update_cospend_project", + "delete_cospend_project", + "get_cospend_project_statistics", + "get_cospend_project_settlement", + "list_cospend_members", + "create_cospend_member", + "update_cospend_member", + "delete_cospend_member", + "list_cospend_bills", + "get_cospend_bill", + "create_cospend_bill", + "update_cospend_bill", + "delete_cospend_bill", + } + + def test_all_cospend_tools_registered(self, nc_mcp: McpTestHelper) -> None: + registered = set(nc_mcp.tool_names()) + missing = self.EXPECTED_TOOLS - registered + assert not missing, f"missing cospend tools: {sorted(missing)}" diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py index 9ceeca4..27a78fd 100644 --- a/tests/integration/test_server.py +++ b/tests/integration/test_server.py @@ -24,6 +24,9 @@ "create_collective_page", "create_contact", "create_conversation", + "create_cospend_bill", + "create_cospend_member", + "create_cospend_project", "create_directory", "create_event", "create_form", @@ -42,6 +45,9 @@ "delete_collective_page", "delete_comment", "delete_contact", + "delete_cospend_bill", + "delete_cospend_member", + "delete_cospend_project", "delete_event", "delete_file", "delete_form", @@ -70,6 +76,10 @@ "get_contact", "get_contacts", "get_conversation", + "get_cospend_bill", + "get_cospend_project", + "get_cospend_project_settlement", + "get_cospend_project_statistics", "get_current_user", "get_event", "get_events", @@ -100,6 +110,9 @@ "list_collectives", "list_comments", "list_conversations", + "list_cospend_bills", + "list_cospend_members", + "list_cospend_projects", "list_directory", "list_forms", "list_mail_accounts", @@ -140,6 +153,9 @@ "update_circle_member_level", "update_circle_name", "update_contact", + "update_cospend_bill", + "update_cospend_member", + "update_cospend_project", "update_event", "update_form", "update_form_share",