study-buddy / tests /test_projects_router.py
GitHub Actions
deploy b3d187d69e5df10bb2ec396e89f539f853465a06
14184e3
Raw
History Blame Contribute Delete
11.3 kB
import io
import pytest
from fastapi.testclient import TestClient
from app.main import app
import app.routers.projects as projects_router
from app.services.project_service import ProjectService
@pytest.fixture(autouse=True)
def tmp_service(tmp_path, monkeypatch):
svc = ProjectService(root=str(tmp_path / "registry"))
monkeypatch.setattr(projects_router, "_service", svc)
from app.services.student_memory import StudentMemoryService
async def _noop_ensure(self, project_id):
return True
monkeypatch.setattr(StudentMemoryService, "ensure_project_dataset", _noop_ensure)
return svc
def test_create_and_get_project():
client = TestClient(app)
r = client.post("/projects", json={"name": "Thesis"})
assert r.status_code == 200
assert r.json()["document_id"] == ""
pid = r.json()["project_id"]
g = client.get(f"/projects/{pid}")
assert g.status_code == 200
assert g.json()["name"] == "Thesis"
assert g.json()["document_id"] == ""
def test_list_projects():
client = TestClient(app)
client.post("/projects", json={"name": "A"})
client.post("/projects", json={"name": "B"})
r = client.get("/projects")
assert r.status_code == 200
assert len(r.json()["items"]) == 2
assert all("document_id" in item for item in r.json()["items"])
def test_get_missing_404():
client = TestClient(app)
assert client.get("/projects/nope").status_code == 404
def test_delete_project(monkeypatch, tmp_path):
import app.routers.projects as pr
deleted: list[str] = []
monkeypatch.setattr(pr, "delete_project_uploads", lambda project_id: deleted.append(project_id))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Gone"}).json()["project_id"]
assert client.delete(f"/projects/{pid}").status_code == 200
assert client.get(f"/projects/{pid}").status_code == 404
assert deleted == [pid]
def test_update_project_details():
client = TestClient(app)
pid = client.post("/projects", json={"name": "Old"}).json()["project_id"]
r = client.patch(f"/projects/{pid}", json={"name": "New Research Direction", "title": "Working title"})
assert r.status_code == 200
body = r.json()
assert body["name"] == "New Research Direction"
assert body["title"] == "Working title"
g = client.get(f"/projects/{pid}")
assert g.json()["name"] == "New Research Direction"
def test_update_project_blank_name_falls_back():
client = TestClient(app)
pid = client.post("/projects", json={"name": "Old"}).json()["project_id"]
r = client.patch(f"/projects/{pid}", json={"name": " "})
assert r.status_code == 200
assert r.json()["name"] == "Untitled Project"
def test_valid_project_id_guard():
from app.routers.projects import _valid_project_id
assert _valid_project_id("some-uuid-1234") is True
assert _valid_project_id("../etc") is False
assert _valid_project_id("a/b") is False
assert _valid_project_id("..") is False
assert _valid_project_id("") is False
def test_add_file_appends_to_project(monkeypatch, tmp_path):
# Avoid heavy embedding: stub the chunker path.
import app.routers.projects as pr
monkeypatch.setattr(pr, "ingest_file", lambda *a, **k: 3)
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
import os
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Grow"}).json()["project_id"]
r = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(b"hello world"), "application/pdf"))],
)
assert r.status_code == 200
body = r.json()
assert len(body["files"]) == 1
assert body["files"][0]["filename"] == "paper.pdf"
assert len(body["files"][0]["file_id"]) == 64 # full sha256
assert len(body["document_id"]) == 64
def test_add_same_file_twice_dedupes_and_skips_rechunk(monkeypatch, tmp_path):
# Fix 1 regression: re-uploading an already-present file must not append
# a duplicate project.files entry, and must not re-invoke ingest_file.
import app.routers.projects as pr
ingest_calls: list[str] = []
def _fake_ingest(content, filename, collection, **kwargs):
ingest_calls.append(filename)
return 3
import os
monkeypatch.setattr(pr, "ingest_file", _fake_ingest)
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Dedup"}).json()["project_id"]
payload = b"same bytes every time"
r1 = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(payload), "application/pdf"))],
)
assert r1.status_code == 200
r2 = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(payload), "application/pdf"))],
)
assert r2.status_code == 200
body = r2.json()
assert len(body["files"]) == 1
assert ingest_calls == ["paper.pdf"]
def test_add_more_than_five_files_returns_400(monkeypatch, tmp_path):
import app.routers.projects as pr
import os
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Cap"}).json()["project_id"]
files = [
("files", (f"paper-{i}.pdf", io.BytesIO(f"payload-{i}".encode()), "application/pdf"))
for i in range(6)
]
r = client.post(f"/projects/{pid}/files", files=files)
assert r.status_code == 400
assert "5 files" in r.json()["detail"]
def test_same_filename_different_content_gets_unique_name(monkeypatch, tmp_path):
import app.routers.projects as pr
import os
monkeypatch.setattr(pr, "ingest_file", lambda *a, **k: 3)
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Collision"}).json()["project_id"]
r1 = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(b"version one"), "application/pdf"))],
)
assert r1.status_code == 200
r2 = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(b"version two"), "application/pdf"))],
)
assert r2.status_code == 200
names = [f["filename"] for f in r2.json()["files"]]
assert len(names) == 2
assert names[0] == "paper.pdf"
assert names[1].startswith("paper-")
assert names[1].endswith(".pdf")
def test_add_file_missing_project_404(monkeypatch):
client = TestClient(app)
r = client.post(
"/projects/nope/files",
files=[("files", ("p.pdf", io.BytesIO(b"x"), "application/pdf"))],
)
assert r.status_code == 404
async def _noop_remove(project_id, filename): # module-level helper for monkeypatch
return True
def test_remove_file_drops_entry(monkeypatch, tmp_path):
import app.routers.projects as pr
monkeypatch.setattr(pr, "ingest_file", lambda *a, **k: 3)
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
monkeypatch.setattr(pr, "remove_file_from_project", _noop_remove)
import os
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
client = TestClient(app)
pid = client.post("/projects", json={"name": "Shrink"}).json()["project_id"]
add = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(b"bytes here"), "application/pdf"))],
).json()
fid = add["files"][0]["file_id"]
r = client.delete(f"/projects/{pid}/files/{fid}")
assert r.status_code == 200
assert r.json()["files"] == []
assert r.json()["document_id"] == ""
def test_remove_file_saves_registry_before_memory_reset(monkeypatch, tmp_path):
import app.routers.projects as pr
seen_counts = []
async def fake_remove(project_id, filename):
project = pr._service.load(project_id)
seen_counts.append(len(project.files if project else []))
return True
monkeypatch.setattr(pr, "ingest_file", lambda *a, **k: 3)
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
monkeypatch.setattr(pr, "_PDF_CACHE_DIR", str(tmp_path / "pdfs"))
monkeypatch.setattr(pr, "remove_file_from_project", fake_remove)
import os
os.makedirs(str(tmp_path / "pdfs"), exist_ok=True)
client = TestClient(app)
pid = client.post("/projects", json={"name": "Memory Order"}).json()["project_id"]
add = client.post(
f"/projects/{pid}/files",
files=[("files", ("paper.pdf", io.BytesIO(b"bytes here"), "application/pdf"))],
).json()
fid = add["files"][0]["file_id"]
assert client.delete(f"/projects/{pid}/files/{fid}").status_code == 200
assert seen_counts == [0]
def test_remove_missing_file_404(monkeypatch, tmp_path):
import app.routers.projects as pr
monkeypatch.setattr(pr, "remove_file_from_project", _noop_remove)
client = TestClient(app)
pid = client.post("/projects", json={"name": "X"}).json()["project_id"]
assert client.delete(f"/projects/{pid}/files/doesnotexist").status_code == 404
def test_get_project_drops_registry_files_missing_on_disk(monkeypatch, tmp_path):
import app.routers.projects as pr
monkeypatch.setattr(pr, "project_upload_dir", lambda pid: str(tmp_path / pid))
client = TestClient(app)
pid = client.post("/projects", json={"name": "Stale"}).json()["project_id"]
folder = tmp_path / pid
folder.mkdir(parents=True, exist_ok=True)
(folder / "kept.pdf").write_bytes(b"kept")
project = pr._service.load(pid)
project.files = [
pr.ProjectFile(filename="missing.pdf", file_id="a" * 64),
pr.ProjectFile(filename="kept.pdf", file_id="b" * 64),
]
pr._service.save(project)
r = client.get(f"/projects/{pid}")
assert r.status_code == 200
assert [f["filename"] for f in r.json()["files"]] == ["kept.pdf"]
saved = pr._service.load(pid)
assert [f.filename for f in saved.files] == ["kept.pdf"]
def test_session_create_persists_project(monkeypatch, tmp_path):
import app.routers.session as sr
import app.routers.projects as pr
svc = ProjectService(root=str(tmp_path / "reg2"))
monkeypatch.setattr(sr, "_project_service", svc, raising=False)
monkeypatch.setattr(pr, "_service", svc)
client = TestClient(app)
pid = client.post("/session/create", json={"topic": "Optics", "intention": "learn"}).json()["project_id"]
# The same id must now be loadable as a real project via /projects
assert client.get(f"/projects/{pid}").status_code == 200