v1.9.0 -> v1.10.0: file upload UI + attachment management
- Paperclip icon left of text input, file preview pill (image thumb or file icon)
- Conversation list shows attachment icon right of trash, opens gallery overlay
- Gallery overlay: scrollable, close (X), delete attachment per item
- DELETE /api/upload/{id} removes from SQLite + Qdrant
- PATCH /api/upload/{id}/link ties upload to conversation
- GET /api/upload/by-conversation/{id} lists attachments
- Chat accepts upload_context_id, injects [ATTACHED DOCUMENT] into system prompt
- Conversation list includes attachment_count field
- 8 new tests: upload context injection, delete, link, by-conversation, image type, attachment count
Still missing (P3): drag-and-drop upload, global attachments page, file download, batch upload
This commit is contained in:
@@ -152,3 +152,123 @@ def test_upload_both_mode(tmp_path: Path, monkeypatch):
|
||||
assert data["mode"] == "both"
|
||||
assert "context_id" in data
|
||||
assert data["chunks_ingested"] > 0
|
||||
|
||||
|
||||
def test_upload_image_type(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.post(
|
||||
"/api/upload", headers=_admin_headers(client),
|
||||
data={"mode": "context"},
|
||||
files={"file": ("photo.png", b"fake-png", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["filename"] == "photo.png"
|
||||
assert "context_id" in data
|
||||
|
||||
|
||||
def test_get_upload_by_conversation(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
headers = _admin_headers(client)
|
||||
resp1 = client.post("/api/upload", headers=headers,
|
||||
data={"mode": "context", "conversation_id": "conv-gal"},
|
||||
files={"file": ("a.txt", b"alpha")})
|
||||
cid1 = resp1.json()["context_id"]
|
||||
resp2 = client.post("/api/upload", headers=headers,
|
||||
data={"mode": "context", "conversation_id": "conv-gal"},
|
||||
files={"file": ("b.txt", b"beta")})
|
||||
cid2 = resp2.json()["context_id"]
|
||||
|
||||
gal = client.get("/api/upload/by-conversation/conv-gal", headers=headers)
|
||||
assert gal.status_code == 200
|
||||
items = gal.json()
|
||||
assert len(items) == 2
|
||||
assert items[0]["filename"] == "a.txt"
|
||||
assert items[1]["filename"] == "b.txt"
|
||||
|
||||
|
||||
def test_link_upload_to_conversation(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
headers = _admin_headers(client)
|
||||
resp = client.post("/api/upload", headers=headers,
|
||||
data={"mode": "context"},
|
||||
files={"file": ("orphan.txt", b"lonely")})
|
||||
cid = resp.json()["context_id"]
|
||||
|
||||
link = client.patch(f"/api/upload/{cid}/link", headers=headers,
|
||||
json={"conversation_id": "new-conv"})
|
||||
assert link.status_code == 200
|
||||
|
||||
gal = client.get("/api/upload/by-conversation/new-conv", headers=headers)
|
||||
assert len(gal.json()) == 1
|
||||
|
||||
|
||||
def test_delete_upload_removes_context(tmp_path: Path, monkeypatch):
|
||||
class FakeAsyncClient:
|
||||
class FakeResponse:
|
||||
def __init__(self, status, json_data=None):
|
||||
self.status_code = status
|
||||
self._json = json_data or {}
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
async def post(self, url, **kw):
|
||||
if "/points/scroll" in url:
|
||||
return self.FakeResponse(200, {"result": {"points": [{"id": "upload-test.txt-0"}, {"id": "upload-test.txt-1"}]}})
|
||||
if "/points/delete" in url:
|
||||
return self.FakeResponse(200)
|
||||
return self.FakeResponse(200)
|
||||
|
||||
async def put(self, url, **kw):
|
||||
return self.FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
|
||||
|
||||
with make_client(tmp_path) as client:
|
||||
headers = _admin_headers(client)
|
||||
resp = client.post("/api/upload", headers=headers,
|
||||
data={"mode": "context", "conversation_id": "del-test"},
|
||||
files={"file": ("test.txt", b"delete me")})
|
||||
cid = resp.json()["context_id"]
|
||||
|
||||
del_resp = client.delete(f"/api/upload/{cid}", headers=headers)
|
||||
assert del_resp.status_code == 200
|
||||
assert del_resp.json()["status"] == "ok"
|
||||
|
||||
row = db.get_db().execute("SELECT id FROM upload_context WHERE id = ?", (cid,)).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_delete_upload_not_found(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.delete("/api/upload/999", headers=_admin_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_conversation_list_includes_attachment_count(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
headers = _admin_headers(client)
|
||||
client.post("/api/conversations", headers=headers, json={"title": "NoAttach"})
|
||||
with_attach = client.post("/api/conversations", headers=headers, json={"title": "WithAttach"}).json()
|
||||
conv_id = with_attach["id"]
|
||||
client.post("/api/upload", headers=headers,
|
||||
data={"mode": "context", "conversation_id": conv_id},
|
||||
files={"file": ("f.txt", b"data")})
|
||||
|
||||
list_resp = client.get("/api/conversations", headers=headers)
|
||||
convs = list_resp.json()
|
||||
for c in convs:
|
||||
if c["title"] == "NoAttach":
|
||||
assert c["attachment_count"] == 0
|
||||
if c["title"] == "WithAttach":
|
||||
assert c["attachment_count"] == 1
|
||||
|
||||
Reference in New Issue
Block a user