vikunja-python 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. vikunja_python-0.1.0/PKG-INFO +16 -0
  2. vikunja_python-0.1.0/README.md +85 -0
  3. vikunja_python-0.1.0/pyproject.toml +40 -0
  4. vikunja_python-0.1.0/setup.cfg +4 -0
  5. vikunja_python-0.1.0/tests/test_auth_integration.py +40 -0
  6. vikunja_python-0.1.0/tests/test_cli_core.py +60 -0
  7. vikunja_python-0.1.0/tests/test_container.py +26 -0
  8. vikunja_python-0.1.0/tests/test_phase6_integration.py +122 -0
  9. vikunja_python-0.1.0/tests/test_task_hierarchy_integration.py +165 -0
  10. vikunja_python-0.1.0/tests/test_task_models_unit.py +73 -0
  11. vikunja_python-0.1.0/vikunja_python/__init__.py +0 -0
  12. vikunja_python-0.1.0/vikunja_python/cli/__init__.py +0 -0
  13. vikunja_python-0.1.0/vikunja_python/cli/main.py +264 -0
  14. vikunja_python-0.1.0/vikunja_python/core/__init__.py +0 -0
  15. vikunja_python-0.1.0/vikunja_python/core/client.py +106 -0
  16. vikunja_python-0.1.0/vikunja_python/core/models/__init__.py +377 -0
  17. vikunja_python-0.1.0/vikunja_python/core/models/api_token.py +131 -0
  18. vikunja_python-0.1.0/vikunja_python/core/models/auth.py +34 -0
  19. vikunja_python-0.1.0/vikunja_python/core/models/base.py +193 -0
  20. vikunja_python-0.1.0/vikunja_python/core/models/bulk_assignees.py +98 -0
  21. vikunja_python-0.1.0/vikunja_python/core/models/filter.py +134 -0
  22. vikunja_python-0.1.0/vikunja_python/core/models/label.py +230 -0
  23. vikunja_python-0.1.0/vikunja_python/core/models/link_sharing.py +138 -0
  24. vikunja_python-0.1.0/vikunja_python/core/models/migration.py +404 -0
  25. vikunja_python-0.1.0/vikunja_python/core/models/phase6_medium.py +74 -0
  26. vikunja_python-0.1.0/vikunja_python/core/models/project.py +217 -0
  27. vikunja_python-0.1.0/vikunja_python/core/models/relation.py +199 -0
  28. vikunja_python-0.1.0/vikunja_python/core/models/task.py +261 -0
  29. vikunja_python-0.1.0/vikunja_python/core/models/task_expansion.py +252 -0
  30. vikunja_python-0.1.0/vikunja_python/core/models/user.py +838 -0
  31. vikunja_python-0.1.0/vikunja_python/core/models/webhook.py +270 -0
  32. vikunja_python-0.1.0/vikunja_python/mcp/__init__.py +0 -0
  33. vikunja_python-0.1.0/vikunja_python/mcp/server.py +678 -0
  34. vikunja_python-0.1.0/vikunja_python.egg-info/PKG-INFO +16 -0
  35. vikunja_python-0.1.0/vikunja_python.egg-info/SOURCES.txt +37 -0
  36. vikunja_python-0.1.0/vikunja_python.egg-info/dependency_links.txt +1 -0
  37. vikunja_python-0.1.0/vikunja_python.egg-info/entry_points.txt +3 -0
  38. vikunja_python-0.1.0/vikunja_python.egg-info/requires.txt +10 -0
  39. vikunja_python-0.1.0/vikunja_python.egg-info/top_level.txt +1 -0
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: vikunja-python
3
+ Version: 0.1.0
4
+ Summary: API wrapper, CLI tool, and MCP server for Vikunja
5
+ Author-email: Your Name <your.email@example.com>
6
+ License-Expression: WTFPL
7
+ Requires-Python: >=3.13.12
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic>=2.0.0
10
+ Requires-Dist: dateparser>=1.2.0
11
+ Requires-Dist: typer>=0.12.0
12
+ Requires-Dist: fastmcp>=0.1.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8.0.0; extra == "test"
15
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
16
+ Requires-Dist: testcontainers[mysql]>=4.0.0; extra == "test"
@@ -0,0 +1,85 @@
1
+ # Vikunja Python
2
+
3
+ An open-source (WTFPL) API wrapper, rich CLI tool, and Model Context Protocol (MCP) server for the [Vikunja](https://vikunja.io/) task management system.
4
+
5
+ Built for **Python 3.13** using `httpx`, `pydantic v2`, and `fastmcp`.
6
+
7
+ ## 🏗 Architecture
8
+
9
+ The project is structured into three distinct layers to ensure modularity and clean dependency management:
10
+
11
+ - **`/core`**: The engine. Handles Pydantic models, HTTP client logic, and error handling. (Minimal dependencies).
12
+ - **`/cli`**: A high-visual-impact command-line interface built with `typer` and `rich`.
13
+ - **`/mcp`**: A Model Context Protocol server designed for agentic workflows, optimized for smaller LLMs (like Gemma).
14
+
15
+ ## 🚀 Getting Started
16
+
17
+ ### Installation
18
+ This project uses `uv` for modern Python package management.
19
+
20
+ ```bash
21
+ uv sync --all-extras
22
+ ```
23
+
24
+ ### Environment Variables
25
+ Configure your Vikunja instance in a `.env` file or your shell:
26
+
27
+ ```bash
28
+ VIKUNJA_URL="https://your-vikunja-instance.com/api/v1"
29
+ VIKUNJA_API_TOKEN="your_api_token_here" # Recommended for MCP
30
+ VIKUNJA_JWT_TOKEN="your_jwt_here" # Required for Buckets/Reactions in CLI
31
+ VIKUNJA_DEBUG="true" # Set to true or 1 to enable verbose stderr logging
32
+ ```
33
+
34
+ ## 🛠 Features & Tooling
35
+
36
+ ### CLI (`vikunja`)
37
+ The CLI provides a rich, human-friendly interface for managing your workspace.
38
+ - **Tasks & Projects**: Full CRUD support with formatted tables.
39
+ - **Buckets (Kanban)**: List and manage columns (Requires JWT).
40
+ - **Reactions**: Add emojis to tasks (Requires JWT).
41
+ - **Labels**: List and categorize tasks.
42
+ - **Auth**: Built-in `login` command to generate JWTs.
43
+
44
+ ```bash
45
+ uv run vikunja list-tasks
46
+ uv run vikunja login
47
+ ```
48
+
49
+ ### MCP Server (`vikunja-mcp`)
50
+ A powerful server that gives LLMs "hands" to manage your Vikunja instance. Optimized for reliability and low turn-count.
51
+ - **Global Search**: Find tasks across all projects in one turn.
52
+ - **Bulk Scaffolding**: Create projects and multiple tasks in a single operation.
53
+ - **Smart Dates**: Natural language date parsing (e.g., "remind me next Friday").
54
+ - **Task Memory**: Full support for reading and writing task comments.
55
+ - **Explicit Hierarchy**: Clear directional tools for subtasks and dependencies.
56
+
57
+ ```bash
58
+ # Start via STDIO (for Claude Desktop)
59
+ uv run vikunja-mcp
60
+
61
+ # Start via SSE (HTTP)
62
+ uv run vikunja-mcp dev --transport sse
63
+ ```
64
+
65
+ ## 🔒 Security & Auth Policy
66
+
67
+ - **MCP Protocol**: The MCP server is strictly limited to **API Key Authentication**. We do not support user/password login via MCP to ensure secure, scoped agentic access.
68
+ - **UI-Only Features**: Features like Buckets (Kanban columns) and Reactions currently require a JWT (UI Session). These are supported in the CLI but are excluded from the MCP server to maintain protocol security.
69
+
70
+ ## 🧪 Testing
71
+
72
+ We use `testcontainers-python` to run integration tests against a live, ephemeral Vikunja instance. No mocks, just real API verification.
73
+
74
+ ```bash
75
+ uv run pytest
76
+ ```
77
+
78
+ ## 🗺 Future Roadmap
79
+ - [ ] **Backgrounds**: Support for Unsplash project backgrounds.
80
+ - [ ] **Attachments**: File upload/download support.
81
+ - [ ] **Webhooks**: Core models for receiving Vikunja events.
82
+ - [ ] **Bulk Labels**: Tools for mass-tagging tasks.
83
+
84
+ ## 📜 License
85
+ WTFPL - Do What the Fuck You Want to Public License.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "vikunja-python"
3
+ version = "0.1.0"
4
+ description = "API wrapper, CLI tool, and MCP server for Vikunja"
5
+ authors = [
6
+ { name = "Your Name", email = "your.email@example.com" }
7
+ ]
8
+ dependencies = [
9
+ "httpx>=0.27.0",
10
+ "pydantic>=2.0.0",
11
+ "dateparser>=1.2.0",
12
+ "typer>=0.12.0",
13
+ "fastmcp>=0.1.0",
14
+ ]
15
+ requires-python = ">=3.13.12"
16
+ license = "WTFPL"
17
+
18
+ [project.optional-dependencies]
19
+ test = [
20
+ "pytest>=8.0.0",
21
+ "pytest-asyncio>=0.23.0",
22
+ "testcontainers[mysql]>=4.0.0",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["setuptools>=61.0"]
27
+ build-backend = "setuptools.build_meta"
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ["vikunja_python*"]
31
+ exclude = ["custom_components*", "tests*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
36
+
37
+ [project.scripts]
38
+ vikunja = "vikunja_python.cli.main:app"
39
+ vikunja-mcp = "vikunja_python.mcp.server:main"
40
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ import httpx
2
+ import pytest
3
+ from vikunja_python.core.models.base import Token
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_full_auth_lifecycle(vikunja_auth):
7
+ """Test full authentication lifecycle using the ephemeral test container."""
8
+ VIKUNJA_URL = vikunja_auth["base_url"]
9
+ USERNAME = vikunja_auth["username"]
10
+ PASSWORD = vikunja_auth["password"]
11
+
12
+ async with httpx.AsyncClient(follow_redirects=True) as client:
13
+ print("\n[1] Attempting Login...")
14
+ login_payload = {
15
+ "long_token": True,
16
+ "password": PASSWORD,
17
+ "username": USERNAME
18
+ }
19
+ login_resp = await client.post(f"{VIKUNJA_URL}/login", json=login_payload)
20
+
21
+ assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
22
+
23
+ # Validate Token model
24
+ token_data = login_resp.json()
25
+ token_obj = Token(**token_data)
26
+ assert token_obj.token is not None
27
+ print("✅ Login successful & Token validated")
28
+
29
+ print("\n[2] Attempting Token Refresh...")
30
+ refresh_resp = await client.get(f"{VIKUNJA_URL}/user/token/refresh", headers={"Authorization": f"Bearer {token_obj.token}"})
31
+ if refresh_resp.status_code == 200:
32
+ print("✅ Token refreshed successfully via GET.")
33
+ new_token_data = refresh_resp.json()
34
+ assert "token" in new_token_data
35
+ elif refresh_resp.status_code == 404:
36
+ print("⚠️ Refresh endpoint not found or incorrect method.")
37
+
38
+ print("\n[3] Attempting Logout...")
39
+ logout_resp = await client.post(f"{VIKUNJA_URL}/logout", headers={"Authorization": f"Bearer {token_obj.token}"})
40
+ assert logout_resp.status_code in [200, 204, 404]
@@ -0,0 +1,60 @@
1
+ import asyncio
2
+ import os
3
+ import pytest
4
+ from vikunja_python.core.client import VikunjaClient
5
+ from vikunja_python.core.models.task import Task
6
+ from vikunja_python.core.models.project import Project
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_cli_integration_basic(vikunja_auth):
10
+ """Verify that the CLI-facing core client works with the testcontainer."""
11
+ base_url = vikunja_auth["base_url"]
12
+ token = vikunja_auth["token"]
13
+
14
+ async with VikunjaClient(base_url, token) as client:
15
+ # 1. Create a project
16
+ proj_data = await client.request("PUT", "/projects", json={"title": "CLI Project"})
17
+ project = Project(**proj_data)
18
+ assert project.title == "CLI Project"
19
+
20
+ # 2. Create a task via client (simulating CLI action)
21
+ task_data = await client.request("PUT", f"/projects/{project.id}/tasks", json={"title": "CLI Task"})
22
+ task = Task(**task_data)
23
+ assert task.title == "CLI Task"
24
+ assert task.project_id == project.id
25
+
26
+ # 4. Update task
27
+ update_data = await client.request("POST", f"/tasks/{task.id}", json={"done": True})
28
+ assert update_data["done"] is True
29
+
30
+ # 5. Create another task for relationship
31
+ task2_data = await client.request("PUT", f"/projects/{project.id}/tasks", json={"title": "Task 2"})
32
+ task2 = Task(**task2_data)
33
+
34
+ # 6. Create relationship
35
+ rel_data = await client.request("PUT", f"/tasks/{task.id}/relations", json={
36
+ "other_task_id": task2.id,
37
+ "relation_kind": "subtask"
38
+ })
39
+ assert rel_data["other_task_id"] == task2.id
40
+
41
+ # 7. Delete tasks
42
+ await client.request("DELETE", f"/tasks/{task.id}")
43
+ await client.request("DELETE", f"/tasks/{task2.id}")
44
+
45
+ # Verify deletion
46
+ check = await client.request("GET", f"/tasks/{task.id}")
47
+ assert check["status_code"] == 404
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_error_handling_structured(vikunja_auth):
51
+ """Verify that the client returns structured error data instead of crashing."""
52
+ base_url = vikunja_auth["base_url"]
53
+ token = vikunja_auth["token"]
54
+
55
+ async with VikunjaClient(base_url, token) as client:
56
+ # Request non-existent project
57
+ data = await client.request("GET", "/projects/999999")
58
+ assert "error" in data
59
+ assert data["status_code"] == 404
60
+ assert "details" in data
@@ -0,0 +1,26 @@
1
+ import pytest
2
+ import httpx
3
+ from vikunja_python.core.models.user import User
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_container_running(vikunja_server):
7
+ """Test that the container is reachable via HTTP."""
8
+ async with httpx.AsyncClient() as client:
9
+ resp = await client.get(f"{vikunja_server}/info")
10
+ assert resp.status_code == 200
11
+ data = resp.json()
12
+ assert "version" in data
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_auth_works(vikunja_auth):
16
+ """Test that authentication provides a valid token."""
17
+ assert vikunja_auth["token"] is not None
18
+ assert vikunja_auth["username"] is not None
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_authenticated_client(async_client):
22
+ """Test that the authenticated client can fetch the current user."""
23
+ resp = await async_client.get("/user")
24
+ assert resp.status_code == 200
25
+ user = User(**resp.json())
26
+ assert user.id > 0
@@ -0,0 +1,122 @@
1
+ import pytest
2
+ from vikunja_python.core.models.phase6_medium import (
3
+ Bucket, BucketCreateRequest, BucketUpdateRequest, BucketMoveRequest,
4
+ ReactionKind, Reaction, ReactionMapEntry, ReactionCreateRequest,
5
+ SubscriptionType, Subscription, SubscriptionCreateRequest
6
+ )
7
+ from vikunja_python.core.models.project import Project
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_project_lifecycle(async_client):
11
+ """CRUD lifecycle for Project to validate model"""
12
+ # 1. Create
13
+ project_data = {"title": "Hermes Test Project"}
14
+ resp = await async_client.put("/projects", json=project_data)
15
+ assert resp.status_code in [200, 201]
16
+
17
+ new_project_json = resp.json()
18
+ project_id = new_project_json['id']
19
+
20
+ project_obj = Project(**new_project_json)
21
+ assert project_obj.id == project_id
22
+
23
+ # 2. Get
24
+ resp = await async_client.get(f"/projects/{project_id}")
25
+ assert resp.status_code == 200
26
+ Project(**resp.json())
27
+
28
+ # 3. Delete
29
+ resp = await async_client.delete(f"/projects/{project_id}")
30
+ assert resp.status_code in [200, 204]
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_bucket_lifecycle(async_client):
34
+ """CRUD lifecycle for Bucket"""
35
+ # 1. Create Project for bucket
36
+ project_data = {"title": "Project for Bucket Test"}
37
+ resp = await async_client.put("/projects", json=project_data)
38
+ assert resp.status_code in [200, 201]
39
+ project_id = resp.json()['id']
40
+
41
+ # Vikunja buckets need a view
42
+ # A view is automatically created for a project. Let's get the views.
43
+ resp = await async_client.get(f"/projects/{project_id}/views")
44
+ assert resp.status_code == 200
45
+ views = resp.json()
46
+ assert len(views) > 0
47
+ view_id = views[0]['id']
48
+
49
+ # 2. Create Bucket
50
+ bucket_payload = {"title": "Hermes Test Column"}
51
+ resp = await async_client.put(f"/projects/{project_id}/views/{view_id}/buckets", json=bucket_payload)
52
+ assert resp.status_code in [200, 201]
53
+
54
+ new_bucket_json = resp.json()
55
+ bucket_id = new_bucket_json['id']
56
+ Bucket(**new_bucket_json)
57
+
58
+ # Create a second bucket so we can delete the first one without a 412 precondition error
59
+ await async_client.put(f"/projects/{project_id}/views/{view_id}/buckets", json={"title": "Keep Column"})
60
+
61
+ # 3. Update
62
+ update_payload = {"title": "Hermes Updated Column"}
63
+ resp = await async_client.post(f"/projects/{project_id}/views/{view_id}/buckets/{bucket_id}", json=update_payload)
64
+ assert resp.status_code == 200
65
+ Bucket(**resp.json())
66
+
67
+ # 4. Delete
68
+ resp = await async_client.delete(f"/projects/{project_id}/views/{view_id}/buckets/{bucket_id}")
69
+ assert resp.status_code in [200, 204]
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_reaction_lifecycle(async_client):
73
+ """CRUD lifecycle for Reaction"""
74
+ # 1. Create Project and Task
75
+ project_data = {"title": "Project for Reaction Test"}
76
+ resp = await async_client.put("/projects", json=project_data)
77
+ project_id = resp.json()['id']
78
+
79
+ task_payload = {"title": "Task for Reaction", "project_id": project_id}
80
+ resp = await async_client.put(f"/projects/{project_id}/tasks", json=task_payload)
81
+ assert resp.status_code in [200, 201], f"Failed to create task: {resp.text}"
82
+ task_id = resp.json()['id']
83
+
84
+ # 2. Create Reaction
85
+ reaction_payload = {"value": "❤️"}
86
+ # Note: Vikunja spec says PUT /tasks/{id}/reactions
87
+ resp = await async_client.put(f"/tasks/{task_id}/reactions", json=reaction_payload)
88
+
89
+ assert resp.status_code in [200, 201], f"Reaction failed: {resp.text}"
90
+ new_reaction_json = resp.json()
91
+ Reaction(**new_reaction_json)
92
+
93
+ # 3. Delete Reaction
94
+ resp = await async_client.post(f"/tasks/{task_id}/reactions/delete", json={"value": "❤️"})
95
+ assert resp.status_code in [200, 204]
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_subscription_lifecycle(async_client):
99
+ """CRUD lifecycle for Subscription"""
100
+ # 1. Create Project and Task
101
+ project_data = {"title": "Project for Sub Test"}
102
+ resp = await async_client.put("/projects", json=project_data)
103
+ project_id = resp.json()['id']
104
+
105
+ task_payload = {"title": "Task for Sub Test", "project_id": project_id}
106
+ resp = await async_client.put(f"/projects/{project_id}/tasks", json=task_payload)
107
+ task_id = resp.json()['id']
108
+
109
+ # 2. Create Subscription
110
+ # The spec actually says PUT /subscriptions/task/{entityID}
111
+ resp = await async_client.put(f"/subscriptions/task/{task_id}")
112
+
113
+ if resp.status_code == 200:
114
+ new_sub_json = resp.json()
115
+ Subscription(**new_sub_json)
116
+ else:
117
+ # Some versions just return 204 on PUT
118
+ assert resp.status_code in [200, 201, 204], f"Subscription failed: {resp.text}"
119
+
120
+ # 3. Delete Subscription
121
+ resp = await async_client.delete(f"/subscriptions/task/{task_id}")
122
+ assert resp.status_code in [200, 204]
@@ -0,0 +1,165 @@
1
+ import pytest
2
+ from vikunja_python.core.models.task import Task
3
+ from vikunja_python.core.models.label import Label, LabelCreateRequest
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_task_hierarchy_and_labels_integration(async_client):
7
+ """
8
+ Mandatory integration test for Phase 6 enhancements:
9
+ 1. Label creation
10
+ 2. Hierarchical task creation (subtasks)
11
+ 3. Detailed listing with expansion
12
+ """
13
+ # 1. Create a Label
14
+ label_payload = {"title": "IntegrationTest", "hex_color": "#ff00ff"}
15
+ resp = await async_client.put("/labels", json=label_payload)
16
+ assert resp.status_code in [200, 201]
17
+ label_id = resp.json()["id"]
18
+
19
+ # 2. Create a Project
20
+ proj_resp = await async_client.put("/projects", json={"title": "Hierarchy Project"})
21
+ assert proj_resp.status_code in [200, 201]
22
+ project_id = proj_resp.json()["id"]
23
+
24
+ # 3. Create Parent Task
25
+ parent_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Parent Task"})
26
+ assert parent_resp.status_code in [200, 201]
27
+ parent_id = parent_resp.json()["id"]
28
+
29
+ # 4. Create Subtask using the relation endpoint
30
+ sub_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Child Task"})
31
+ assert sub_resp.status_code in [200, 201]
32
+ child_id = sub_resp.json()["id"]
33
+
34
+ # Create the subtask relationship
35
+ rel_payload = {"other_task_id": child_id, "relation_kind": "subtask"}
36
+ rel_resp = await async_client.put(f"/tasks/{parent_id}/relations", json=rel_payload)
37
+ assert rel_resp.status_code in [200, 201]
38
+
39
+ # 5. Add label to Parent
40
+ await async_client.put(f"/tasks/{parent_id}/labels", json={"label_id": label_id})
41
+
42
+ # 6. Test list_tasks with expansion (The core of the enhancement)
43
+ # GET /projects/{id}/tasks?expand=subtasks
44
+ list_resp = await async_client.get(f"/projects/{project_id}/tasks", params={"expand": ["subtasks"]})
45
+ assert list_resp.status_code == 200
46
+ tasks_data = list_resp.json()
47
+
48
+ # Use the Task model to parse the response
49
+ tasks = [Task(**item) for item in tasks_data]
50
+ parent_task = next((t for t in tasks if t.id == parent_id), None)
51
+
52
+ assert parent_task is not None
53
+ assert len(parent_task.subtasks) == 1
54
+ assert parent_task.subtasks[0].id == child_id
55
+ assert parent_task.subtasks[0].title == "Child Task"
56
+
57
+ # 7. Verify Labels are present (v2.3.0 returns them by default)
58
+ assert len(parent_task.labels) == 1
59
+ assert parent_task.labels[0].id == label_id
60
+ assert parent_task.labels[0].hex_color == "#ff00ff"
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_pagination_integration(async_client):
64
+ """Verify that pagination parameters are respected."""
65
+ # Create a project and 3 tasks
66
+ proj_resp = await async_client.put("/projects", json={"title": "Pagination Project"})
67
+ project_id = proj_resp.json()["id"]
68
+
69
+ for i in range(3):
70
+ await async_client.put(f"/projects/{project_id}/tasks", json={"title": f"Task {i}"})
71
+
72
+ # List with per_page=2
73
+ list_resp = await async_client.get("/tasks", params={"per_page": 2, "filter": f"project_id = {project_id}"})
74
+ assert list_resp.status_code == 200
75
+ assert len(list_resp.json()) == 2
76
+
77
+ # Check headers for pagination info (as defined in base.py/task.py)
78
+ assert "x-pagination-total-pages" in list_resp.headers
79
+ assert int(list_resp.headers["x-pagination-total-pages"]) >= 2
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_expand_parameter_sanitization(async_client, vikunja_auth):
83
+ """
84
+ Verify that the MCP list_tasks tool correctly sanitizes the expand parameter
85
+ to prevent 412 Precondition Failed errors from the Vikunja API.
86
+ """
87
+ # 1. Create a Project
88
+ proj_resp = await async_client.put("/projects", json={"title": "Expand Test Project"})
89
+ assert proj_resp.status_code in [200, 201]
90
+ project_id = proj_resp.json()["id"]
91
+
92
+ # 2. Call the raw API with an invalid expand parameter to prove it fails (412)
93
+ raw_resp = await async_client.get("/tasks", params={"expand": ["labels", "invalid_field"]})
94
+ assert raw_resp.status_code == 412
95
+
96
+ # 3. Import and call the MCP tool function directly to test its sanitization logic
97
+ from vikunja_python.mcp.server import list_tasks
98
+ import os
99
+
100
+ # Set env vars temporarily for the tool's get_client() call
101
+ os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
102
+ os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
103
+
104
+ # Call tool with invalid 'labels' and valid 'subtasks'
105
+ # The tool should filter out 'labels' and send only 'subtasks', returning 200 OK.
106
+ result = await list_tasks(project_id=project_id, expand=["labels", "subtasks"])
107
+
108
+ # If it failed with 412, result would start with "Error fetching tasks: 412" or similar
109
+ assert "Error fetching tasks" not in result
110
+ assert "No tasks found" in result or "ID:" in result # Should succeed (return empty string or tasks)
111
+
112
+ # 4. Call tool with ONLY invalid params
113
+ # The tool should filter all out and send no expand param at all, returning 200 OK.
114
+ result2 = await list_tasks(project_id=project_id, expand=["labels", "invalid_field"])
115
+ assert "Error fetching tasks" not in result2
116
+ assert "No tasks found" in result2 or "ID:" in result2
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_invalid_expand_sanitization_v2(async_client, vikunja_auth):
120
+ """
121
+ Explicitly verify that 'attachments', 'reminders', and 'assignees'
122
+ are sanitized out to prevent 412 errors.
123
+ """
124
+ from vikunja_python.mcp.server import list_tasks
125
+ import os
126
+
127
+ os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
128
+ os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
129
+
130
+ # These three specifically caused 412 errors in the wild
131
+ invalid_expands = ["attachments", "reminders", "assignees"]
132
+
133
+ # Call tool with these invalid expands
134
+ # If sanitization fails, the API will return 412 and the tool will return an error string.
135
+ result = await list_tasks(expand=invalid_expands)
136
+
137
+ assert "Error fetching tasks: 412" not in result
138
+ assert "Error fetching tasks" not in result
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_get_task_expansion_integration(async_client, vikunja_auth):
142
+ """Verify that get_task supports full expansion including attachments/reminders."""
143
+ from vikunja_python.mcp.server import get_task
144
+ import os
145
+
146
+ os.environ["VIKUNJA_URL"] = vikunja_auth["base_url"]
147
+ os.environ["VIKUNJA_API_TOKEN"] = vikunja_auth["token"]
148
+
149
+ # 1. Create a task with a description
150
+ proj_resp = await async_client.put("/projects", json={"title": "GetTask Project"})
151
+ project_id = proj_resp.json()["id"]
152
+ task_resp = await async_client.put(f"/projects/{project_id}/tasks", json={"title": "Detailed Task", "description": "Full details here"})
153
+ task_id = task_resp.json()["id"]
154
+
155
+ # 2. Test get_task with expansion
156
+ # Note: We can't easily upload attachments in this test without more setup,
157
+ # but we can verify the API call doesn't 412.
158
+ result = await get_task(task_id=task_id, expand=["attachments", "reminders", "assignees", "comments"])
159
+
160
+ assert "Error fetching task" not in result
161
+ assert "ID: " in result
162
+ assert "Full details here" in result
163
+ assert "Detailed Task" in result
164
+
165
+
@@ -0,0 +1,73 @@
1
+ import pytest
2
+ from vikunja_python.core.models.task import Task
3
+ from vikunja_python.core.models.base import Label
4
+
5
+ def test_task_model_with_subtasks():
6
+ """Verify that the Task model can handle nested subtasks."""
7
+ subtask_data = {
8
+ "id": 2,
9
+ "title": "Subtask 1",
10
+ "identifier": "#2",
11
+ "project_id": 1,
12
+ "done": False,
13
+ "created": "2026-05-05T12:00:00Z",
14
+ "updated": "2026-05-05T12:00:00Z"
15
+ }
16
+
17
+ task_data = {
18
+ "id": 1,
19
+ "title": "Parent Task",
20
+ "identifier": "#1",
21
+ "project_id": 1,
22
+ "done": False,
23
+ "created": "2026-05-05T12:00:00Z",
24
+ "updated": "2026-05-05T12:00:00Z",
25
+ "subtasks": [subtask_data]
26
+ }
27
+
28
+ task = Task(**task_data)
29
+ assert task.id == 1
30
+ assert len(task.subtasks) == 1
31
+ assert task.subtasks[0].id == 2
32
+ assert task.subtasks[0].title == "Subtask 1"
33
+
34
+ def test_task_model_with_labels():
35
+ """Verify that the Task model handles expanded labels."""
36
+ label_data = {
37
+ "id": 10,
38
+ "title": "Urgent",
39
+ "hex_color": "ff0000",
40
+ "created": "2026-05-05T12:00:00Z",
41
+ "updated": "2026-05-05T12:00:00Z"
42
+ }
43
+
44
+ task_data = {
45
+ "id": 1,
46
+ "title": "Task with labels",
47
+ "identifier": "#1",
48
+ "project_id": 1,
49
+ "done": False,
50
+ "created": "2026-05-05T12:00:00Z",
51
+ "updated": "2026-05-05T12:00:00Z",
52
+ "labels": [label_data]
53
+ }
54
+
55
+ task = Task(**task_data)
56
+ assert len(task.labels) == 1
57
+ assert task.labels[0].title == "Urgent"
58
+ assert task.labels[0].hex_color == "#ff0000"
59
+
60
+ def test_label_model_validation():
61
+ """Test label model validation (hex color format)."""
62
+ # Both models now normalize to include # prefix
63
+
64
+ from vikunja_python.core.models.base import Label as BaseLabel
65
+ l1 = BaseLabel(id=1, title="test", hex_color="ff0000", created="2026-05-05T12:00:00Z", updated="2026-05-05T12:00:00Z")
66
+ assert l1.hex_color == "#ff0000"
67
+
68
+ from vikunja_python.core.models.label import Label as FullLabel
69
+ l2 = FullLabel(id=1, title="test", hex_color="#ff0000")
70
+ assert l2.hex_color == "#ff0000"
71
+
72
+ with pytest.raises(ValueError):
73
+ FullLabel(id=1, title="test", hex_color="invalid")
File without changes
File without changes