"""Tests for the research-project routes (proposals lifecycle). The pure-helper unit tests always run. The full lifecycle integration test runs only when INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg), e.g.: export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" cd be0 && python -m unittest tests.test_research_routes -v Prereq for the DB test: migration 016_research_projects.sql applied (compose init mount or scripts/apply_initiative_migrations.py). """ from __future__ import annotations import os import unittest import uuid _RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") class ExtractScalarsTests(unittest.TestCase): """Pure unit tests for the proposal-content scalar extraction (no DB).""" def test_reads_dotted_keys_and_coerces(self) -> None: from src.research_routes import _extract_scalars s = _extract_scalars( { "tenDeTai": " AI dự đoán di căn ", "capDeTai": "Thành phố", "chuNhiem.hoTen": "TS. Nguyễn Văn A", "thoiGianThucHienThang": "24", "tongKinhPhi": "1800.5", } ) self.assertEqual(s["title"], "AI dự đoán di căn") self.assertEqual(s["level"], "Thành phố") self.assertEqual(s["pi_name"], "TS. Nguyễn Văn A") self.assertEqual(s["period_months"], 24) self.assertAlmostEqual(s["budget_total"], 1800.5) def test_missing_and_garbage_values(self) -> None: from src.research_routes import _extract_scalars empty = _extract_scalars({}) self.assertEqual(empty["title"], "") self.assertIsNone(empty["period_months"]) self.assertIsNone(empty["budget_total"]) garbage = _extract_scalars({"thoiGianThucHienThang": "abc", "tongKinhPhi": ""}) self.assertIsNone(garbage["period_months"]) self.assertIsNone(garbage["budget_total"]) def test_non_dict_content(self) -> None: from src.research_routes import _extract_scalars self.assertEqual(_extract_scalars(None)["title"], "") self.assertEqual(_extract_scalars("nope")["pi_name"], "") def _bearer(uid: uuid.UUID, roles: list[str]) -> str: import jwt from src.auth_jwt import jwt_secret return "Bearer " + jwt.encode({"sub": str(uid), "roles": roles, "cv": 0}, jwt_secret(), algorithm="HS256") @unittest.skipUnless( _RUN_DB, "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", ) class ResearchLifecycleDbTests(unittest.IsolatedAsyncioTestCase): """End-to-end: draft → submit → approve, owner/admin authz, and the audit trail.""" async def asyncSetUp(self) -> None: from src.initiative_db import engine as eng await eng.dispose_engine() await eng.init_engine() self._user_ids: list[uuid.UUID] = [] self._project_ids: list[uuid.UUID] = [] async def asyncTearDown(self) -> None: from sqlalchemy import delete from src.initiative_db import engine as eng from src.initiative_db.engine import get_session from src.initiative_db.models import ResearchProject, User async with get_session() as session: for pid in self._project_ids: await session.execute(delete(ResearchProject).where(ResearchProject.id == pid)) for uid in self._user_ids: await session.execute(delete(User).where(User.id == uid)) await session.commit() await eng.dispose_engine() async def _seed_user(self, *, admin: bool = False) -> uuid.UUID: from src.initiative_db.engine import get_session from src.initiative_db.models import User uid = uuid.uuid4() async with get_session() as session: session.add( User( id=uid, email=f"rp-{uid.hex[:10]}@ump.edu.vn", password_hash="x", full_name=("Quản trị" if admin else "Chủ nhiệm") + " Test", ) ) await session.commit() self._user_ids.append(uid) return uid async def test_full_lifecycle_and_authz(self) -> None: from fastapi import HTTPException from src.research_routes import ( ApproveIn, ProjectCreateIn, approve_project, create_project, get_project, list_audit, submit_project, ) owner = await self._seed_user() admin = await self._seed_user(admin=True) other = await self._seed_user() owner_tok = _bearer(owner, ["viewer"]) admin_tok = _bearer(admin, ["admin"]) other_tok = _bearer(other, ["viewer"]) # create draft created = await create_project( ProjectCreateIn( content={ "tenDeTai": "Đề tài thử nghiệm", "capDeTai": "Cơ sở", "thoiGianThucHienThang": "12", "tongKinhPhi": "500", } ), owner_tok, ) self._project_ids.append(uuid.UUID(created.id)) self.assertEqual(created.status, "draft") self.assertEqual(created.title, "Đề tài thử nghiệm") self.assertEqual(created.periodMonths, 12) # another user cannot read it (404 hides existence) with self.assertRaises(HTTPException) as ctx_read: await get_project(created.id, other_tok) self.assertEqual(ctx_read.exception.status_code, 404) # submit (owner) submitted = await submit_project(created.id, owner_tok) self.assertEqual(submitted.status, "submitted") self.assertIsNotNone(submitted.submittedAt) # owner cannot approve (admin-only) → 403 with self.assertRaises(HTTPException) as ctx_appr: await approve_project(created.id, ApproveIn(), owner_tok) self.assertEqual(ctx_appr.exception.status_code, 403) # admin approves with a code approved = await approve_project(created.id, ApproveIn(code="ĐTUD-TEST", note="Đạt"), admin_tok) self.assertEqual(approved.status, "approved") self.assertEqual(approved.code, "ĐTUD-TEST") self.assertIsNotNone(approved.reviewedAt) # cannot re-approve (not submitted anymore) → 409 with self.assertRaises(HTTPException) as ctx_reappr: await approve_project(created.id, ApproveIn(), admin_tok) self.assertEqual(ctx_reappr.exception.status_code, 409) # audit trail recorded each transition (owner can read) audit = await list_audit(created.id, owner_tok) actions = [a.action for a in audit] self.assertIn("Tạo bản thảo đề tài", actions) self.assertIn("Nộp đề tài", actions) self.assertIn("Phê duyệt đề tài", actions) async def test_reject_path_and_admin_cannot_edit_others_draft(self) -> None: from fastapi import HTTPException from src.research_routes import ( ProjectCreateIn, ProjectUpdateIn, RejectIn, create_project, reject_project, submit_project, update_project, ) owner = await self._seed_user() admin = await self._seed_user(admin=True) owner_tok = _bearer(owner, ["viewer"]) admin_tok = _bearer(admin, ["admin"]) created = await create_project(ProjectCreateIn(content={"tenDeTai": "Đề tài bị từ chối"}), owner_tok) self._project_ids.append(uuid.UUID(created.id)) # admin can load it but cannot update/submit someone else's draft (owner-only) → 403 with self.assertRaises(HTTPException) as ctx_upd: await update_project(created.id, ProjectUpdateIn(content={"tenDeTai": "x"}), admin_tok) self.assertEqual(ctx_upd.exception.status_code, 403) with self.assertRaises(HTTPException) as ctx_sub: await submit_project(created.id, admin_tok) self.assertEqual(ctx_sub.exception.status_code, 403) # owner submits, admin rejects with a note await submit_project(created.id, owner_tok) rejected = await reject_project(created.id, RejectIn(note="Chưa đạt yêu cầu"), admin_tok) self.assertEqual(rejected.status, "rejected") self.assertEqual(rejected.reviewNote, "Chưa đạt yêu cầu") # cannot submit a rejected proposal (not draft) → 409 with self.assertRaises(HTTPException) as ctx_resub: await submit_project(created.id, owner_tok) self.assertEqual(ctx_resub.exception.status_code, 409) async def test_cockpit_entities_crud_seeding_and_gate(self) -> None: from fastapi import HTTPException from src.research_routes import ( ApproveIn, ProjectCreateIn, approve_project, create_entity, create_project, delete_entity, get_cockpit, list_entity, submit_project, update_entity, ) owner = await self._seed_user() admin = await self._seed_user(admin=True) owner_tok = _bearer(owner, ["viewer"]) admin_tok = _bearer(admin, ["admin"]) created = await create_project( ProjectCreateIn( content={ "tenDeTai": "Đề tài cockpit", "chuNhiem.hoTen": "TS. PI A", "thanhVienThucHien": [{"hoTenHocVi": "KS. Lê C", "chucDanh": "Thành viên chính"}], "tienDoThucHien": [{"noiDungCongViec": "ND1", "ketQua": "Báo cáo", "thoiGian": "T1-T3"}], } ), owner_tok, ) pid = created.id self._project_ids.append(uuid.UUID(pid)) # entity mutation before approval is locked → 409 with self.assertRaises(HTTPException) as ctx_gate: await create_entity(pid, "datasets", {"name": "X"}, owner_tok) self.assertEqual(ctx_gate.exception.status_code, 409) # unknown entity type → 404 with self.assertRaises(HTTPException) as ctx_unknown: await list_entity(pid, "nope", owner_tok) self.assertEqual(ctx_unknown.exception.status_code, 404) await submit_project(pid, owner_tok) await approve_project(pid, ApproveIn(code="C1"), admin_tok) # approval seeded members (PI + 1 member) + milestones (1) from the proposal content members = await list_entity(pid, "members", owner_tok) self.assertGreaterEqual(len(members), 2) self.assertTrue(any(m["name"] == "TS. PI A" for m in members)) milestones = await list_entity(pid, "milestones", owner_tok) self.assertGreaterEqual(len(milestones), 1) self.assertEqual(milestones[0]["start"], "T1-T3") # create + coercion (records "620" → int 620) ds = await create_entity(pid, "datasets", {"name": "Ảnh CLVT", "records": "620", "status": "Sẵn sàng"}, owner_tok) self.assertEqual(ds["name"], "Ảnh CLVT") self.assertEqual(ds["records"], 620) # update upd = await update_entity(pid, "datasets", ds["id"], {"status": "Khóa"}, owner_tok) self.assertEqual(upd["status"], "Khóa") # cockpit bundle reflects entities + audit captured the actions bundle = await get_cockpit(pid, admin_tok) self.assertEqual(bundle["project"]["status"], "approved") self.assertEqual(len(bundle["datasets"]), 1) audit_actions = [a["action"] for a in bundle["audit"]] self.assertIn("Thêm bộ dữ liệu", audit_actions) self.assertIn("Cập nhật bộ dữ liệu", audit_actions) # delete await delete_entity(pid, "datasets", ds["id"], owner_tok) self.assertEqual(len(await list_entity(pid, "datasets", owner_tok)), 0) async def test_seeding_survives_malformed_content(self) -> None: """A PI can put arbitrary JSON in content; approve-time seeding must never crash (best-effort).""" from src.research_routes import ( ApproveIn, ProjectCreateIn, approve_project, create_project, list_entity, submit_project, ) owner = await self._seed_user() admin = await self._seed_user(admin=True) owner_tok = _bearer(owner, ["viewer"]) admin_tok = _bearer(admin, ["admin"]) created = await create_project( ProjectCreateIn( content={ "tenDeTai": "Đề tài lỗi định dạng", "chuNhiem.hoTen": "TS. PI A", "thanhVienThucHien": 5, # truthy non-list — must be ignored, not crash "tienDoThucHien": "oops", # truthy non-list } ), owner_tok, ) pid = created.id self._project_ids.append(uuid.UUID(pid)) await submit_project(pid, owner_tok) approved = await approve_project(pid, ApproveIn(), admin_tok) # must not raise self.assertEqual(approved.status, "approved") # malformed repeatables seeded nothing; only the PI member came from chuNhiem.hoTen self.assertEqual(len(await list_entity(pid, "milestones", owner_tok)), 0) members = await list_entity(pid, "members", owner_tok) self.assertTrue(any(m["name"] == "TS. PI A" for m in members)) async def test_update_detail_merges_after_approval(self) -> None: """The cockpit detail endpoint: approved-only, owner-or-admin, shallow-merge + audit.""" from fastapi import HTTPException from src.research_routes import ( ApproveIn, ProjectCreateIn, ProjectDetailPatchIn, approve_project, create_project, get_cockpit, submit_project, update_project_detail, ) owner = await self._seed_user() admin = await self._seed_user(admin=True) other = await self._seed_user() owner_tok = _bearer(owner, ["viewer"]) admin_tok = _bearer(admin, ["admin"]) other_tok = _bearer(other, ["viewer"]) created = await create_project( ProjectCreateIn(content={"tenDeTai": "Đề tài chi tiết", "tongKinhPhi": "300"}), owner_tok, ) pid = created.id self._project_ids.append(uuid.UUID(pid)) # detail patch is rejected before approval (cockpit-only) → 409 with self.assertRaises(HTTPException) as ctx_draft: await update_project_detail(pid, ProjectDetailPatchIn(patch={"soHopDong": "x"}), owner_tok) self.assertEqual(ctx_draft.exception.status_code, 409) await submit_project(pid, owner_tok) await approve_project(pid, ApproveIn(code="C-DET"), admin_tok) # owner patches admin-detail fields; merge preserves the original proposal key + re-derives scalars patched = await update_project_detail( pid, ProjectDetailPatchIn(patch={"soHopDong": "205/2024/HĐ", "tongKinhPhi": "29900000"}), owner_tok, ) self.assertEqual(patched.content["soHopDong"], "205/2024/HĐ") self.assertEqual(patched.content["tenDeTai"], "Đề tài chi tiết") # untouched proposal key self.assertAlmostEqual(patched.budgetTotal, 29900000.0) # admin may also patch (owner-or-admin — unlike draft update_project which is owner-only) patched2 = await update_project_detail( pid, ProjectDetailPatchIn(patch={"khoaDonVi": "Dược"}), admin_tok ) self.assertEqual(patched2.content["khoaDonVi"], "Dược") self.assertEqual(patched2.content["soHopDong"], "205/2024/HĐ") # earlier patch survives # a stranger cannot patch (404 hides the row) with self.assertRaises(HTTPException) as ctx_other: await update_project_detail(pid, ProjectDetailPatchIn(patch={"x": "y"}), other_tok) self.assertEqual(ctx_other.exception.status_code, 404) # audit captured the update bundle = await get_cockpit(pid, owner_tok) self.assertIn("Cập nhật thông tin đề tài", [a["action"] for a in bundle["audit"]])