404 lines
16 KiB
Python
404 lines
16 KiB
Python
"""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"]])
|