Skip to content

Commit 414a7ec

Browse files
authored
Create test_files_api.py
1 parent 75cac99 commit 414a7ec

1 file changed

Lines changed: 182 additions & 0 deletions

File tree

‎tests/test_files_api.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Conversation file upload/list/delete through the HTTP API."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from pathlib import Path
7+
8+
import pytest
9+
from fastapi.testclient import TestClient
10+
11+
from app.core.config import get_settings
12+
13+
14+
def _setup(client: TestClient) -> tuple[dict, str]:
15+
tokens = client.post(
16+
"/auth/register", json={"email": "files@example.com", "password": "password-1"}
17+
).json()
18+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
19+
conversation_id = client.post(
20+
"/conversations", json={"title": "files"}, headers=headers
21+
).json()["id"]
22+
return headers, conversation_id
23+
24+
25+
class TestFileRoutes:
26+
def test_upload_list_delete(self, client: TestClient) -> None:
27+
headers, conversation_id = _setup(client)
28+
res = client.post(
29+
f"/conversations/{conversation_id}/files",
30+
headers=headers,
31+
files={"file": ("sales.xlsx", b"fake-xlsx-bytes", "application/octet-stream")},
32+
)
33+
assert res.status_code == 201, res.text
34+
body = res.json()
35+
assert body["filename"] == "sales.xlsx"
36+
assert body["size"] == len(b"fake-xlsx-bytes")
37+
38+
detail = client.get(f"/conversations/{conversation_id}", headers=headers).json()
39+
assert [f["filename"] for f in detail["files"]] == ["sales.xlsx"]
40+
41+
# the file must exist on disk inside the conversation workspace
42+
workspace = Path(get_settings().workspace_root) / conversation_id
43+
assert len(list(workspace.iterdir())) == 1
44+
45+
res = client.delete(f"/conversations/{conversation_id}/files/{body['id']}", headers=headers)
46+
assert res.status_code == 204
47+
detail = client.get(f"/conversations/{conversation_id}", headers=headers).json()
48+
assert detail["files"] == []
49+
50+
def test_upload_requires_auth(self, client: TestClient) -> None:
51+
conversation_id = "cnv_missing"
52+
res = client.post(
53+
f"/conversations/{conversation_id}/files",
54+
files={"file": ("a.xlsx", b"x", "application/octet-stream")},
55+
)
56+
assert res.status_code == 401
57+
58+
def test_upload_foreign_conversation_404(self, client: TestClient) -> None:
59+
headers, _ = _setup(client)
60+
res = client.post(
61+
"/conversations/cnv_nobody/files",
62+
headers=headers,
63+
files={"file": ("a.xlsx", b"x", "application/octet-stream")},
64+
)
65+
assert res.status_code == 404
66+
67+
def test_upload_sanitizes_filename(self, client: TestClient) -> None:
68+
headers, conversation_id = _setup(client)
69+
res = client.post(
70+
f"/conversations/{conversation_id}/files",
71+
headers=headers,
72+
files={"file": ("../../etc/passwd.xlsx", b"x", "application/octet-stream")},
73+
)
74+
assert res.status_code == 201, res.text
75+
assert res.json()["filename"] == "passwd.xlsx"
76+
77+
def test_artifacts_list_and_download(self, client: TestClient) -> None:
78+
headers, conversation_id = _setup(client)
79+
client.post(
80+
f"/conversations/{conversation_id}/files",
81+
headers=headers,
82+
files={"file": ("input.xlsx", b"upload-bytes", "application/octet-stream")},
83+
)
84+
workspace = Path(get_settings().workspace_root) / conversation_id
85+
(workspace / "result.xlsx").write_bytes(b"agent-output")
86+
87+
res = client.get(f"/conversations/{conversation_id}/artifacts", headers=headers)
88+
assert res.status_code == 200, res.text
89+
names = [a["name"] for a in res.json()]
90+
# the upload is excluded; the agent output is listed
91+
assert names == ["result.xlsx"]
92+
93+
res = client.get(f"/conversations/{conversation_id}/artifacts/result.xlsx", headers=headers)
94+
assert res.status_code == 200
95+
assert res.content == b"agent-output"
96+
assert "result.xlsx" in res.headers["content-disposition"]
97+
98+
def test_artifacts_path_traversal_rejected(self, client: TestClient) -> None:
99+
headers, conversation_id = _setup(client)
100+
res = client.get(
101+
f"/conversations/{conversation_id}/artifacts/..%2F..%2Fetc%2Fpasswd",
102+
headers=headers,
103+
)
104+
assert res.status_code in (400, 404)
105+
106+
def test_artifacts_unknown_404(self, client: TestClient) -> None:
107+
headers, conversation_id = _setup(client)
108+
res = client.get(f"/conversations/{conversation_id}/artifacts/nope.xlsx", headers=headers)
109+
assert res.status_code == 404
110+
111+
def test_artifacts_foreign_conversation_404(self, client: TestClient) -> None:
112+
headers, _ = _setup(client)
113+
res = client.get("/conversations/cnv_nobody/artifacts", headers=headers)
114+
assert res.status_code == 404
115+
116+
def test_delete_unknown_file_404(self, client: TestClient) -> None:
117+
headers, conversation_id = _setup(client)
118+
res = client.delete(f"/conversations/{conversation_id}/files/file_nope", headers=headers)
119+
assert res.status_code == 404
120+
121+
def test_run_prompt_includes_uploaded_files(self, client: TestClient) -> None:
122+
from app.controller import manager as manager_mod
123+
from app.controller.runner import ExecResult, Runner
124+
125+
class CapturingRunner(Runner):
126+
captured: list[str] = []
127+
128+
def create(self, user_id: str, conversation_id: str) -> str:
129+
return "sbx_files"
130+
131+
def destroy(self, sandbox_id: str) -> None:
132+
pass
133+
134+
def exec_run(self, sandbox_id, prompt, run_id, on_line=None, timeout=None):
135+
CapturingRunner.captured.append(prompt)
136+
return ExecResult(
137+
exit_code=0,
138+
stdout='{"type": "result", "answer": "ok", "errors": []}\n',
139+
stderr="",
140+
)
141+
142+
def reap_idle(self, ttl_seconds: float) -> list[str]:
143+
return []
144+
145+
monkeypatched = pytest.MonkeyPatch()
146+
monkeypatched.setattr(manager_mod, "_controller", None)
147+
monkeypatched.setattr(manager_mod, "get_runner", lambda: CapturingRunner(), raising=True)
148+
try:
149+
headers, conversation_id = _setup(client)
150+
client.post(
151+
f"/conversations/{conversation_id}/files",
152+
headers=headers,
153+
files={"file": ("sales.xlsx", b"x", "application/octet-stream")},
154+
)
155+
started = client.post(
156+
f"/conversations/{conversation_id}/runs",
157+
json={"prompt": "summarize"},
158+
headers=headers,
159+
)
160+
assert started.status_code == 202, started.text
161+
deadline = time.time() + 5
162+
while time.time() < deadline:
163+
if not CapturingRunner.captured:
164+
time.sleep(0.02)
165+
continue
166+
break
167+
assert CapturingRunner.captured, "run never executed"
168+
assert "sales.xlsx" in CapturingRunner.captured[0]
169+
finally:
170+
monkeypatched.undo()
171+
172+
def test_delete_conversation_removes_files(self, client: TestClient) -> None:
173+
headers, conversation_id = _setup(client)
174+
client.post(
175+
f"/conversations/{conversation_id}/files",
176+
headers=headers,
177+
files={"file": ("a.xlsx", b"x", "application/octet-stream")},
178+
)
179+
res = client.delete(f"/conversations/{conversation_id}", headers=headers)
180+
assert res.status_code == 204
181+
workspace = Path(get_settings().workspace_root) / conversation_id
182+
assert not workspace.is_dir() or not any(workspace.iterdir())

0 commit comments

Comments
 (0)