sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"""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"]])
|
||||
Reference in New Issue
Block a user