- Extend Document model with reasoning_type, confidence, reasoning_steps, model_source, tiptap_content fields
- Add new endpoints:
- GET /documents/{id}/reasoning - Get reasoning metadata
- PATCH /documents/{id}/reasoning - Update reasoning metadata
- GET /documents/{id}/reasoning-panel - Get reasoning panel data for UI
- POST /documents/{id}/reasoning-steps - Add reasoning step
- DELETE /documents/{id}/reasoning-steps/{step} - Delete reasoning step
- GET /documents/{id}/content?format=tiptap|markdown - Get content in TipTap or Markdown
- PUT /documents/{id}/content - Update content (supports both TipTap JSON and Markdown)
- Add TipTap to Markdown conversion
- Update database schema with new columns
- Add comprehensive tests for all new endpoints
- All 37 tests passing
495 lines
17 KiB
Python
495 lines
17 KiB
Python
import pytest
|
|
|
|
|
|
async def setup_project_and_get_token(client):
|
|
await client.post("/api/v1/auth/register", json={"username": "docuser", "password": "pass123"})
|
|
login = await client.post("/api/v1/auth/login", json={"username": "docuser", "password": "pass123"})
|
|
token = login.json()["access_token"]
|
|
proj_resp = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Doc Test Project"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
return token, proj_resp.json()["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_document(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
response = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "My Document", "content": "# Hello\n\nWorld"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["title"] == "My Document"
|
|
assert data["content"] == "# Hello\n\nWorld"
|
|
assert data["project_id"] == proj_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_document(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
create_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Get Doc Test"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = create_resp.json()["id"]
|
|
response = await client.get(
|
|
f"/api/v1/documents/{doc_id}",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["title"] == "Get Doc Test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_document_content(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
create_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Original Title", "content": "Original Content"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = create_resp.json()["id"]
|
|
response = await client.put(
|
|
f"/api/v1/documents/{doc_id}/content",
|
|
json={"content": "Updated Content"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["content"] == "Updated Content"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_document(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
create_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "To Delete"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = create_resp.json()["id"]
|
|
del_resp = await client.delete(
|
|
f"/api/v1/documents/{doc_id}",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert del_resp.status_code == 204
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_tag(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Tagged Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
tag_resp = await client.post(
|
|
"/api/v1/tags",
|
|
json={"name": "important", "color": "#ff0000"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
tag_id = tag_resp.json()["id"]
|
|
assign_resp = await client.post(
|
|
f"/api/v1/documents/{doc_id}/tags",
|
|
json={"tag_ids": [tag_id]},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert assign_resp.status_code == 204
|
|
|
|
get_resp = await client.get(
|
|
f"/api/v1/documents/{doc_id}",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert len(get_resp.json()["tags"]) == 1
|
|
assert get_resp.json()["tags"][0]["name"] == "important"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_tag(client):
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Tagged Doc 2"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
tag_resp = await client.post(
|
|
"/api/v1/tags",
|
|
json={"name": "temp", "color": "#00ff00"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
tag_id = tag_resp.json()["id"]
|
|
await client.post(
|
|
f"/api/v1/documents/{doc_id}/tags",
|
|
json={"tag_ids": [tag_id]},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
remove_resp = await client.delete(
|
|
f"/api/v1/documents/{doc_id}/tags/{tag_id}",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert remove_resp.status_code == 204
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 2: Reasoning Endpoint Tests
|
|
# =============================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_document_reasoning_empty(client):
|
|
"""Test getting reasoning from a document with no reasoning set."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "No Reasoning Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
response = await client.get(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["reasoning_type"] is None
|
|
assert data["confidence"] is None
|
|
assert data["reasoning_steps"] == []
|
|
assert data["model_source"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_document_reasoning(client):
|
|
"""Test updating reasoning metadata."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Reasoning Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Update reasoning
|
|
response = await client.patch(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
json={
|
|
"reasoning_type": "chain",
|
|
"confidence": 0.87,
|
|
"model_source": "claude-3",
|
|
"reasoning_steps": [
|
|
{"step": 1, "thought": "First thought", "conclusion": "First conclusion"}
|
|
]
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["reasoning_type"] == "chain"
|
|
assert data["confidence"] == 0.87
|
|
assert data["model_source"] == "claude-3"
|
|
assert len(data["reasoning_steps"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_reasoning_invalid_confidence(client):
|
|
"""Test that invalid confidence values are rejected."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Reasoning Doc 2"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Invalid confidence > 1.0 - Pydantic validation returns 422
|
|
response = await client.patch(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
json={"confidence": 1.5},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
# Invalid confidence < 0.0 - Pydantic validation returns 422
|
|
response = await client.patch(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
json={"confidence": -0.1},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_reasoning_panel(client):
|
|
"""Test getting reasoning panel data."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Panel Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Initially no reasoning
|
|
panel_resp = await client.get(
|
|
f"/api/v1/documents/{doc_id}/reasoning-panel",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert panel_resp.status_code == 200
|
|
panel = panel_resp.json()
|
|
assert panel["has_reasoning"] is False
|
|
assert panel["reasoning"] is None
|
|
assert panel["editable"] is True
|
|
|
|
# Add reasoning
|
|
await client.patch(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
json={"reasoning_type": "idea", "confidence": 0.95},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
|
|
# Now has reasoning
|
|
panel_resp = await client.get(
|
|
f"/api/v1/documents/{doc_id}/reasoning-panel",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
panel = panel_resp.json()
|
|
assert panel["has_reasoning"] is True
|
|
assert panel["reasoning"]["reasoning_type"] == "idea"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_reasoning_step(client):
|
|
"""Test adding a reasoning step."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Steps Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Add first step
|
|
step1_resp = await client.post(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps",
|
|
json={"thought": "First step thought", "conclusion": "First conclusion"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert step1_resp.status_code == 201
|
|
step1 = step1_resp.json()
|
|
assert step1["step"] == 1
|
|
assert step1["thought"] == "First step thought"
|
|
|
|
# Add second step
|
|
step2_resp = await client.post(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps",
|
|
json={"thought": "Second step thought"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert step2_resp.status_code == 201
|
|
step2 = step2_resp.json()
|
|
assert step2["step"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_reasoning_step(client):
|
|
"""Test deleting a reasoning step."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Delete Step Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Add steps
|
|
await client.post(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps",
|
|
json={"thought": "Step 1"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
await client.post(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps",
|
|
json={"thought": "Step 2"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
|
|
# Delete step 1
|
|
del_resp = await client.delete(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps/1",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert del_resp.status_code == 204
|
|
|
|
# Verify step 2 still exists
|
|
reasoning_resp = await client.get(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
steps = reasoning_resp.json()["reasoning_steps"]
|
|
assert len(steps) == 1
|
|
assert steps[0]["step"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_nonexistent_step(client):
|
|
"""Test deleting a step that doesn't exist."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "No Steps Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
del_resp = await client.delete(
|
|
f"/api/v1/documents/{doc_id}/reasoning-steps/999",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert del_resp.status_code == 404
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 2: TipTap Content Endpoint Tests
|
|
# =============================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_content_tiptap_format(client):
|
|
"""Test getting content in TipTap format."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "TipTap Doc", "content": "Hello world"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
response = await client.get(
|
|
f"/api/v1/documents/{doc_id}/content",
|
|
params={"format": "tiptap"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["format"] == "tiptap"
|
|
assert "content" in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_content_markdown_format(client):
|
|
"""Test getting content in Markdown format."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Markdown Doc", "content": "# Hello\n\nWorld"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
response = await client.get(
|
|
f"/api/v1/documents/{doc_id}/content",
|
|
params={"format": "markdown"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["format"] == "markdown"
|
|
assert "# Hello" in data["content"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_content_tiptap(client):
|
|
"""Test updating content with TipTap JSON."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Update TipTap Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
tiptap_content = {
|
|
"type": "doc",
|
|
"content": [
|
|
{
|
|
"type": "heading",
|
|
"attrs": {"level": 1},
|
|
"content": [{"type": "text", "text": "My Title"}]
|
|
},
|
|
{
|
|
"type": "paragraph",
|
|
"content": [{"type": "text", "text": "Hello world"}]
|
|
}
|
|
]
|
|
}
|
|
|
|
response = await client.put(
|
|
f"/api/v1/documents/{doc_id}/content",
|
|
json={"content": tiptap_content, "format": "tiptap"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
print(f"DEBUG: status={response.status_code} body={response.text}")
|
|
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
|
|
data = response.json()
|
|
assert data["tiptap_content"] is not None
|
|
|
|
# Verify markdown content was also updated
|
|
assert "My Title" in data["content"] or "# My Title" in data["content"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_content_markdown(client):
|
|
"""Test updating content with Markdown."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Update MD Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
response = await client.put(
|
|
f"/api/v1/documents/{doc_id}/content",
|
|
json={"content": "# New Title\n\nNew content here", "format": "markdown"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["content"] == "# New Title\n\nNew content here"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_document_response_includes_reasoning_fields(client):
|
|
"""Test that DocumentResponse includes new reasoning fields."""
|
|
token, proj_id = await setup_project_and_get_token(client)
|
|
doc_resp = await client.post(
|
|
f"/api/v1/projects/{proj_id}/documents",
|
|
json={"title": "Full Doc"},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
doc_id = doc_resp.json()["id"]
|
|
|
|
# Add reasoning
|
|
await client.patch(
|
|
f"/api/v1/documents/{doc_id}/reasoning",
|
|
json={"reasoning_type": "reflection", "confidence": 0.99},
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
|
|
# Get document and verify fields
|
|
get_resp = await client.get(
|
|
f"/api/v1/documents/{doc_id}",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
data = get_resp.json()
|
|
assert "reasoning_type" in data
|
|
assert "confidence" in data
|
|
assert "reasoning_steps" in data
|
|
assert "model_source" in data
|
|
assert "tiptap_content" in data
|
|
assert data["reasoning_type"] == "reflection"
|
|
assert data["confidence"] == 0.99
|