Files
sciagent/be0/tests/test_research_routes.py
T
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

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"]])