sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
"""Minimal valid staff fields for POST /api/v1/auth/register in integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def register_staff_fields() -> dict[str, str]:
|
||||
"""Unique employee_id per call (DB partial unique index on user_staff_profiles.employee_id)."""
|
||||
suffix = uuid.uuid4().hex[:8].upper()
|
||||
return {
|
||||
"employeeId": f"CB-{suffix}",
|
||||
"academicTitleCode": "master",
|
||||
"unitNameFreetext": "Khoa kiểm thử",
|
||||
"jobTitle": "Cán bộ",
|
||||
}
|
||||
Binary file not shown.
+99
@@ -0,0 +1,99 @@
|
||||
"""Minimal valid tab JSON for submit readiness checks (technical classification)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def minimal_report_tab(*, initiative_name: str = "Test initiative") -> Dict[str, Any]:
|
||||
return {
|
||||
"introduction": "Mở đầu đủ.",
|
||||
"initiativeName": initiative_name,
|
||||
"representativeAuthor": "Nguyễn Văn A",
|
||||
"representativePhone": "0900000000",
|
||||
"representativeEmail": "a@ump.edu.vn",
|
||||
"applicationField": "Y tế",
|
||||
"currentStatus": "Hiện trạng.",
|
||||
"purpose": "Mục đích.",
|
||||
"solutionContent": "Nội dung giải pháp.",
|
||||
"implementationSteps": "Các bước.",
|
||||
"firstAppliedUnit": "Đơn vị.",
|
||||
"achievedResult": "Kết quả.",
|
||||
"conditions": "Điều kiện.",
|
||||
"trialUnits": [],
|
||||
"novelty": "Tính mới.",
|
||||
"effectiveness": {
|
||||
"economic": "Kinh tế.",
|
||||
"social": "Xã hội.",
|
||||
"teaching": "Giảng dạy.",
|
||||
"productivity": "",
|
||||
"quality": "",
|
||||
"environment": "",
|
||||
"safety": "An toàn.",
|
||||
},
|
||||
"confidentialInfo": "",
|
||||
"submissionDate": "05/05/2026",
|
||||
"authorName": "Nguyễn Văn A",
|
||||
"honestyConfirmed": True,
|
||||
}
|
||||
|
||||
|
||||
def minimal_application_tab_technical(*, initiative_name: str = "Test initiative") -> Dict[str, Any]:
|
||||
return {
|
||||
"unitName": "Đơn vị A",
|
||||
"authors": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Nguyễn Văn A",
|
||||
"dob": "01/01/1980",
|
||||
"workplace": "UMP",
|
||||
"title": "GV",
|
||||
"qualification": "TS",
|
||||
"contributionPercent": 100,
|
||||
}
|
||||
],
|
||||
"initiativeName": initiative_name,
|
||||
"investorName": "Chủ đầu tư",
|
||||
"applicationField": "Y tế",
|
||||
"firstApplyDate": "15/04/2025",
|
||||
"initiativeClassification": "technical",
|
||||
"textbookEvidenceKind": "",
|
||||
"researchEvidenceKind": "",
|
||||
"researchEvidenceFile": None,
|
||||
"textbookEvidenceFile": None,
|
||||
"technicalEvidenceFile": None,
|
||||
"internationalJournalDeclaration": "",
|
||||
"banCamKet": {},
|
||||
"referenceMaterialHonesty": {},
|
||||
"researchDomesticHonesty": {},
|
||||
"contentSummary": "Tóm tắt nội dung.",
|
||||
"confidentialInfo": "",
|
||||
"conditions": "Điều kiện đơn.",
|
||||
"authorEvaluation": "Đánh giá tác giả.",
|
||||
"trialEvaluation": "Đánh giá thử.",
|
||||
"supportStaff": [],
|
||||
"honestyConfirmed": True,
|
||||
"submissionDay": 5,
|
||||
"submissionMonth": 5,
|
||||
"submissionYear": "2026",
|
||||
}
|
||||
|
||||
|
||||
def minimal_contribution_tab(*, initiative_name: str = "Test initiative") -> Dict[str, Any]:
|
||||
return {
|
||||
"initiativeName": initiative_name,
|
||||
"mainAuthor": "Nguyễn Văn A",
|
||||
"position": "UMP",
|
||||
"representativePercent": 100,
|
||||
"submissionDate": "2026-05-05T00:00:00.000Z",
|
||||
"participants": [],
|
||||
"digitalSignatureConfirmed": True,
|
||||
}
|
||||
|
||||
|
||||
def minimal_tabs_bundle(*, initiative_name: str = "Test initiative") -> Dict[str, Dict[str, Any]]:
|
||||
return {
|
||||
"report": minimal_report_tab(initiative_name=initiative_name),
|
||||
"application": minimal_application_tab_technical(initiative_name=initiative_name),
|
||||
"contribution": minimal_contribution_tab(initiative_name=initiative_name),
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Shared JWT bearer header for route security tests (uses auth_jwt.jwt_secret())."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Sequence
|
||||
|
||||
import jwt
|
||||
|
||||
from src.auth_jwt import jwt_secret
|
||||
|
||||
|
||||
def mint_bearer_token(
|
||||
*,
|
||||
roles: Sequence[str] = ("viewer",),
|
||||
sub: uuid.UUID | None = None,
|
||||
email: str = "security-test@ump.edu.vn",
|
||||
credential_version: int = 0,
|
||||
) -> str:
|
||||
user_id = sub or uuid.uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"roles": list(roles),
|
||||
"cv": credential_version,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(hours=1)).timestamp()),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret(), algorithm="HS256")
|
||||
return f"Bearer {token}"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Sanity checks for admin audit router registration (no DB required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class AdminAuditRouterSmokeTests(unittest.TestCase):
|
||||
def test_audit_router_registers_list_and_detail(self) -> None:
|
||||
from src.admin_audit_routes import router
|
||||
|
||||
paths = [getattr(r, "path", "") for r in router.routes]
|
||||
self.assertIn("/admin/audit", paths)
|
||||
self.assertTrue(
|
||||
any(isinstance(p, str) and p.startswith("/admin/audit/") for p in paths),
|
||||
msg=f"detail route missing under router, paths={paths}",
|
||||
)
|
||||
|
||||
def test_parse_sort_behavior(self) -> None:
|
||||
from src.admin_audit_routes import _parse_sort
|
||||
|
||||
self.assertFalse(_parse_sort("occurred_at:desc"))
|
||||
self.assertTrue(_parse_sort("occurred_at:asc"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unit tests for backup ZIP helpers (no database)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from src.initiative_db.backup_naming import backup_zip_attachment_filename, official_form_pdf_backup_zip_path
|
||||
from src.initiative_db.application_storage import (
|
||||
EVIDENCE_ROLE_RESEARCH,
|
||||
STORAGE_FILESYSTEM,
|
||||
STORAGE_MINIO_ATTACHMENTS,
|
||||
STORAGE_MINIO_EXPORTS,
|
||||
effective_storage_kind,
|
||||
)
|
||||
|
||||
|
||||
class OfficialFormPdfZipPathTests(unittest.TestCase):
|
||||
def test_trang_bia_fields(self) -> None:
|
||||
obm = {
|
||||
"TRANG BÌA": {
|
||||
"Tên sáng kiến (Tiếng Việt)": " Khảo sát thảo dược ",
|
||||
"Tác giả/nhóm tác giả sáng kiến": "Lê Thị A",
|
||||
"Thông tin liên hệ (Điện thoại, Email)": "0909, a.b@ump.edu.vn",
|
||||
}
|
||||
}
|
||||
p = official_form_pdf_backup_zip_path(obm)
|
||||
self.assertEqual(p, "submitted/Khảo_sát_thảo_dược_Lê_Thị_A_a.b@ump.edu.vn.pdf")
|
||||
|
||||
def test_no_trang_bia_returns_none(self) -> None:
|
||||
self.assertIsNone(official_form_pdf_backup_zip_path({}))
|
||||
self.assertIsNone(official_form_pdf_backup_zip_path({"OTHER": {}}))
|
||||
|
||||
def test_empty_cover_returns_none(self) -> None:
|
||||
self.assertIsNone(
|
||||
official_form_pdf_backup_zip_path({"TRANG BÌA": {"Tên sáng kiến (Tiếng Việt)": " "}})
|
||||
)
|
||||
|
||||
|
||||
class BackupZipFilenameTests(unittest.TestCase):
|
||||
def test_email_local_part_and_sub_id(self) -> None:
|
||||
self.assertEqual(
|
||||
backup_zip_attachment_filename(
|
||||
owner_email=" nguyen.van.a@ump.edu.vn ",
|
||||
owner_full_name="Nguyễn Văn A",
|
||||
public_application_id="sub-deadbeef",
|
||||
),
|
||||
"nguyen.van.a_sub-deadbeef.zip",
|
||||
)
|
||||
|
||||
def test_fallback_name_when_no_email(self) -> None:
|
||||
fn = backup_zip_attachment_filename(
|
||||
owner_email=None,
|
||||
owner_full_name=" Lê Thị B ",
|
||||
public_application_id="sub-001",
|
||||
)
|
||||
self.assertTrue(fn.endswith("_sub-001.zip"))
|
||||
self.assertIn("Lê", fn)
|
||||
|
||||
def test_applicant_fallback(self) -> None:
|
||||
self.assertEqual(
|
||||
backup_zip_attachment_filename(
|
||||
owner_email="",
|
||||
owner_full_name="",
|
||||
public_application_id="sub-x",
|
||||
),
|
||||
"applicant_sub-x.zip",
|
||||
)
|
||||
|
||||
|
||||
class EffectiveStorageKindTests(unittest.TestCase):
|
||||
def test_full_pdf_minio_key(self) -> None:
|
||||
self.assertEqual(
|
||||
effective_storage_kind("full_pdf", "initiatives/abcd/2025/01/x-file.pdf", None),
|
||||
STORAGE_MINIO_EXPORTS,
|
||||
)
|
||||
|
||||
def test_full_pdf_filesystem(self) -> None:
|
||||
self.assertEqual(
|
||||
effective_storage_kind("full_pdf", "/submitted-initiatives/sub-abc.pdf", None),
|
||||
STORAGE_FILESYSTEM,
|
||||
)
|
||||
|
||||
def test_evidence_attachments(self) -> None:
|
||||
self.assertEqual(
|
||||
effective_storage_kind(
|
||||
EVIDENCE_ROLE_RESEARCH,
|
||||
"initiatives/abcd/2025/01/x-file.pdf",
|
||||
None,
|
||||
),
|
||||
STORAGE_MINIO_ATTACHMENTS,
|
||||
)
|
||||
|
||||
def test_respects_declared(self) -> None:
|
||||
self.assertEqual(
|
||||
effective_storage_kind("full_pdf", "/any", STORAGE_MINIO_EXPORTS),
|
||||
STORAGE_MINIO_EXPORTS,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
QA: GET draft bundle returns 200 with empty tabs when nothing is stored yet.
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_application_drafts_get -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
_CASE = "CASE-1776577845956"
|
||||
|
||||
|
||||
class ApplicationDraftsGetTests(unittest.TestCase):
|
||||
@patch("src.initiative_db.engine.is_postgres_enabled", return_value=False)
|
||||
@patch("main._load_application_draft_yaml", return_value=None)
|
||||
def test_unknown_case_returns_200_empty_shape(self, _mock_yaml, _mock_pg) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/application-drafts/{_CASE}")
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json()
|
||||
self.assertEqual(body.get("caseId"), _CASE)
|
||||
self.assertEqual(body.get("tabs"), {})
|
||||
self.assertIn("updatedAt", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,797 @@
|
||||
"""
|
||||
PostgreSQL integration tests for submitted applications (update / delete).
|
||||
|
||||
These tests exercise `src.initiative_db.submissions` against a real database.
|
||||
They are skipped unless INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg).
|
||||
|
||||
Example (host port from repo docker-compose.yml):
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
cd be0 && python -m unittest tests.test_applications_db_integration -v
|
||||
|
||||
Prerequisites:
|
||||
- Schema applied: `001_initiative_schema.sql` and `002_application_storage_extensions.sql` (see docker-compose postgres init mounts), or equivalent.
|
||||
- Network reachable from the machine running tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
|
||||
_HAS_MINIO = all(
|
||||
os.getenv(k, "").strip()
|
||||
for k in (
|
||||
"S3_ENDPOINT_URL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_BUCKET_ATTACHMENTS",
|
||||
"S3_BUCKET_EXPORTS",
|
||||
"S3_BUCKET_QUARANTINE",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests",
|
||||
)
|
||||
class ApplicationsDbIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""End-to-end persistence for applicant submission update + delete."""
|
||||
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def test_update_then_delete_submission_round_trip(self) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submissions import (
|
||||
delete_my_submitted_application,
|
||||
get_application_by_id,
|
||||
update_my_submitted_application,
|
||||
)
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"dbtest-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}"
|
||||
submission_id = f"sub-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
# --- seed owner + submitted initiative + draft payload ---
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="DB Test Applicant",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
payload = {
|
||||
"caseId": case_code,
|
||||
"updatedAt": "2026-01-01T12:00:00Z",
|
||||
"tabs": {},
|
||||
"submissionRecord": {
|
||||
"id": submission_id,
|
||||
"submittedDate": "2026-01-01T12:00:00.000Z",
|
||||
"name": "Original title",
|
||||
"author": {
|
||||
"id": case_code,
|
||||
"name": "DB Test Applicant",
|
||||
"email": owner_email,
|
||||
},
|
||||
"status": "submitted",
|
||||
"reviewStatus": "not_reviewed",
|
||||
},
|
||||
}
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload=payload,
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
|
||||
# --- update (same semantics as PUT /api/applications/{id}) ---
|
||||
async with get_session() as session:
|
||||
row = await update_my_submitted_application(
|
||||
session,
|
||||
owner_id,
|
||||
owner_email,
|
||||
submission_id,
|
||||
"Renamed via DB test",
|
||||
"2026-06-15",
|
||||
)
|
||||
|
||||
self.assertEqual(row.get("name"), "Renamed via DB test")
|
||||
self.assertIn("2026-06-15", str(row.get("submittedDate") or ""))
|
||||
|
||||
async with get_session() as session:
|
||||
loaded = await get_application_by_id(session, submission_id)
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual(loaded.get("name"), "Renamed via DB test")
|
||||
|
||||
# --- delete (same semantics as DELETE /api/applications/{id}) ---
|
||||
async with get_session() as session:
|
||||
await delete_my_submitted_application(session, owner_id, owner_email, submission_id)
|
||||
|
||||
async with get_session() as session:
|
||||
gone = await get_application_by_id(session, submission_id)
|
||||
ini_row = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one_or_none()
|
||||
|
||||
self.assertIsNone(gone)
|
||||
self.assertIsNone(ini_row)
|
||||
|
||||
# --- cleanup user (initiative already cascade-deleted) ---
|
||||
async with get_session() as session:
|
||||
u = await session.get(User, owner_id)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
async def test_get_application_by_id_matches_fallback_sub_id_when_record_has_no_id(self) -> None:
|
||||
"""List rows use sub-{initiative.id[:16]} when submissionRecord.id is absent; GET must accept the same id."""
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submissions import get_application_by_id
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"dbtest-fallback-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Fallback Id Test",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 3, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
# Must match `_submission_display_id`: first 16 hex digits of UUID, not `str(uuid)[:16]`.
|
||||
expected_list_id = f"sub-{ini.id.hex[:16]}"
|
||||
payload = {
|
||||
"caseId": case_code,
|
||||
"updatedAt": "2026-03-01T12:00:00Z",
|
||||
"tabs": {},
|
||||
"submissionRecord": {
|
||||
"submittedDate": "2026-03-01T12:00:00.000Z",
|
||||
"name": "No explicit submission id",
|
||||
"author": {
|
||||
"id": case_code,
|
||||
"name": "Fallback Id Test",
|
||||
"email": owner_email,
|
||||
},
|
||||
"status": "submitted",
|
||||
"reviewStatus": "not_reviewed",
|
||||
},
|
||||
}
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload=payload,
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
loaded = await get_application_by_id(session, expected_list_id)
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual(loaded.get("id"), expected_list_id)
|
||||
self.assertEqual(loaded.get("name"), "No explicit submission id")
|
||||
|
||||
async with get_session() as session:
|
||||
ini_row = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one()
|
||||
await session.delete(ini_row)
|
||||
u = await session.get(User, owner_id)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
async def test_update_forbidden_for_non_owner_mismatched_email(self) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submissions import update_my_submitted_application
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"owner-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
intruder_id = uuid.uuid4()
|
||||
intruder_email = f"other-{intruder_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}"
|
||||
submission_id = f"sub-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Owner",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
User(
|
||||
id=intruder_id,
|
||||
email=intruder_email,
|
||||
password_hash="-",
|
||||
full_name="Intruder",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 2, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload={
|
||||
"caseId": case_code,
|
||||
"submissionRecord": {
|
||||
"id": submission_id,
|
||||
"submittedDate": "2026-02-01T12:00:00.000Z",
|
||||
"name": "Sealed",
|
||||
"author": {"id": case_code, "name": "Owner", "email": owner_email},
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
with self.assertRaises(PermissionError):
|
||||
await update_my_submitted_application(
|
||||
session,
|
||||
intruder_id,
|
||||
intruder_email,
|
||||
submission_id,
|
||||
"Should not apply",
|
||||
"2026-02-02",
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one()
|
||||
d = (
|
||||
await session.execute(select(Draft).where(Draft.initiative_id == ini.id).limit(1))
|
||||
).scalar_one()
|
||||
name = (d.payload or {}).get("submissionRecord", {}).get("name")
|
||||
|
||||
self.assertEqual(name, "Sealed")
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one_or_none()
|
||||
if ini is not None:
|
||||
await session.delete(ini)
|
||||
u1 = await session.get(User, owner_id)
|
||||
u2 = await session.get(User, intruder_id)
|
||||
if u1 is not None:
|
||||
await session.delete(u1)
|
||||
if u2 is not None:
|
||||
await session.delete(u2)
|
||||
|
||||
async def test_save_submitted_application_rejects_when_readiness_not_met(self) -> None:
|
||||
"""Server readiness runs before official-form MinIO work (no S3_* required)."""
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submission_readiness import ApplicationSubmissionNotReadyError
|
||||
from src.initiative_db.submissions import save_submitted_application
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"ready-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"READYCASE-{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Readiness tester",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(case_code=case_code, owner_id=owner_id, status="draft")
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload={
|
||||
"caseId": case_code,
|
||||
"tabs": {},
|
||||
"updatedAt": datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
with self.assertRaises(ApplicationSubmissionNotReadyError) as ctx:
|
||||
await save_submitted_application(
|
||||
session,
|
||||
metadata={
|
||||
"caseId": case_code,
|
||||
"initiativeName": "Incomplete",
|
||||
"authorName": "X",
|
||||
"authorEmail": owner_email,
|
||||
},
|
||||
file_url="/submitted-initiatives/x.pdf",
|
||||
owner_user_id=owner_id,
|
||||
pdf_byte_size=50,
|
||||
pdf_sha256="ab" * 32,
|
||||
pdf_original_name="x.pdf",
|
||||
)
|
||||
self.assertTrue(len(ctx.exception.missing) > 0)
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one_or_none()
|
||||
if ini is not None:
|
||||
await session.delete(ini)
|
||||
u = await session.get(User, owner_id)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
@unittest.skipUnless(
|
||||
_HAS_MINIO,
|
||||
"Needs S3_* env — submit persists official forms via MinIO after snapshots.",
|
||||
)
|
||||
async def test_save_submitted_application_writes_storage_tables(self) -> None:
|
||||
"""Requires migration 002 (submit snapshots, taxonomy, workflow, artifacts)."""
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import (
|
||||
ApplicationArtifact,
|
||||
ApplicationSubmitSnapshot,
|
||||
ApplicationTaxonomy,
|
||||
ApplicationWorkflow,
|
||||
Draft,
|
||||
Initiative,
|
||||
User,
|
||||
)
|
||||
from src.initiative_db.submissions import save_submitted_application
|
||||
|
||||
from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"snaps-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"SNAPCASE-{uuid.uuid4().hex[:10]}"
|
||||
sha = "ab" * 32
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Snap tester",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(case_code=case_code, owner_id=owner_id, status="draft")
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
tabs_payload = minimal_tabs_bundle(initiative_name="Storage ext test")
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload={
|
||||
"caseId": case_code,
|
||||
"tabs": tabs_payload,
|
||||
"updatedAt": datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
ApplicationArtifact(
|
||||
initiative_id=ini.id,
|
||||
role="technical_evidence",
|
||||
storage_uri="initiatives/test/evidence-key.pdf",
|
||||
mime_type="application/pdf",
|
||||
byte_size=900,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await save_submitted_application(
|
||||
session,
|
||||
metadata={
|
||||
"caseId": case_code,
|
||||
"initiativeName": "Storage ext test",
|
||||
"authorName": "Snap",
|
||||
"authorEmail": owner_email,
|
||||
"subjectId": "math",
|
||||
"groupId": "g1",
|
||||
"topicType": "Hồ sơ PDF",
|
||||
},
|
||||
file_url="/submitted-initiatives/t.pdf",
|
||||
owner_user_id=owner_id,
|
||||
pdf_byte_size=123,
|
||||
pdf_sha256=sha,
|
||||
pdf_original_name="t.pdf",
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one()
|
||||
snaps = (
|
||||
await session.execute(
|
||||
select(ApplicationSubmitSnapshot).where(
|
||||
ApplicationSubmitSnapshot.initiative_id == ini.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
arts = (
|
||||
await session.execute(
|
||||
select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == ini.id)
|
||||
)
|
||||
).scalars().all()
|
||||
tax = (
|
||||
await session.execute(
|
||||
select(ApplicationTaxonomy).where(ApplicationTaxonomy.initiative_id == ini.id)
|
||||
)
|
||||
).scalar_one()
|
||||
wf = (
|
||||
await session.execute(
|
||||
select(ApplicationWorkflow).where(ApplicationWorkflow.initiative_id == ini.id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
self.assertEqual(len(snaps), 1)
|
||||
self.assertEqual(snaps[0].submission_record_id[:4], "sub-")
|
||||
full_pdf_rows = [a for a in arts if a.role == "full_pdf"]
|
||||
self.assertEqual(len(full_pdf_rows), 1)
|
||||
self.assertEqual(full_pdf_rows[0].byte_size, 123)
|
||||
self.assertEqual(tax.subject_id, "math")
|
||||
self.assertEqual(wf.review_status, "not_reviewed")
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one_or_none()
|
||||
if ini is not None:
|
||||
await session.delete(ini)
|
||||
u = await session.get(User, owner_id)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
async def test_draft_tab_save_records_tab_snapshot(self) -> None:
|
||||
"""Requires migration 002 (`draft_tab_snapshots`)."""
|
||||
from src.initiative_db.drafts import save_application_draft_tab
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import DraftTabSnapshot, Initiative, User
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"tabsnap-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"TABCASE-{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Tab snap",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await save_application_draft_tab(
|
||||
session, case_code, "report", {"title": "Chapter 1"}, owner_id=owner_id
|
||||
)
|
||||
await save_application_draft_tab(
|
||||
session, case_code, "report", {"title": "Chapter 2"}, owner_id=owner_id
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DraftTabSnapshot)
|
||||
.where(DraftTabSnapshot.initiative_id == ini.id)
|
||||
.where(DraftTabSnapshot.tab == "report")
|
||||
.order_by(DraftTabSnapshot.tab_version)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0].tab_version, 1)
|
||||
self.assertEqual(rows[0].payload.get("title"), "Chapter 1")
|
||||
self.assertEqual(rows[1].tab_version, 2)
|
||||
self.assertEqual(rows[1].payload.get("title"), "Chapter 2")
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one_or_none()
|
||||
if ini is not None:
|
||||
await session.delete(ini)
|
||||
u = await session.get(User, owner_id)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
async def test_admin_result_upsert_appears_in_decided_list(self) -> None:
|
||||
"""PUT-style upsert updates initiative status; decided lifecycle list includes the row."""
|
||||
from src.initiative_db.application_admin_results import upsert_admin_result
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submissions import list_submitted_applications
|
||||
|
||||
admin_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"admin-upsert-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
admin_email = f"admin-{admin_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"ADM-{uuid.uuid4().hex[:10]}"
|
||||
submission_id = f"sub-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Owner upsert",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
User(
|
||||
id=admin_id,
|
||||
email=admin_email,
|
||||
password_hash="-",
|
||||
full_name="Admin upsert",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload={
|
||||
"caseId": case_code,
|
||||
"updatedAt": "2026-04-01T12:00:00Z",
|
||||
"tabs": {},
|
||||
"submissionRecord": {
|
||||
"id": submission_id,
|
||||
"submittedDate": "2026-04-01T12:00:00.000Z",
|
||||
"name": "Upsert list test",
|
||||
"author": {
|
||||
"id": case_code,
|
||||
"name": "Owner upsert",
|
||||
"email": owner_email,
|
||||
},
|
||||
"status": "submitted",
|
||||
"reviewStatus": "not_reviewed",
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
await upsert_admin_result(
|
||||
session,
|
||||
submission_id,
|
||||
admin_id,
|
||||
decision="approved",
|
||||
feedback="ok",
|
||||
rationale=None,
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
out = await list_submitted_applications(
|
||||
session=session,
|
||||
page=1,
|
||||
page_size=50,
|
||||
name="",
|
||||
author_name="",
|
||||
reviewer_name="",
|
||||
status="",
|
||||
review_status="",
|
||||
date_from="",
|
||||
date_to="",
|
||||
sort_by="submittedDate",
|
||||
sort_order="desc",
|
||||
lifecycle="decided",
|
||||
)
|
||||
ids = {str(r.get("id")) for r in out.get("data") or []}
|
||||
self.assertIn(submission_id, ids)
|
||||
decided_row = next(
|
||||
(r for r in (out.get("data") or []) if str(r.get("id")) == submission_id),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(decided_row)
|
||||
assert decided_row is not None
|
||||
self.assertEqual(decided_row.get("nhan_xet"), "ok", "Admin feedback must surface as nhan_xet for «Nhận xét» in admin list")
|
||||
reviewer = decided_row.get("reviewer") or {}
|
||||
self.assertEqual(
|
||||
reviewer.get("name"),
|
||||
"Admin upsert",
|
||||
"Người đánh giá should show adjudicating admin users.full_name (updated_by)",
|
||||
)
|
||||
self.assertEqual(str(reviewer.get("id")), str(admin_id))
|
||||
|
||||
async with get_session() as session:
|
||||
ini_row = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one_or_none()
|
||||
if ini_row is not None:
|
||||
await session.delete(ini_row)
|
||||
for uid in (owner_id, admin_id):
|
||||
u = await session.get(User, uid)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
async def test_notification_inbox_after_admin_upsert(self) -> None:
|
||||
"""Requires migration 006 (`user_notifications`). Applicant receives inbox row after admin upsert + best_effort."""
|
||||
from src.initiative_db.application_admin_results import upsert_admin_result
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.user_notifications import (
|
||||
best_effort_notify_applicant_after_admin_decision,
|
||||
count_unread_notifications,
|
||||
list_notifications_for_user,
|
||||
mark_notification_read,
|
||||
)
|
||||
|
||||
admin_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"notif-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
admin_email = f"notif-adm-{admin_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"NTF-{uuid.uuid4().hex[:10]}"
|
||||
submission_id = f"sub-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Notif owner",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
User(
|
||||
id=admin_id,
|
||||
email=admin_email,
|
||||
password_hash="-",
|
||||
full_name="Notif admin",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload={
|
||||
"caseId": case_code,
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "research",
|
||||
"researchEvidenceKind": "international",
|
||||
}
|
||||
},
|
||||
"submissionRecord": {
|
||||
"id": submission_id,
|
||||
"submittedDate": "2026-05-01T12:00:00.000Z",
|
||||
"name": "Notif seed",
|
||||
"author": {
|
||||
"id": case_code,
|
||||
"name": "Notif owner",
|
||||
"email": owner_email,
|
||||
},
|
||||
"status": "submitted",
|
||||
"reviewStatus": "not_reviewed",
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
|
||||
result: dict
|
||||
async with get_session() as session:
|
||||
result = await upsert_admin_result(
|
||||
session,
|
||||
submission_id,
|
||||
admin_id,
|
||||
decision="approved",
|
||||
feedback="Kết quả thử nghiệm thông báo.",
|
||||
rationale=None,
|
||||
)
|
||||
|
||||
await best_effort_notify_applicant_after_admin_decision(result)
|
||||
|
||||
async with get_session() as session:
|
||||
unread_before = await count_unread_notifications(session, owner_id)
|
||||
inbox = await list_notifications_for_user(session, owner_id, page=1, page_size=10)
|
||||
|
||||
self.assertEqual(unread_before, 1)
|
||||
self.assertEqual(inbox["pagination"]["totalItems"], 1)
|
||||
row = inbox["data"][0]
|
||||
self.assertEqual(row["applicationId"], submission_id)
|
||||
self.assertEqual(row["decision"], "approved")
|
||||
self.assertEqual(row["meritCategoryLabel"], "Xuất sắc")
|
||||
self.assertIn("Kết quả thử nghiệm", row["feedback"])
|
||||
self.assertIsNone(row["readAt"])
|
||||
|
||||
nid = uuid.UUID(row["id"])
|
||||
async with get_session() as session:
|
||||
ok = await mark_notification_read(session, owner_id, nid)
|
||||
self.assertTrue(ok)
|
||||
unread_after = await count_unread_notifications(session, owner_id)
|
||||
self.assertEqual(unread_after, 0)
|
||||
|
||||
async with get_session() as session:
|
||||
ini_row = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_code))
|
||||
).scalar_one_or_none()
|
||||
if ini_row is not None:
|
||||
await session.delete(ini_row)
|
||||
for uid in (owner_id, admin_id):
|
||||
u = await session.get(User, uid)
|
||||
if u is not None:
|
||||
await session.delete(u)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Password reset + credential_version JWT integration.
|
||||
|
||||
Requires Postgres, migrations through 013 (email_verified) and **014** (registration_otp_codes).
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/012_password_reset.sql
|
||||
docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/013_email_verification.sql
|
||||
cd be0 && python -m unittest tests.test_auth_password_reset_integration -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from tests.auth_register_staff_fixture import register_staff_fields
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
|
||||
_TEST_PASSWORD = "Testpass1!"
|
||||
_NEW_PASSWORD = "Newpass1!"
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests",
|
||||
)
|
||||
class AuthPasswordResetIntegrationTests(unittest.TestCase):
|
||||
def _delete_user_email(self, email: str) -> None:
|
||||
"""Cleanup after TestClient — use a fresh engine (TestClient tears down the app engine)."""
|
||||
|
||||
async def go() -> None:
|
||||
from src.initiative_db.engine import dispose_engine, get_session, init_engine, is_postgres_enabled
|
||||
|
||||
if not is_postgres_enabled():
|
||||
return
|
||||
await init_engine()
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from src.initiative_db.models import User
|
||||
|
||||
async with get_session() as session:
|
||||
await session.execute(delete(User).where(User.email == email))
|
||||
finally:
|
||||
await dispose_engine()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
def _register(self, email: str) -> object:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def grab(_to: str, raw: str) -> None:
|
||||
captured.append(raw)
|
||||
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab):
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"fullName": "Reset Test",
|
||||
"email": email,
|
||||
"password": _TEST_PASSWORD,
|
||||
"passwordConfirm": _TEST_PASSWORD,
|
||||
**register_staff_fields(),
|
||||
},
|
||||
)
|
||||
if r.status_code == 200 and captured:
|
||||
client.post(
|
||||
"/api/v1/auth/verify-otp",
|
||||
json={"email": email, "otp": captured[0]},
|
||||
)
|
||||
return r
|
||||
|
||||
def test_forgot_password_unknown_email_same_message(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": f"nope-{uuid.uuid4().hex[:8]}@ump.edu.vn"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
msg = r.json().get("message", "")
|
||||
self.assertIn("Nếu email", msg)
|
||||
|
||||
def test_forgot_password_invalid_domain_400(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "x@gmail.com"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 400, r.text)
|
||||
|
||||
def test_reset_flow_login_and_stale_jwt(self) -> None:
|
||||
email = f"reset-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
try:
|
||||
reg = self._register(email)
|
||||
self.assertEqual(reg.status_code, 200, reg.text)
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
with TestClient(app) as client_pre:
|
||||
lg0 = client_pre.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(lg0.status_code, 200, lg0.text)
|
||||
access_before = lg0.json()["accessToken"]
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def grab(_to: str, raw: str) -> None:
|
||||
captured.append(raw)
|
||||
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with patch("src.auth_api.deliver_password_reset_email", side_effect=grab):
|
||||
with TestClient(app) as client:
|
||||
fr = client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": email},
|
||||
)
|
||||
self.assertEqual(fr.status_code, 200, fr.text)
|
||||
|
||||
rr = client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={
|
||||
"token": captured[0],
|
||||
"newPassword": _NEW_PASSWORD,
|
||||
"newPasswordConfirm": _NEW_PASSWORD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(rr.status_code, 200, rr.text)
|
||||
|
||||
bad = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(bad.status_code, 401, bad.text)
|
||||
|
||||
ok = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _NEW_PASSWORD},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
|
||||
me_old = client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {access_before}"},
|
||||
)
|
||||
self.assertEqual(me_old.status_code, 401, me_old.text)
|
||||
finally:
|
||||
self._delete_user_email(email)
|
||||
|
||||
def test_reset_token_single_use(self) -> None:
|
||||
email = f"reset2-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
try:
|
||||
reg = self._register(email)
|
||||
self.assertEqual(reg.status_code, 200, reg.text)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def grab(_to: str, raw: str) -> None:
|
||||
captured.append(raw)
|
||||
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with patch("src.auth_api.deliver_password_reset_email", side_effect=grab):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/v1/auth/forgot-password", json={"email": email})
|
||||
tok = captured[0]
|
||||
r1 = client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={
|
||||
"token": tok,
|
||||
"newPassword": _NEW_PASSWORD,
|
||||
"newPasswordConfirm": _NEW_PASSWORD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200, r1.text)
|
||||
r2 = client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={
|
||||
"token": tok,
|
||||
"newPassword": _NEW_PASSWORD,
|
||||
"newPasswordConfirm": _NEW_PASSWORD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(r2.status_code, 400, r2.text)
|
||||
finally:
|
||||
self._delete_user_email(email)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Auth policy integration: server-derived admin vs viewer, ignored client role, UMC domain.
|
||||
|
||||
Requires Postgres + migration 007 (admin_from_email_policy column) and 013 (email_verified).
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
cd be0 && python -m unittest tests.test_auth_policy_integration -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.auth_register_staff_fixture import register_staff_fields
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
|
||||
_TEST_PASSWORD = "Testpass1!"
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests",
|
||||
)
|
||||
class AuthPolicyIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def _delete_user_email(self, email: str) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import User
|
||||
|
||||
async with get_session() as session:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one_or_none()
|
||||
if user is not None:
|
||||
await session.delete(user)
|
||||
|
||||
def _register(self, email: str, full_name: str = "Policy Test", extra: dict | None = None):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
body = {
|
||||
"fullName": full_name,
|
||||
"email": email,
|
||||
"password": _TEST_PASSWORD,
|
||||
"passwordConfirm": _TEST_PASSWORD,
|
||||
**register_staff_fields(),
|
||||
}
|
||||
if extra:
|
||||
body.update(extra)
|
||||
with TestClient(app) as client:
|
||||
return client.post("/api/v1/auth/register", json=body)
|
||||
|
||||
async def test_register_default_viewer_ump(self) -> None:
|
||||
email = f"applicant-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
try:
|
||||
r = self._register(email)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
data = r.json()
|
||||
self.assertTrue(data.get("emailVerificationRequired"))
|
||||
self.assertNotIn("accessToken", data)
|
||||
roles = data["user"]["roles"]
|
||||
self.assertIn("viewer", roles)
|
||||
self.assertNotIn("admin", roles)
|
||||
finally:
|
||||
await self._delete_user_email(email)
|
||||
|
||||
async def test_register_default_viewer_umc(self) -> None:
|
||||
email = f"applicant-{uuid.uuid4().hex[:12]}@umc.edu.vn"
|
||||
try:
|
||||
r = self._register(email)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(r.json().get("emailVerificationRequired"))
|
||||
roles = r.json()["user"]["roles"]
|
||||
self.assertIn("viewer", roles)
|
||||
self.assertNotIn("admin", roles)
|
||||
finally:
|
||||
await self._delete_user_email(email)
|
||||
|
||||
async def test_register_ignores_client_admin_role(self) -> None:
|
||||
email = f"privesc-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
try:
|
||||
r = self._register(
|
||||
email,
|
||||
extra={"role": "admin"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(r.json().get("emailVerificationRequired"))
|
||||
roles = r.json()["user"]["roles"]
|
||||
self.assertIn("viewer", roles)
|
||||
self.assertNotIn("admin", roles)
|
||||
finally:
|
||||
await self._delete_user_email(email)
|
||||
|
||||
async def test_policy_env_makes_admin(self) -> None:
|
||||
email = f"stub-admin-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
try:
|
||||
with patch.dict(os.environ, {"AUTH_ADMIN_EMAILS": email}):
|
||||
r = self._register(email)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(r.json().get("emailVerificationRequired"))
|
||||
roles = r.json()["user"]["roles"]
|
||||
self.assertIn("admin", roles)
|
||||
self.assertNotIn("viewer", roles)
|
||||
finally:
|
||||
await self._delete_user_email(email)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for the AuthenticateUser use case using fakes (no DB, no FastAPI).
|
||||
|
||||
Async use case is driven via ``asyncio.run`` so no pytest-asyncio plugin is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from src.application.identity.dto import LoginCommand
|
||||
from src.application.identity.use_cases.authenticate_user import AuthenticateUser
|
||||
from src.domain.identity.entities import User
|
||||
from src.domain.identity.errors import (
|
||||
EmailNotVerified,
|
||||
InvalidCredentials,
|
||||
InvalidInstitutionalEmail,
|
||||
)
|
||||
from src.shared_kernel.errors import RateLimited
|
||||
|
||||
|
||||
class FakeUsers:
|
||||
def __init__(self, user: User | None = None, roles: list[str] | None = None) -> None:
|
||||
self._user = user
|
||||
self._roles = roles or []
|
||||
self.reconciled = False
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
return self._user if (self._user and self._user.email == email) else None
|
||||
|
||||
async def get_by_id(self, user_id): # pragma: no cover - unused here
|
||||
return self._user
|
||||
|
||||
async def roles_after_reconcile(self, user: User) -> list[str]:
|
||||
self.reconciled = True
|
||||
return self._roles
|
||||
|
||||
|
||||
class FakeHasher:
|
||||
def __init__(self, ok: bool = True) -> None:
|
||||
self.ok = ok
|
||||
|
||||
def hash(self, plain: str) -> str:
|
||||
return "h:" + plain
|
||||
|
||||
def verify(self, plain: str, hashed: str) -> bool:
|
||||
return self.ok
|
||||
|
||||
|
||||
class FakeTokens:
|
||||
def issue(self, user_id, email, roles, credential_version) -> str:
|
||||
return f"tok:{user_id}:{credential_version}:{','.join(roles)}"
|
||||
|
||||
|
||||
class FakeRateLimiter:
|
||||
def __init__(self, allow: bool = True) -> None:
|
||||
self._allow = allow
|
||||
|
||||
def allow(self, email: str, client_ip: str) -> bool:
|
||||
return self._allow
|
||||
|
||||
|
||||
class FakeAudit:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple] = []
|
||||
|
||||
async def login_succeeded(self, *, user_id, email, roles) -> None:
|
||||
self.events.append(("ok", email, tuple(roles)))
|
||||
|
||||
async def login_failed(self, *, email, user_id, reason) -> None:
|
||||
self.events.append(("fail", email, reason))
|
||||
|
||||
|
||||
def _user(**kw) -> User:
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
email="a@ump.edu.vn",
|
||||
full_name="Test",
|
||||
password_hash="x",
|
||||
email_verified=True,
|
||||
is_active=True,
|
||||
credential_version=2,
|
||||
)
|
||||
base.update(kw)
|
||||
return User(**base)
|
||||
|
||||
|
||||
def _build(users: FakeUsers, hasher=None, rate_limiter=None, audit=None) -> tuple:
|
||||
audit = audit or FakeAudit()
|
||||
uc = AuthenticateUser(
|
||||
users=users,
|
||||
hasher=hasher or FakeHasher(ok=True),
|
||||
tokens=FakeTokens(),
|
||||
rate_limiter=rate_limiter or FakeRateLimiter(allow=True),
|
||||
audit=audit,
|
||||
)
|
||||
return uc, audit
|
||||
|
||||
|
||||
def test_login_success_returns_token_and_reconciles_roles() -> None:
|
||||
users = FakeUsers(user=_user(), roles=["admin", "viewer"])
|
||||
uc, audit = _build(users)
|
||||
result = asyncio.run(uc.execute(LoginCommand("A@ump.edu.vn", "pw", "1.2.3.4")))
|
||||
assert result.access_token.endswith(":2:admin,viewer")
|
||||
assert result.roles == ["admin", "viewer"]
|
||||
assert users.reconciled is True
|
||||
assert audit.events == [("ok", "a@ump.edu.vn", ("admin", "viewer"))]
|
||||
|
||||
|
||||
def test_wrong_password_raises_401_and_audits_failure() -> None:
|
||||
users = FakeUsers(user=_user())
|
||||
uc, audit = _build(users, hasher=FakeHasher(ok=False))
|
||||
with pytest.raises(InvalidCredentials) as exc:
|
||||
asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "bad", "ip")))
|
||||
assert exc.value.message == "Email hoặc mật khẩu không đúng."
|
||||
assert audit.events == [("fail", "a@ump.edu.vn", None)]
|
||||
|
||||
|
||||
def test_unknown_email_raises_401() -> None:
|
||||
uc, _ = _build(FakeUsers(user=None))
|
||||
with pytest.raises(InvalidCredentials):
|
||||
asyncio.run(uc.execute(LoginCommand("nobody@ump.edu.vn", "pw", "ip")))
|
||||
|
||||
|
||||
def test_unverified_email_raises_403_with_reason() -> None:
|
||||
users = FakeUsers(user=_user(email_verified=False))
|
||||
uc, audit = _build(users)
|
||||
with pytest.raises(EmailNotVerified):
|
||||
asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "pw", "ip")))
|
||||
assert audit.events == [("fail", "a@ump.edu.vn", "email_unverified")]
|
||||
|
||||
|
||||
def test_rate_limited_raises_429_before_db() -> None:
|
||||
users = FakeUsers(user=_user())
|
||||
uc, _ = _build(users, rate_limiter=FakeRateLimiter(allow=False))
|
||||
with pytest.raises(RateLimited):
|
||||
asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "pw", "ip")))
|
||||
|
||||
|
||||
def test_non_institutional_email_rejected_before_lookup() -> None:
|
||||
users = FakeUsers(user=_user())
|
||||
uc, _ = _build(users)
|
||||
with pytest.raises(InvalidInstitutionalEmail):
|
||||
asyncio.run(uc.execute(LoginCommand("a@gmail.com", "pw", "ip")))
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Full-stack backup E2E: HTTP submit (PDF → Postgres + MinIO) then admin ZIP download.
|
||||
|
||||
Requires PostgreSQL with migrations through **009** and reachable **MinIO (S3 API)**.
|
||||
|
||||
Run (host → docker-compose ports):
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
export S3_ENDPOINT_URL="http://127.0.0.1:19000"
|
||||
export S3_PUBLIC_ENDPOINT_URL="http://127.0.0.1:19000"
|
||||
export S3_ACCESS_KEY="minio_user"
|
||||
export S3_SECRET_KEY="minio_password"
|
||||
export S3_BUCKET_ATTACHMENTS="initiative-attachments"
|
||||
export S3_BUCKET_EXPORTS="initiative-exports"
|
||||
export S3_BUCKET_QUARANTINE="initiative-quarantine"
|
||||
export E2E_BACKUP=1
|
||||
cd be0 && python -m unittest tests.test_backup_e2e -v
|
||||
|
||||
Browser E2E: see ``fe0/e2e/backup-admin-download.spec.ts`` (requires the same stack plus ``fe0`` + ``E2E_ADMIN_EMAIL`` on ``AUTH_ADMIN_EMAILS``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.auth_register_staff_fixture import register_staff_fields
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
_S3_KEYS = (
|
||||
"S3_ENDPOINT_URL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_BUCKET_ATTACHMENTS",
|
||||
"S3_BUCKET_EXPORTS",
|
||||
"S3_BUCKET_QUARANTINE",
|
||||
)
|
||||
_HAS_S3 = all(os.getenv(k, "").strip() for k in _S3_KEYS)
|
||||
_RUN_BACKUP = os.getenv("E2E_BACKUP", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
_TEST_PASSWORD = "Testpass1!"
|
||||
|
||||
_MIN_PDF = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n" + b"0" * 120
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB and _HAS_S3 and _RUN_BACKUP,
|
||||
"Need INITIATIVE_DATABASE_URL, full S3_* env, and E2E_BACKUP=1 (see module docstring).",
|
||||
)
|
||||
class BackupFullStackApiE2ETests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def _delete_users_by_email(self, emails: list[str]) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Initiative, User
|
||||
|
||||
async with get_session() as session:
|
||||
for email in emails:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
continue
|
||||
inis = (
|
||||
await session.execute(select(Initiative).where(Initiative.owner_id == user.id))
|
||||
).scalars().all()
|
||||
for ini in inis:
|
||||
await session.delete(ini)
|
||||
await session.flush()
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
async def test_submit_creates_minio_full_pdf_then_backup_zip(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import ApplicationArtifact, Initiative
|
||||
|
||||
applicant_email = f"e2e-backup-app-{uuid.uuid4().hex[:10]}@ump.edu.vn"
|
||||
admin_email = f"e2e-backup-adm-{uuid.uuid4().hex[:10]}@ump.edu.vn"
|
||||
|
||||
try:
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with TestClient(app) as client:
|
||||
cap_app: list[str] = []
|
||||
|
||||
async def grab_app(_t: str, raw: str) -> None:
|
||||
cap_app.append(raw)
|
||||
|
||||
with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab_app):
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"fullName": "E2E Applicant",
|
||||
"email": applicant_email,
|
||||
"password": _TEST_PASSWORD,
|
||||
"passwordConfirm": _TEST_PASSWORD,
|
||||
**register_staff_fields(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(cap_app)
|
||||
r = client.post(
|
||||
"/api/v1/auth/verify-otp",
|
||||
json={"email": applicant_email, "otp": cap_app[0]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": applicant_email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
applicant_token = r.json()["accessToken"]
|
||||
|
||||
r = client.post(
|
||||
"/api/applications/new",
|
||||
headers={"Authorization": f"Bearer {applicant_token}"},
|
||||
json={"name": "E2E backup row"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
shell = r.json().get("application") or {}
|
||||
case_id = str(shell.get("draft_case_id") or "").strip()
|
||||
application_id = str(r.json().get("id") or shell.get("id") or "").strip()
|
||||
self.assertTrue(case_id, shell)
|
||||
self.assertTrue(application_id, shell)
|
||||
|
||||
from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle
|
||||
|
||||
bundle = minimal_tabs_bundle(initiative_name="E2E Backup Initiative")
|
||||
for tab_name in ("report", "application", "contribution"):
|
||||
r = client.post(
|
||||
"/api/v1/application-drafts",
|
||||
headers={"Authorization": f"Bearer {applicant_token}"},
|
||||
json={"caseId": case_id, "tab": tab_name, "data": bundle[tab_name]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
|
||||
r = client.post(
|
||||
f"/api/v1/application-drafts/{case_id}/evidence",
|
||||
headers={"Authorization": f"Bearer {applicant_token}"},
|
||||
data={"kind": "technical"},
|
||||
files={"file": ("mc.pdf", io.BytesIO(_MIN_PDF), "application/pdf")},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
|
||||
meta = {
|
||||
"initiativeCaseId": case_id,
|
||||
"initiativeName": "E2E Backup Initiative",
|
||||
"authorName": "Applicant",
|
||||
"authorEmail": applicant_email,
|
||||
"subjectId": "s1",
|
||||
"groupId": "g1",
|
||||
"topicType": "Hồ sơ PDF",
|
||||
}
|
||||
r = client.post(
|
||||
"/api/applications/submit",
|
||||
headers={"Authorization": f"Bearer {applicant_token}"},
|
||||
files={"file": ("e2e.pdf", io.BytesIO(_MIN_PDF), "application/pdf")},
|
||||
data={"metadata": json.dumps(meta)},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text, r.text)
|
||||
application_id = str((r.json() or {}).get("id") or application_id)
|
||||
|
||||
with patch.dict(os.environ, {"AUTH_ADMIN_EMAILS": admin_email}):
|
||||
cap_adm: list[str] = []
|
||||
|
||||
async def grab_adm(_t: str, raw: str) -> None:
|
||||
cap_adm.append(raw)
|
||||
|
||||
with patch(
|
||||
"src.auth_api.deliver_registration_otp_email",
|
||||
side_effect=grab_adm,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"fullName": "E2E Admin",
|
||||
"email": admin_email,
|
||||
"password": _TEST_PASSWORD,
|
||||
"passwordConfirm": _TEST_PASSWORD,
|
||||
**register_staff_fields(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(cap_adm)
|
||||
self.assertIn("admin", r.json()["user"]["roles"])
|
||||
r = client.post(
|
||||
"/api/v1/auth/verify-otp",
|
||||
json={"email": admin_email, "otp": cap_adm[0]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
admin_token = r.json()["accessToken"]
|
||||
|
||||
r = client.get(
|
||||
f"/api/applications/{application_id}/backup",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.headers.get("content-type", "").split(";")[0], "application/zip")
|
||||
|
||||
buf = io.BytesIO(r.content)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
names = zf.namelist()
|
||||
self.assertIn("manifest.json", names)
|
||||
self.assertIn("submitted/full-package.pdf", names)
|
||||
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
self.assertEqual(str(manifest.get("applicationId")), application_id)
|
||||
self.assertIn("initiative_id", manifest)
|
||||
packed = {str(x.get("zip_path")): x for x in manifest.get("files") or []}
|
||||
self.assertIn("submitted/full-package.pdf", packed)
|
||||
self.assertFalse(packed["submitted/full-package.pdf"].get("skipped"))
|
||||
|
||||
async with get_session() as session:
|
||||
ini = (
|
||||
await session.execute(select(Initiative).where(Initiative.case_code == case_id))
|
||||
).scalar_one()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(ApplicationArtifact).where(
|
||||
ApplicationArtifact.initiative_id == ini.id,
|
||||
ApplicationArtifact.role == "full_pdf",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
self.assertIsNotNone(row)
|
||||
assert row is not None
|
||||
uri = (row.storage_uri or "").strip()
|
||||
self.assertTrue(uri.startswith("initiatives/"), uri)
|
||||
self.assertEqual(row.storage_kind, "minio_exports")
|
||||
finally:
|
||||
await self._delete_users_by_email([applicant_email, admin_email])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
GET /api/conferences and /api/supervisors — dashboard filter lookups.
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_dashboard_lookup_routes -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class DashboardLookupRoutesTests(unittest.TestCase):
|
||||
def test_no_db_returns_empty_lists(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
with patch.object(eng, "is_postgres_enabled", return_value=False):
|
||||
client = TestClient(app)
|
||||
r1 = client.get("/api/conferences")
|
||||
r2 = client.get("/api/supervisors")
|
||||
self.assertEqual(r1.status_code, 200, r1.text)
|
||||
self.assertEqual(r2.status_code, 200, r2.text)
|
||||
self.assertEqual(r1.json(), [])
|
||||
self.assertEqual(r2.json(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Tests for OOXML normalization used after docxtpl render."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
from src.be01.docx_normalize import (
|
||||
collapse_empty_page_break_paragraphs_in_docx,
|
||||
force_times_new_roman_in_styles_docx,
|
||||
move_signature_date_to_top_row,
|
||||
normalize_bo_y_te_header_lines,
|
||||
relax_justified_softbreak_paragraphs_in_docx,
|
||||
shift_selected_header_lines_left,
|
||||
strip_mau_04_evaluation_section_in_docx,
|
||||
strip_table_row_height_rules_from_docx,
|
||||
)
|
||||
|
||||
|
||||
def _wrap_doc_in_zip(doc_xml: bytes) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _read_document_xml(docx_bytes: bytes) -> str:
|
||||
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as z:
|
||||
return z.read("word/document.xml").decode("utf-8")
|
||||
|
||||
|
||||
class DocxNormalizeTests(unittest.TestCase):
|
||||
def test_strip_tr_height_removes_self_closing(self) -> None:
|
||||
xml = (
|
||||
b'<?xml version="1.0" encoding="UTF-8"?><w:document '
|
||||
b'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
b"<w:tbl><w:tr><w:trPr>"
|
||||
b'<w:trHeight w:val="720" w:hRule="atLeast"/>'
|
||||
b"</w:trPr><w:tc><w:p><w:r><w:t>a</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"
|
||||
b"</w:document>"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = strip_table_row_height_rules_from_docx(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
self.assertNotIn("trHeight", doc)
|
||||
self.assertNotIn("720", doc)
|
||||
|
||||
def test_normalize_bo_y_te_strips_ministry_bold_centers(self) -> None:
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="left"/><w:ind w:left="3600"/></w:pPr>
|
||||
<w:r><w:rPr><w:b w:val="1"/><w:rFonts w:ascii="Arial"/></w:rPr><w:t>BỘ Y TẾ</w:t></w:r>
|
||||
</w:p>
|
||||
<w:p><w:pPr><w:jc w:val="center"/><w:ind w:left="-150"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Arial"/></w:rPr><w:t>ĐẠI HỘC Y DƯỢC</w:t><w:br/><w:t>THÀNH PHỐ HỒ CHÍ MINH</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
phase1 = shift_selected_header_lines_left(buf.getvalue())
|
||||
out = normalize_bo_y_te_header_lines(phase1)
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
ministry = re.search(r"<[^>]*:p\b[^>]*>.*?BỘ Y TẾ.*?</[^>]*:p>", doc, re.DOTALL | re.IGNORECASE)
|
||||
self.assertIsNotNone(ministry)
|
||||
assert ministry is not None
|
||||
block = ministry.group(0)
|
||||
self.assertNotIn("ns0:b", block.split("BỘ Y TẾ")[0])
|
||||
self.assertIn('val="center"', block)
|
||||
uni = re.search(r"<[^>]*:p\b[^>]*>.*?ĐẠI HỘC Y DƯỢC.*?</[^>]*:p>", doc, re.DOTALL | re.IGNORECASE)
|
||||
self.assertIsNotNone(uni)
|
||||
assert uni is not None
|
||||
self.assertIn("ns0:b", uni.group(0))
|
||||
self.assertIn("Times New Roman", uni.group(0))
|
||||
|
||||
def test_university_letterhead_two_paragraphs_bold_centered(self) -> None:
|
||||
"""Cover may use two paragraphs instead of one line break."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="left"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Arial"/></w:rPr><w:t>ĐẠI HỘC Y DƯỢC</w:t></w:r>
|
||||
</w:p>
|
||||
<w:p><w:pPr><w:jc w:val="right"/><w:ind w:left="-150"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Arial"/></w:rPr><w:t>THÀNH PHỐ HỒ CHÍ MINH</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = normalize_bo_y_te_header_lines(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
for label, needle in (
|
||||
("dhyd", "ĐẠI HỘC Y DƯỢC"),
|
||||
("tphcm", "THÀNH PHỐ HỒ CHÍ MINH"),
|
||||
):
|
||||
blk = re.search(
|
||||
rf"<[^>]*:p\b[^>]*>.*?{re.escape(needle)}.*?</[^>]*:p>",
|
||||
doc,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
self.assertIsNotNone(blk, msg=label)
|
||||
assert blk is not None
|
||||
b = blk.group(0)
|
||||
self.assertIn("ns0:b", b, msg=label)
|
||||
self.assertIn('val="center"', b, msg=label)
|
||||
|
||||
def test_university_letterhead_one_paragraph_gets_soft_break_inserted(self) -> None:
|
||||
"""When both letterhead phrases share one paragraph on a single visual line, a
|
||||
soft <w:br/> is inserted before the city line so the cover renders on two lines.
|
||||
|
||||
Also asserts the runs end up bold + upright (no italic) + Times New Roman."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="left"/></w:pPr>
|
||||
<w:r><w:rPr><w:i w:val="1"/><w:rFonts w:ascii="Arial"/></w:rPr><w:t>ĐẠI HỘC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = normalize_bo_y_te_header_lines(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
# A soft break should now sit between the two phrases.
|
||||
self.assertRegex(
|
||||
doc,
|
||||
r"ĐẠI HỘC Y DƯỢC.*?<[^>]*:br[^>]*/?>.*?THÀNH PHỐ HỒ CHÍ MINH",
|
||||
)
|
||||
# Paragraph is centered, runs are bold + not italic + Times New Roman.
|
||||
self.assertIn('val="center"', doc)
|
||||
self.assertIn("ns0:b", doc)
|
||||
self.assertIn('ns0:i ns0:val="0"', doc)
|
||||
self.assertIn("Times New Roman", doc)
|
||||
|
||||
def test_university_letterhead_soft_break_idempotent(self) -> None:
|
||||
"""Running normalize twice should not stack additional <w:br/> elements between
|
||||
the letterhead phrases."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="left"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Arial"/></w:rPr><w:t>ĐẠI HỘC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
once = normalize_bo_y_te_header_lines(buf.getvalue())
|
||||
twice = normalize_bo_y_te_header_lines(once)
|
||||
with zipfile.ZipFile(io.BytesIO(twice)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
br_count = len(re.findall(r"<[^>]*:br\b[^>]*/?>", doc))
|
||||
self.assertEqual(br_count, 1, msg=f"expected exactly one <w:br/>, got {br_count}: {doc!r}")
|
||||
|
||||
def test_first_page_scope_second_bo_te_unchanged(self) -> None:
|
||||
"""Only the cover « BỘ Y TẾ » is stripped of bold; a later duplicate keeps bold."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:rPr><w:b w:val="1"/></w:rPr><w:t>BỘ Y TẾ</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>ĐẠI HỘC Y DƯỢC</w:t><w:br/><w:t>THÀNH PHỐ HỒ CHÍ MINH</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:rPr><w:b w:val="1"/></w:rPr><w:t>BỘ Y TẾ</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = normalize_bo_y_te_header_lines(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
paras = re.findall(r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", doc, re.DOTALL | re.IGNORECASE)
|
||||
self.assertEqual(len(paras), 4, msg="expected 4 paragraphs")
|
||||
first_bo = paras[0]
|
||||
late_bo = paras[3]
|
||||
self.assertNotIn("ns0:b", first_bo.split("BỘ Y TẾ")[0])
|
||||
self.assertIn("ns0:b", late_bo)
|
||||
|
||||
def test_move_signature_date_creates_full_width_top_row(self) -> None:
|
||||
"""The date paragraph is lifted into a single-cell top row spanning every column."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:tbl>
|
||||
<w:tblGrid><w:gridCol w:w="4702"/><w:gridCol w:w="4702"/></w:tblGrid>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>LÃNH ĐẠO ĐƠN VỊ</w:t></w:r></w:p><w:p><w:r><w:t>(Ký, ghi rõ họ tên)</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026</w:t></w:r></w:p><w:p><w:r><w:t>Tác giả sáng kiến</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = move_signature_date_to_top_row(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
|
||||
rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?</[^>]*:tr>", doc, re.DOTALL)
|
||||
self.assertEqual(len(rows), 2, msg=f"expected 2 rows after lift, got: {doc!r}")
|
||||
|
||||
first_row, second_row = rows
|
||||
# Top row: single cell, gridSpan=2, contains the date, right-aligned.
|
||||
self.assertEqual(first_row.count("<ns0:tc>") + first_row.count("<ns0:tc "), 1)
|
||||
self.assertRegex(first_row, r'<[^>]*:gridSpan\s+[^>]*:val="2"')
|
||||
self.assertIn("Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026", first_row)
|
||||
self.assertRegex(first_row, r'<[^>]*:jc\s+[^>]*:val="right"')
|
||||
|
||||
# Second row: original 2 cells. Right cell starts with "Tác giả sáng kiến"
|
||||
# (no date paragraph anymore), so it aligns with "LÃNH ĐẠO ĐƠN VỊ".
|
||||
self.assertNotIn("Tp. Hồ Chí Minh, ngày", second_row)
|
||||
self.assertIn("LÃNH ĐẠO ĐƠN VỊ", second_row)
|
||||
self.assertIn("Tác giả sáng kiến", second_row)
|
||||
|
||||
def test_move_signature_date_is_idempotent(self) -> None:
|
||||
"""A second pass over an already-lifted table is a no-op (still exactly 2 rows)."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:tbl>
|
||||
<w:tblGrid><w:gridCol w:w="4702"/><w:gridCol w:w="4702"/></w:tblGrid>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>LÃNH ĐẠO ĐƠN VỊ</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026</w:t></w:r></w:p><w:p><w:r><w:t>Tác giả sáng kiến</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
once = move_signature_date_to_top_row(buf.getvalue())
|
||||
twice = move_signature_date_to_top_row(once)
|
||||
with zipfile.ZipFile(io.BytesIO(twice)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?</[^>]*:tr>", doc, re.DOTALL)
|
||||
self.assertEqual(len(rows), 2, msg=f"expected 2 rows after second pass, got: {doc!r}")
|
||||
date_hits = doc.count("Tp. Hồ Chí Minh, ngày")
|
||||
self.assertEqual(date_hits, 1, msg=f"date should appear exactly once, got {date_hits}")
|
||||
|
||||
def test_move_signature_date_skips_table_without_date(self) -> None:
|
||||
"""Tables that do not contain the date prefix are left untouched."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:tbl>
|
||||
<w:tblGrid><w:gridCol w:w="4702"/><w:gridCol w:w="4702"/></w:tblGrid>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>cell A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>cell B</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = move_signature_date_to_top_row(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
doc = z2.read("word/document.xml").decode("utf-8")
|
||||
rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?</[^>]*:tr>", doc, re.DOTALL)
|
||||
self.assertEqual(len(rows), 1, msg="non-signature tables must be left untouched")
|
||||
|
||||
def test_relax_justified_splits_paragraph_at_soft_break_in_run(self) -> None:
|
||||
"""Justified paragraph with a soft <w:br/> mid-run is split into two paragraphs.
|
||||
Both fragments keep <w:jc w:val="both"/> so the layout stays justified, and the
|
||||
line that used to be followed by the soft break (« first chunk ») becomes the
|
||||
last line of its own paragraph -> stops being stretched."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="both"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Arial"/></w:rPr><w:t>first chunk</w:t><w:br/><w:t>second chunk</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertNotRegex(
|
||||
doc, r"<[^>]*:br\b(?![^>]*:type=\"page\")[^>]*/?>",
|
||||
msg="soft <w:br/> should be consumed by the split",
|
||||
)
|
||||
paras = re.findall(r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", doc, re.DOTALL)
|
||||
self.assertEqual(len(paras), 2, msg=f"expected 2 paragraphs after split: {doc!r}")
|
||||
for p in paras:
|
||||
self.assertRegex(p, r'<[^>]*:jc\s+[^>]*:val="both"')
|
||||
self.assertIn("Arial", p) # run properties preserved on both fragments
|
||||
self.assertIn("first chunk", paras[0])
|
||||
self.assertIn("second chunk", paras[1])
|
||||
self.assertNotIn("second chunk", paras[0])
|
||||
|
||||
def test_relax_justified_distribute_becomes_both(self) -> None:
|
||||
"""`distribute` stretches every line including the last; rewrite it to `both`."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="distribute"/></w:pPr><w:r><w:t>solo line</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertNotIn('val="distribute"', doc)
|
||||
self.assertRegex(doc, r'<[^>]*:jc\s+[^>]*:val="both"')
|
||||
|
||||
def test_relax_justified_rewrites_distribute_in_styles_xml(self) -> None:
|
||||
"""Paragraph styles may use ``distribute``; rewrite so body text is justified like Word ``both``."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>""".encode("utf-8")
|
||||
styles_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="BodyText">
|
||||
<w:pPr><w:jc w:val="distribute"/></w:pPr>
|
||||
</w:style>
|
||||
</w:styles>""".encode("utf-8")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr("word/styles.xml", styles_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
styles = z2.read("word/styles.xml").decode("utf-8")
|
||||
self.assertNotIn('val="distribute"', styles)
|
||||
self.assertIn('val="both"', styles)
|
||||
|
||||
def test_relax_justified_merges_do_not_expand_shift_return_in_settings(self) -> None:
|
||||
"""Compatibility flag so lines ending in soft breaks are not fully stretched when justified."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>""".encode("utf-8")
|
||||
settings_xml = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:zoom w:percent="100"/>
|
||||
</w:settings>"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/document.xml", doc_xml)
|
||||
z.writestr("word/settings.xml", settings_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
settings = z2.read("word/settings.xml").decode("utf-8")
|
||||
self.assertIn("doNotExpandShiftReturn", settings)
|
||||
self.assertRegex(settings, r'doNotExpandShiftReturn[^>]*val="1"')
|
||||
|
||||
def test_relax_justified_preserves_non_justified_paragraphs(self) -> None:
|
||||
"""Soft breaks in non-justified paragraphs are left alone (no surprise splits)."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="left"/></w:pPr>
|
||||
<w:r><w:t>line1</w:t><w:br/><w:t>line2</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
paras = re.findall(r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", doc, re.DOTALL)
|
||||
self.assertEqual(len(paras), 1, msg="left-aligned paragraphs must not be split")
|
||||
self.assertRegex(doc, r"<[^>]*:br\b[^>]*/?>", msg="soft break should survive")
|
||||
|
||||
def test_relax_justified_preserves_page_break(self) -> None:
|
||||
"""Page breaks (`<w:br w:type="page"/>`) are NOT treated as soft breaks."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="both"/></w:pPr>
|
||||
<w:r><w:t>before</w:t><w:br w:type="page"/><w:t>after</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
paras = re.findall(r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", doc, re.DOTALL)
|
||||
self.assertEqual(len(paras), 1, msg="page breaks must not trigger paragraph split")
|
||||
self.assertRegex(doc, r'<[^>]*:br\s+[^>]*:type="page"')
|
||||
|
||||
def test_relax_justified_idempotent(self) -> None:
|
||||
"""Running twice produces the same output as running once."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:pPr><w:jc w:val="both"/></w:pPr>
|
||||
<w:r><w:t>aaa</w:t><w:br/><w:t>bbb</w:t><w:br/><w:t>ccc</w:t></w:r>
|
||||
</w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
once = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
twice = relax_justified_softbreak_paragraphs_in_docx(once)
|
||||
self.assertEqual(
|
||||
_read_document_xml(once),
|
||||
_read_document_xml(twice),
|
||||
msg="second pass should be a no-op",
|
||||
)
|
||||
paras = re.findall(
|
||||
r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", _read_document_xml(once), re.DOTALL
|
||||
)
|
||||
self.assertEqual(len(paras), 3, msg="two soft breaks should yield three paragraphs")
|
||||
|
||||
def test_strip_mau_04_removes_section_between_page_breaks(self) -> None:
|
||||
"""Body order: mau_03 sig, page-break, letterhead table, « Mẫu số 04 », content,
|
||||
page-break, « Bản cam kết ». Strip should drop everything from the leading
|
||||
page-break paragraph through the last Mẫu số 04 content paragraph (inclusive),
|
||||
keeping the trailing page-break paragraph that opens « Bản cam kết »."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>{{ mau_03.tac_gia_chinh_ky }}</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>BỘ Y TẾ</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:r><w:t>Mẫu số 04</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>PHIẾU ĐÁNH GIÁ SÁNG KIẾN</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>1. Tên sáng kiến: {{ mau_04.ten_sang_kien }}</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>Kết luận: {{ mau_04.ket_luan }}</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>{{ mau_04.thanh_vien_hoi_dong }}</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>CỘNG HOÀ XÃ HỘI CHỦ NGHĨA VIỆT NAM</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>BẢN CAM KẾT</w:t></w:r></w:p>
|
||||
<w:sectPr/>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertNotIn("Mẫu số 04", doc)
|
||||
self.assertNotIn("PHIẾU ĐÁNH GIÁ", doc)
|
||||
self.assertNotIn("mau_04", doc)
|
||||
# The leading page break + letterhead + content are gone, but the trailing
|
||||
# page-break paragraph (now the only page break) must survive so Bản cam kết
|
||||
# still starts on its own page.
|
||||
page_breaks = re.findall(r'<[^>]*:br\s+[^>]*:type="page"', doc)
|
||||
self.assertEqual(len(page_breaks), 1, msg=f"expected 1 page break, got {len(page_breaks)}: {doc!r}")
|
||||
self.assertIn("mau_03.tac_gia_chinh_ky", doc)
|
||||
self.assertIn("BẢN CAM KẾT", doc)
|
||||
self.assertIn("CỘNG HOÀ XÃ HỘI CHỦ NGHĨA VIỆT NAM", doc)
|
||||
# sectPr must survive the trim.
|
||||
self.assertRegex(doc, r"<[^>]*:sectPr")
|
||||
|
||||
def test_strip_mau_04_is_idempotent(self) -> None:
|
||||
"""Second pass over an already-stripped document is a no-op."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>mau_03 end</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>Mẫu số 04</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>content</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>BẢN CAM KẾT</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
once = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
twice = strip_mau_04_evaluation_section_in_docx(once)
|
||||
self.assertEqual(_read_document_xml(once), _read_document_xml(twice))
|
||||
self.assertNotIn("Mẫu số 04", _read_document_xml(once))
|
||||
|
||||
def test_strip_mau_04_noop_when_marker_missing(self) -> None:
|
||||
"""Documents that don't carry the « Mẫu số 04 » header are left untouched."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>only mau_03</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>BẢN CAM KẾT</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
before = _read_document_xml(_wrap_doc_in_zip(doc_xml))
|
||||
after = _read_document_xml(out)
|
||||
# Allow whitespace / declaration differences from ElementTree round-trip; the
|
||||
# human-readable text content must be unchanged.
|
||||
for needle in ("only mau_03", "BẢN CAM KẾT"):
|
||||
self.assertIn(needle, after)
|
||||
self.assertNotIn("Mẫu số 04", after)
|
||||
|
||||
def test_strip_mau_04_bails_out_without_leading_page_break(self) -> None:
|
||||
"""If there's no page break before the Mẫu số 04 header (malformed template),
|
||||
leave the document alone instead of removing the previous section by mistake."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>previous section content</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>Mẫu số 04</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>{{ mau_04.ten_sang_kien }}</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertIn("previous section content", doc)
|
||||
self.assertIn("Mẫu số 04", doc, msg="strip must not run when leading page break is missing")
|
||||
|
||||
def test_collapse_empty_pagebreak_before_table_uses_pagebreakbefore(self) -> None:
|
||||
"""An empty paragraph that hosts only ``<w:br w:type="page"/>`` followed by a
|
||||
table is removed; the first paragraph in the first cell of the table gets
|
||||
``<w:pageBreakBefore/>`` so the table anchors to a new page without an
|
||||
intervening empty body paragraph."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>previous content</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>letterhead cell</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:t>next section paragraph</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertNotRegex(
|
||||
doc, r'<[^>]*:br\s+[^>]*:type="page"',
|
||||
msg="inline <w:br w:type=\"page\"/> should be replaced",
|
||||
)
|
||||
self.assertRegex(doc, r"<[^>]*:pageBreakBefore")
|
||||
# The empty page-break paragraph is gone but original content survives.
|
||||
self.assertIn("previous content", doc)
|
||||
self.assertIn("letterhead cell", doc)
|
||||
self.assertIn("next section paragraph", doc)
|
||||
|
||||
def test_collapse_empty_pagebreak_before_paragraph(self) -> None:
|
||||
"""Empty page-break paragraph followed by a non-empty paragraph: the empty
|
||||
paragraph is removed and ``<w:pageBreakBefore/>`` is added to the next paragraph
|
||||
so it starts on a new page."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>A</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>B</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
# Exactly two body paragraphs left (empty break paragraph collapsed).
|
||||
paras = re.findall(r"<[^>]*:p\b[^>]*>.*?</[^>]*:p>", doc, re.DOTALL)
|
||||
self.assertEqual(len(paras), 2, msg=f"expected 2 paragraphs, got {len(paras)}: {doc!r}")
|
||||
# The B paragraph keeps its center alignment AND gains pageBreakBefore.
|
||||
b_para = next(p for p in paras if "B</" in p or "B<" in p)
|
||||
self.assertIn("pageBreakBefore", b_para)
|
||||
self.assertRegex(b_para, r'<[^>]*:jc\s+[^>]*:val="center"')
|
||||
|
||||
def test_collapse_empty_pagebreak_idempotent(self) -> None:
|
||||
"""Second pass produces the same output as first pass."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>A</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>B</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
once = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
twice = collapse_empty_page_break_paragraphs_in_docx(once)
|
||||
self.assertEqual(_read_document_xml(once), _read_document_xml(twice))
|
||||
# And exactly one pageBreakBefore in the result (not double-registered).
|
||||
pbb_count = len(re.findall(r"<[^>]*:pageBreakBefore", _read_document_xml(once)))
|
||||
self.assertEqual(pbb_count, 1)
|
||||
|
||||
def test_collapse_empty_pagebreak_preserves_text_carrying_breaks(self) -> None:
|
||||
"""A paragraph that carries real text *and* an inline page break (rare; usually
|
||||
Word-edited) must not be collapsed: dropping the text would lose content."""
|
||||
doc_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>visible text</w:t><w:br w:type="page"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>after</w:t></w:r></w:p>
|
||||
</w:body></w:document>""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml))
|
||||
doc = _read_document_xml(out)
|
||||
self.assertRegex(doc, r'<[^>]*:br\s+[^>]*:type="page"', msg="break must survive")
|
||||
self.assertIn("visible text", doc)
|
||||
self.assertIn("after", doc)
|
||||
self.assertNotIn("pageBreakBefore", doc)
|
||||
|
||||
def test_force_times_new_roman_styles(self) -> None:
|
||||
styles_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="26"/></w:rPr></w:rPrDefault></w:docDefaults>
|
||||
<w:style w:styleId="Heading1"><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri" w:eastAsia="Calibri"/></w:rPr></w:style>
|
||||
</w:styles>"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("word/styles.xml", styles_xml)
|
||||
z.writestr(
|
||||
"[Content_Types].xml",
|
||||
b'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
|
||||
)
|
||||
out = force_times_new_roman_in_styles_docx(buf.getvalue())
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as z2:
|
||||
st = z2.read("word/styles.xml").decode("utf-8")
|
||||
self.assertNotIn("Calibri", st)
|
||||
self.assertIn("Times New Roman", st)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
``resolve_initiative_for_draft_case_key`` — evidence URLs may use ``sub-…`` or ``SUB-…`` instead of ``Initiative.case_code``.
|
||||
|
||||
Set INITIATIVE_DATABASE_URL to run (same as tests.test_applications_db_integration).
|
||||
|
||||
Run:
|
||||
cd be0 && python -m unittest tests.test_evidence_initiative_resolution -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests",
|
||||
)
|
||||
class EvidenceInitiativeResolutionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def test_resolves_by_public_submission_id_case_insensitive(self) -> None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Draft, Initiative, User
|
||||
from src.initiative_db.submissions import resolve_initiative_for_draft_case_key
|
||||
|
||||
owner_id = uuid.uuid4()
|
||||
owner_email = f"evtest-{owner_id.hex[:8]}@ump.edu.vn"
|
||||
case_code = f"EVCASE-{uuid.uuid4().hex[:10]}"
|
||||
submission_id = f"sub-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=owner_email,
|
||||
password_hash="-",
|
||||
full_name="Evidence resolver test",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
ini = Initiative(
|
||||
case_code=case_code,
|
||||
owner_id=owner_id,
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
session.add(ini)
|
||||
await session.flush()
|
||||
payload = {
|
||||
"caseId": case_code,
|
||||
"updatedAt": "2026-01-01T12:00:00Z",
|
||||
"tabs": {},
|
||||
"submissionRecord": {
|
||||
"id": submission_id,
|
||||
"submittedDate": "2026-01-01T12:00:00.000Z",
|
||||
"name": "Test",
|
||||
"author": {"id": case_code, "name": "T", "email": owner_email},
|
||||
"status": "submitted",
|
||||
"reviewStatus": "not_reviewed",
|
||||
},
|
||||
}
|
||||
session.add(
|
||||
Draft(
|
||||
draft_code=f"DRAFT-{case_code}",
|
||||
initiative_id=ini.id,
|
||||
payload=payload,
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
ini_id = ini.id
|
||||
|
||||
async with get_session() as session:
|
||||
upper_alias = "SUB-" + submission_id.split("-", 1)[1]
|
||||
r1 = await resolve_initiative_for_draft_case_key(session, submission_id)
|
||||
r2 = await resolve_initiative_for_draft_case_key(session, upper_alias)
|
||||
self.assertIsNotNone(r1)
|
||||
self.assertIsNotNone(r2)
|
||||
assert r1 is not None and r2 is not None
|
||||
self.assertEqual(r1.case_code, case_code)
|
||||
self.assertEqual(r2.case_code, case_code)
|
||||
|
||||
async with get_session() as session:
|
||||
await session.execute(delete(Draft).where(Draft.initiative_id == ini_id))
|
||||
await session.execute(delete(Initiative).where(Initiative.id == ini_id))
|
||||
await session.execute(delete(User).where(User.id == owner_id))
|
||||
await session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Unit tests for ``_evidence_kind_to_role`` (query/form normalization).
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_evidence_kind_parsing -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class EvidenceKindParsingTests(unittest.TestCase):
|
||||
def test_plain_strings(self) -> None:
|
||||
from main import _evidence_kind_to_role
|
||||
|
||||
self.assertEqual(_evidence_kind_to_role("research"), "research_evidence")
|
||||
self.assertEqual(_evidence_kind_to_role("TextBook"), "textbook_evidence")
|
||||
self.assertEqual(_evidence_kind_to_role("TECHNICAL"), "technical_evidence")
|
||||
self.assertIsNone(_evidence_kind_to_role("other"))
|
||||
|
||||
def test_prefers_valid_entry_in_list(self) -> None:
|
||||
"""Duplicate or noisy ``kind=`` values: first matching token wins."""
|
||||
from main import _evidence_kind_to_role
|
||||
|
||||
self.assertEqual(_evidence_kind_to_role(["", "bad", "research"]), "research_evidence")
|
||||
self.assertEqual(_evidence_kind_to_role(["research", "textbook"]), "research_evidence")
|
||||
|
||||
def test_strips_zwsp_and_bom(self) -> None:
|
||||
from main import _evidence_kind_to_role
|
||||
|
||||
self.assertEqual(_evidence_kind_to_role("research\u200b"), "research_evidence")
|
||||
self.assertEqual(_evidence_kind_to_role("\ufeffresearch"), "research_evidence")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for the pure filename-normalization helpers in imagehub_routes.
|
||||
|
||||
These cover the {prefix}_{caseID}_0000.{ext} rename convention (caseID = the file's number,
|
||||
5-digit zero-padded), the case-number extraction, and the double-extension split — with no
|
||||
Postgres / MinIO dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.imagehub_routes import _case_number, _normalized_name, _split_name_ext
|
||||
|
||||
|
||||
class TestSplitNameExt(unittest.TestCase):
|
||||
def test_double_extension_nii_gz(self):
|
||||
self.assertEqual(_split_name_ext("a.nii.gz"), ("a", ".nii.gz"))
|
||||
|
||||
def test_double_extension_tar_gz(self):
|
||||
self.assertEqual(_split_name_ext("a.tar.gz"), ("a", ".tar.gz"))
|
||||
|
||||
def test_single_extension(self):
|
||||
self.assertEqual(_split_name_ext("a.png"), ("a", ".png"))
|
||||
|
||||
def test_no_extension(self):
|
||||
self.assertEqual(_split_name_ext("a"), ("a", ""))
|
||||
|
||||
|
||||
class TestCaseNumber(unittest.TestCase):
|
||||
def test_plain_number(self):
|
||||
self.assertEqual(_case_number("1"), 1)
|
||||
self.assertEqual(_case_number("100"), 100)
|
||||
|
||||
def test_strips_channel_tag(self):
|
||||
self.assertEqual(_case_number("10_0000"), 10)
|
||||
|
||||
def test_prefixed_stem(self):
|
||||
# year digits in the prefix must NOT be mistaken for the case number
|
||||
self.assertEqual(_case_number("POLYP25_00001"), 1)
|
||||
self.assertEqual(_case_number("POLYP25_00123_0000"), 123)
|
||||
|
||||
def test_no_digits(self):
|
||||
self.assertIsNone(_case_number("frame"))
|
||||
|
||||
|
||||
class TestNormalizedName(unittest.TestCase):
|
||||
def test_image_gets_prefix_padding_and_channel(self):
|
||||
self.assertEqual(_normalized_name("1.png", "POLYP25", False), "POLYP25_00001_0000.png")
|
||||
self.assertEqual(_normalized_name("100.png", "POLYP25", False), "POLYP25_00100_0000.png")
|
||||
|
||||
def test_label_gets_prefix_padding_no_channel(self):
|
||||
self.assertEqual(_normalized_name("1.png", "POLYP25", True), "POLYP25_00001.png")
|
||||
|
||||
def test_image_and_label_share_case_id(self):
|
||||
img = _normalized_name("7.png", "POLYP25", False)
|
||||
lbl = _normalized_name("7.png", "POLYP25", True)
|
||||
self.assertEqual(img, "POLYP25_00007_0000.png")
|
||||
self.assertEqual(lbl, "POLYP25_00007.png")
|
||||
|
||||
def test_double_extension(self):
|
||||
self.assertEqual(_normalized_name("5.nii.gz", "RIB25", False), "RIB25_00005_0000.nii.gz")
|
||||
|
||||
def test_idempotent_already_correct(self):
|
||||
self.assertIsNone(_normalized_name("POLYP25_00001_0000.png", "POLYP25", False))
|
||||
self.assertIsNone(_normalized_name("POLYP25_00001.png", "POLYP25", True))
|
||||
|
||||
def test_reprefix_changes_prefix_keeps_case(self):
|
||||
self.assertEqual(
|
||||
_normalized_name("OLD25_00009_0000.png", "POLYP25", False), "POLYP25_00009_0000.png"
|
||||
)
|
||||
|
||||
def test_no_case_number_returns_none(self):
|
||||
self.assertIsNone(_normalized_name("frame.png", "POLYP25", False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for the pure Identity domain layer.
|
||||
|
||||
No DB, no FastAPI — runs anywhere (``python -m pytest tests/test_identity_domain.py``).
|
||||
Pins the behavior extracted from auth_api.py so the eventual cut-over can't drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src.domain.identity.entities import User
|
||||
from src.domain.identity.errors import InvalidInstitutionalEmail, WeakPassword
|
||||
from src.domain.identity.services import (
|
||||
DEFAULT_POLICY_ADMIN_EMAILS,
|
||||
AdminReconcileAction,
|
||||
build_access_token_claims,
|
||||
policy_admin_emails,
|
||||
reconcile_admin_action,
|
||||
)
|
||||
from src.domain.identity.value_objects import (
|
||||
InstitutionalEmail,
|
||||
Role,
|
||||
assert_password_policy,
|
||||
)
|
||||
|
||||
|
||||
class TestInstitutionalEmail:
|
||||
@pytest.mark.parametrize("raw", [" ThaoNTT@UMP.edu.vn ", "x@umc.edu.vn"])
|
||||
def test_parse_normalizes_and_accepts(self, raw: str) -> None:
|
||||
assert InstitutionalEmail.parse(raw).value == raw.strip().lower()
|
||||
|
||||
@pytest.mark.parametrize("raw", ["a@gmail.com", "a@ump.edu.vn.evil.com", "", " "])
|
||||
def test_parse_rejects_non_institutional(self, raw: str) -> None:
|
||||
with pytest.raises(InvalidInstitutionalEmail):
|
||||
InstitutionalEmail.parse(raw)
|
||||
|
||||
def test_value_object_equality_by_value(self) -> None:
|
||||
assert InstitutionalEmail.parse("A@ump.edu.vn") == InstitutionalEmail.parse("a@ump.edu.vn")
|
||||
|
||||
|
||||
class TestPasswordPolicy:
|
||||
def test_accepts_strong_password(self) -> None:
|
||||
assert_password_policy("Abcdef1!") # no raise
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pwd, msg",
|
||||
[
|
||||
("Ab1!", "Mật khẩu tối thiểu 6 ký tự."),
|
||||
("abcdef1!", "Mật khẩu phải có ít nhất một chữ cái hoa."),
|
||||
("ABCDEF1!", "Mật khẩu phải có ít nhất một chữ cái thường."),
|
||||
("Abcdefg!", "Mật khẩu phải có ít nhất một chữ số."),
|
||||
("Abcdef12", "Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số)."),
|
||||
],
|
||||
)
|
||||
def test_rejects_with_exact_message(self, pwd: str, msg: str) -> None:
|
||||
with pytest.raises(WeakPassword) as exc:
|
||||
assert_password_policy(pwd)
|
||||
assert exc.value.message == msg
|
||||
|
||||
def test_rejects_overlong(self) -> None:
|
||||
with pytest.raises(WeakPassword):
|
||||
assert_password_policy("Ab1!" + "a" * 600)
|
||||
|
||||
|
||||
class TestRolePolicy:
|
||||
def test_env_overrides_defaults(self) -> None:
|
||||
assert policy_admin_emails("A@ump.edu.vn, b@umc.edu.vn ") == frozenset(
|
||||
{"a@ump.edu.vn", "b@umc.edu.vn"}
|
||||
)
|
||||
|
||||
def test_unset_uses_builtin_allowlist(self) -> None:
|
||||
assert policy_admin_emails(None) == DEFAULT_POLICY_ADMIN_EMAILS
|
||||
assert policy_admin_emails(" ") == DEFAULT_POLICY_ADMIN_EMAILS
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email, has_row, from_policy, expected",
|
||||
[
|
||||
("a@ump.edu.vn", False, False, AdminReconcileAction.add_admin),
|
||||
("a@ump.edu.vn", True, True, AdminReconcileAction.mark_policy),
|
||||
("b@ump.edu.vn", True, True, AdminReconcileAction.remove_admin),
|
||||
("b@ump.edu.vn", True, False, AdminReconcileAction.none), # manual admin preserved
|
||||
("b@ump.edu.vn", False, False, AdminReconcileAction.none),
|
||||
],
|
||||
)
|
||||
def test_reconcile_decision(self, email, has_row, from_policy, expected) -> None:
|
||||
policy = frozenset({"a@ump.edu.vn"})
|
||||
assert reconcile_admin_action(email, policy, has_row, from_policy) == expected
|
||||
|
||||
|
||||
class TestTokenClaims:
|
||||
def test_claim_shape(self) -> None:
|
||||
uid = uuid.uuid4()
|
||||
now = datetime(2026, 6, 13, 12, 0, tzinfo=timezone.utc)
|
||||
claims = build_access_token_claims(uid, "a@ump.edu.vn", ["admin", "viewer"], 3, now, 12)
|
||||
assert claims["sub"] == str(uid)
|
||||
assert claims["email"] == "a@ump.edu.vn"
|
||||
assert claims["roles"] == ["admin", "viewer"]
|
||||
assert claims["cv"] == 3
|
||||
assert claims["exp"] - claims["iat"] == 12 * 3600
|
||||
|
||||
|
||||
class TestUserAggregate:
|
||||
def _user(self, **kw) -> User:
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
email="a@ump.edu.vn",
|
||||
full_name="Test",
|
||||
password_hash="x",
|
||||
email_verified=True,
|
||||
is_active=True,
|
||||
credential_version=0,
|
||||
)
|
||||
base.update(kw)
|
||||
return User(**base)
|
||||
|
||||
def test_can_authenticate_requires_active(self) -> None:
|
||||
assert self._user(is_active=True).can_authenticate()
|
||||
assert not self._user(is_active=False).can_authenticate()
|
||||
|
||||
def test_bump_credential_version(self) -> None:
|
||||
u = self._user(credential_version=2)
|
||||
u.bump_credential_version()
|
||||
assert u.credential_version == 3
|
||||
|
||||
def test_identity_equality(self) -> None:
|
||||
uid = uuid.uuid4()
|
||||
assert self._user(id=uid, full_name="A") == self._user(id=uid, full_name="B")
|
||||
|
||||
def test_role_enum_values(self) -> None:
|
||||
assert {r.value for r in Role} == {"admin", "editor", "viewer"}
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Tests for the ImageHub dataset routes (milestone 1 walking skeleton).
|
||||
|
||||
Pure-helper unit tests always run. The full integration test (create dataset → upload with
|
||||
content-addressed dedup → version snapshot → owner/admin authz → audit) runs only when BOTH:
|
||||
- INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg), and
|
||||
- S3_ENDPOINT_URL is set (a reachable MinIO; the dev stack maps it to http://localhost:19000).
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
export S3_ENDPOINT_URL="http://localhost:19000" S3_ACCESS_KEY=minio_user S3_SECRET_KEY=minio_password \\
|
||||
S3_BUCKET_ATTACHMENTS=initiative-attachments S3_BUCKET_EXPORTS=initiative-exports \\
|
||||
S3_BUCKET_QUARANTINE=initiative-quarantine S3_PUBLIC_ENDPOINT_URL=http://localhost:19000
|
||||
cd be0 && python -m unittest tests.test_imagehub_datasets -v
|
||||
|
||||
Prereq for the integration test: migration 017_imagehub_datasets.sql applied (compose init mount
|
||||
or scripts/apply_initiative_migrations.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
_RUN_S3 = bool(os.getenv("S3_ENDPOINT_URL", "").strip())
|
||||
|
||||
# Let the module (which imports src.minio.storage → S3Settings()) import even when not running
|
||||
# against a real MinIO, so the pure-unit tests below can always run. These defaults match the
|
||||
# dev stack's host-mapped MinIO; the integration test only fires when S3_ENDPOINT_URL was set.
|
||||
os.environ.setdefault("S3_ENDPOINT_URL", "http://localhost:19000")
|
||||
os.environ.setdefault("S3_ACCESS_KEY", "minio_user")
|
||||
os.environ.setdefault("S3_SECRET_KEY", "minio_password")
|
||||
os.environ.setdefault("S3_BUCKET_ATTACHMENTS", "initiative-attachments")
|
||||
os.environ.setdefault("S3_BUCKET_EXPORTS", "initiative-exports")
|
||||
os.environ.setdefault("S3_BUCKET_QUARANTINE", "initiative-quarantine")
|
||||
os.environ.setdefault("S3_PUBLIC_ENDPOINT_URL", "http://localhost:19000")
|
||||
|
||||
|
||||
class PureHelperTests(unittest.TestCase):
|
||||
"""No DB / no network — string + sniff helpers."""
|
||||
|
||||
def test_build_blob_key_is_content_addressed(self) -> None:
|
||||
from src.minio.storage import S3Storage
|
||||
|
||||
key = S3Storage.build_blob_key("AbCdEf0123456789")
|
||||
self.assertEqual(key, "blobs/ab/cd/abcdef0123456789")
|
||||
|
||||
def test_slugify_strips_diacritics_and_punct(self) -> None:
|
||||
from src.imagehub_routes import _slugify
|
||||
|
||||
self.assertEqual(_slugify("Bộ dữ liệu CT Ngực!! 2026"), "bo-du-lieu-ct-nguc-2026")
|
||||
self.assertEqual(_slugify(""), "dataset")
|
||||
|
||||
def test_safe_logical_path_basename_only(self) -> None:
|
||||
from src.imagehub_routes import _safe_logical_path
|
||||
|
||||
self.assertEqual(_safe_logical_path("/evil/../a b.dcm"), "a_b.dcm")
|
||||
self.assertEqual(_safe_logical_path("C:\\scans\\series1.nii.gz"), "series1.nii.gz")
|
||||
self.assertEqual(_safe_logical_path(""), "file")
|
||||
|
||||
def test_safe_folder_path_preserves_dirs_rejects_traversal(self) -> None:
|
||||
from src.imagehub_routes import _safe_folder_path
|
||||
|
||||
# the directory is kept (basename dropped) so an uploaded tree round-trips
|
||||
self.assertEqual(_safe_folder_path("imagesTr/ct_001.nii.gz"), "imagesTr")
|
||||
self.assertEqual(_safe_folder_path("a/b/c/scan.nii.gz"), "a/b/c")
|
||||
# no directory component → dataset root
|
||||
self.assertEqual(_safe_folder_path("readme.txt"), "")
|
||||
self.assertEqual(_safe_folder_path(""), "")
|
||||
# leading slash + ".." traversal segments are stripped
|
||||
self.assertEqual(_safe_folder_path("/evil/../x/y.dcm"), "evil/x")
|
||||
# backslashes normalise to forward slashes
|
||||
self.assertEqual(_safe_folder_path("labelsTr\\sub\\m.nii.gz"), "labelsTr/sub")
|
||||
|
||||
def test_coerce_tags(self) -> None:
|
||||
from src.imagehub_routes import _coerce_tags
|
||||
|
||||
self.assertEqual(_coerce_tags(["CT", " MRI ", "", 7]), ["CT", "MRI", "7"])
|
||||
self.assertEqual(_coerce_tags("nope"), [])
|
||||
|
||||
def test_coerce_label_map(self) -> None:
|
||||
from src.imagehub_routes import _coerce_label_map
|
||||
|
||||
# valid entries kept + trimmed; non-positive / non-int keys and empty/non-str values dropped
|
||||
self.assertEqual(
|
||||
_coerce_label_map(
|
||||
{"1": " kidney ", "2": "tumor", "0": "bad", "-3": "bad", "x": "bad", "4": "", "+5": "bad", "1_0": "bad"}
|
||||
),
|
||||
{"1": "kidney", "2": "tumor"},
|
||||
)
|
||||
# integer keys coerce to strings; non-dict input → {}
|
||||
self.assertEqual(_coerce_label_map({1: "kidney"}), {"1": "kidney"})
|
||||
self.assertEqual(_coerce_label_map("nope"), {})
|
||||
self.assertEqual(_coerce_label_map(None), {})
|
||||
|
||||
def test_sniff_never_raises_on_non_imaging(self) -> None:
|
||||
from src.imagehub_routes import _sniff_imaging_meta
|
||||
|
||||
# plain bytes → {}; a .dcm name with junk must degrade to {} (never raise)
|
||||
self.assertEqual(_sniff_imaging_meta("notes.txt", b"hello world", "text/plain"), {})
|
||||
self.assertIsInstance(_sniff_imaging_meta("x.dcm", b"DICM" + b"\x00" * 200, "application/dicom"), dict)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _upload(name: str, data: bytes, ctype: str = "application/octet-stream"):
|
||||
from starlette.datastructures import Headers, UploadFile
|
||||
|
||||
return UploadFile(io.BytesIO(data), filename=name, headers=Headers({"content-type": ctype}))
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB and _RUN_S3,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://… and S3_ENDPOINT_URL=… to run the integration test",
|
||||
)
|
||||
class ImagehubDatasetDbTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""End-to-end: create → upload (content-addressed dedup) → version → owner/admin authz → audit."""
|
||||
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
from src.minio.storage import storage
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
try:
|
||||
await storage.ensure_buckets_exist()
|
||||
except Exception as exc: # MinIO not reachable → skip rather than error
|
||||
self.skipTest(f"MinIO not reachable: {exc}")
|
||||
self._user_ids: list[uuid.UUID] = []
|
||||
self._dataset_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 ImagehubDataset, User
|
||||
|
||||
async with get_session() as session:
|
||||
for did in self._dataset_ids:
|
||||
await session.execute(delete(ImagehubDataset).where(ImagehubDataset.id == did))
|
||||
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"ih-{uid.hex[:10]}@ump.edu.vn",
|
||||
password_hash="x",
|
||||
full_name=("Quản trị" if admin else "Nhà nghiên cứu") + " Test",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
self._user_ids.append(uid)
|
||||
return uid
|
||||
|
||||
async def test_dataset_research_project_link(self) -> None:
|
||||
"""A dataset can be created linked to a research project ("workspace"); the list can be
|
||||
filtered to that project; bad/foreign project ids are rejected (migration 024)."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.imagehub_routes import DatasetCreateIn, create_dataset, list_datasets
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import ResearchProject
|
||||
|
||||
owner = await self._seed_user()
|
||||
owner_tok = _bearer(owner, ["viewer"])
|
||||
|
||||
# seed a research project ("workspace") owned by the user (cascade-cleaned with the user)
|
||||
proj_id = uuid.uuid4()
|
||||
async with get_session() as session:
|
||||
session.add(ResearchProject(id=proj_id, owner_user_id=owner, title="Đề tài thử nghiệm"))
|
||||
await session.commit()
|
||||
|
||||
# create a dataset linked to the project → the link is persisted
|
||||
ds = await create_dataset(
|
||||
DatasetCreateIn(name="Bộ dữ liệu thuộc đề tài", researchProjectId=str(proj_id)),
|
||||
owner_tok,
|
||||
)
|
||||
self._dataset_ids.append(uuid.UUID(ds.id))
|
||||
self.assertEqual(ds.researchProjectId, str(proj_id))
|
||||
|
||||
# a standalone dataset (no project) is still allowed and stays unlinked
|
||||
ds2 = await create_dataset(DatasetCreateIn(name="Bộ dữ liệu độc lập"), owner_tok)
|
||||
self._dataset_ids.append(uuid.UUID(ds2.id))
|
||||
self.assertIsNone(ds2.researchProjectId)
|
||||
|
||||
# a non-existent project id is rejected (422)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
await create_dataset(
|
||||
DatasetCreateIn(name="x", researchProjectId=str(uuid.uuid4())), owner_tok
|
||||
)
|
||||
self.assertEqual(ctx.exception.status_code, 422)
|
||||
|
||||
# ?projectId= filters the list to that project only (3rd positional arg = projectId)
|
||||
in_proj = await list_datasets("mine", owner_tok, str(proj_id))
|
||||
ids_in_proj = [d.id for d in in_proj]
|
||||
self.assertIn(ds.id, ids_in_proj)
|
||||
self.assertNotIn(ds2.id, ids_in_proj)
|
||||
self.assertTrue(all(d.researchProjectId == str(proj_id) for d in in_proj))
|
||||
|
||||
async def test_update_label_map_sanitizes_and_persists(self) -> None:
|
||||
"""update_dataset accepts a per-value label map, sanitizes it, and round-trips it (migration 027)."""
|
||||
from src.imagehub_routes import (
|
||||
DatasetCreateIn,
|
||||
DatasetUpdateIn,
|
||||
create_dataset,
|
||||
get_dataset,
|
||||
update_dataset,
|
||||
)
|
||||
|
||||
owner = await self._seed_user()
|
||||
owner_tok = _bearer(owner, ["viewer"])
|
||||
|
||||
ds = await create_dataset(DatasetCreateIn(name="KiTS labels"), owner_tok)
|
||||
self._dataset_ids.append(uuid.UUID(ds.id))
|
||||
self.assertEqual(ds.labelMap, {}) # empty by default
|
||||
|
||||
# garbage keys/values are dropped; valid ones trimmed + kept
|
||||
updated = await update_dataset(
|
||||
ds.id,
|
||||
DatasetUpdateIn(labelMap={"1": "kidney", "2": "tumor", "3": "cyst", "0": "bad", "x": "bad"}),
|
||||
owner_tok,
|
||||
)
|
||||
self.assertEqual(updated.labelMap, {"1": "kidney", "2": "tumor", "3": "cyst"})
|
||||
|
||||
# persisted: a fresh read returns the same map
|
||||
fresh = await get_dataset(ds.id, owner_tok)
|
||||
self.assertEqual(fresh.labelMap, {"1": "kidney", "2": "tumor", "3": "cyst"})
|
||||
|
||||
async def test_review_persists_decision_and_stats(self) -> None:
|
||||
"""review_task writes a structured review event; review-stats tallies it per reviewer (025)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.imagehub_routes import ReviewIn, review_stats, review_task
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import (
|
||||
ImagehubBlob,
|
||||
ImagehubDataset,
|
||||
ImagehubDatasetFile,
|
||||
ImagehubDatasetStage,
|
||||
ImagehubTask,
|
||||
ImagehubTaskReviewEvent,
|
||||
)
|
||||
|
||||
owner = await self._seed_user()
|
||||
owner_tok = _bearer(owner, ["viewer"])
|
||||
|
||||
# build the minimal chain (no upload): dataset + a Review stage + a file + a task already
|
||||
# advanced to that Review stage, assigned to the owner.
|
||||
ds_id, stage_id, file_id, task_id = (uuid.uuid4() for _ in range(4))
|
||||
sha = uuid.uuid4().hex
|
||||
async with get_session() as session:
|
||||
session.add(ImagehubDataset(id=ds_id, owner_user_id=owner, name="Review demo"))
|
||||
session.add(
|
||||
ImagehubDatasetStage(id=stage_id, dataset_id=ds_id, name="Rà soát 1", kind="review", seq=1)
|
||||
)
|
||||
session.add(ImagehubBlob(sha256=sha, size_bytes=1))
|
||||
session.add(
|
||||
ImagehubDatasetFile(id=file_id, dataset_id=ds_id, logical_path="ct.nii.gz", blob_sha256=sha)
|
||||
)
|
||||
session.add(
|
||||
ImagehubTask(
|
||||
id=task_id, dataset_id=ds_id, dataset_file_id=file_id, name="ct.nii.gz",
|
||||
current_stage_id=stage_id, pipeline_state="inReview", queue_status="assigned",
|
||||
assignee_user_id=owner,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
self._dataset_ids.append(ds_id) # cascade-cleans stage/file/task/events in teardown
|
||||
|
||||
# accept the review → a structured event is persisted (decision + reviewer + stage + note)
|
||||
await review_task(str(ds_id), str(task_id), ReviewIn(decision="accept", note="Đạt"), owner_tok)
|
||||
async with get_session() as session:
|
||||
evs = (
|
||||
await session.execute(
|
||||
select(ImagehubTaskReviewEvent).where(ImagehubTaskReviewEvent.task_id == task_id)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(len(evs), 1)
|
||||
self.assertEqual(evs[0].decision, "accept")
|
||||
self.assertEqual(evs[0].reviewer_user_id, owner)
|
||||
self.assertEqual(evs[0].stage_id, stage_id)
|
||||
self.assertEqual(evs[0].note, "Đạt")
|
||||
|
||||
# the stats endpoint tallies it for the reviewer (authorization is the LAST positional arg)
|
||||
stats = await review_stats(str(ds_id), str(owner), 30, owner_tok)
|
||||
self.assertEqual(stats.accepted, 1)
|
||||
self.assertEqual(stats.rejected, 0)
|
||||
# a foreign reviewer has no tally
|
||||
empty = await review_stats(str(ds_id), str(uuid.uuid4()), 30, owner_tok)
|
||||
self.assertEqual(empty.accepted, 0)
|
||||
|
||||
async def test_create_upload_dedup_version_authz_audit(self) -> None:
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.imagehub_routes import (
|
||||
DatasetCreateIn,
|
||||
VersionCreateIn,
|
||||
create_dataset,
|
||||
create_version,
|
||||
get_dataset,
|
||||
list_audit,
|
||||
list_datasets,
|
||||
list_files,
|
||||
list_versions,
|
||||
upload_files,
|
||||
)
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import ImagehubBlob, ImagehubDatasetFile
|
||||
|
||||
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
|
||||
ds = await create_dataset(
|
||||
DatasetCreateIn(name="CT Ngực thử nghiệm", description="demo", modalityTags=["CT"]),
|
||||
owner_tok,
|
||||
)
|
||||
self._dataset_ids.append(uuid.UUID(ds.id))
|
||||
self.assertEqual(ds.name, "CT Ngực thử nghiệm")
|
||||
self.assertEqual(ds.modalityTags, ["CT"])
|
||||
self.assertEqual(ds.fileCount, 0)
|
||||
|
||||
# upload the SAME content under two names → content-addressed dedup
|
||||
blob_bytes = uuid.uuid4().bytes * 64 # unique per run
|
||||
res = await upload_files(
|
||||
ds.id, [_upload("scan_a.bin", blob_bytes), _upload("scan_b.bin", blob_bytes)], owner_tok
|
||||
)
|
||||
self.assertTrue(res["ok"])
|
||||
shas = {f["sha256"] for f in res["files"]}
|
||||
self.assertEqual(len(shas), 1, "same content must hash to one sha256")
|
||||
deduped_flags = sorted(f["deduped"] for f in res["files"])
|
||||
self.assertEqual(deduped_flags, [False, True], "first stores the blob, second dedups")
|
||||
|
||||
# DB: exactly one blob row for that sha256, two file rows for the dataset
|
||||
sha = next(iter(shas))
|
||||
async with get_session() as session:
|
||||
blob_count = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(ImagehubBlob).where(ImagehubBlob.sha256 == sha)
|
||||
)
|
||||
).scalar_one()
|
||||
file_count = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ImagehubDatasetFile)
|
||||
.where(ImagehubDatasetFile.dataset_id == uuid.UUID(ds.id))
|
||||
)
|
||||
).scalar_one()
|
||||
self.assertEqual(blob_count, 1)
|
||||
self.assertEqual(file_count, 2)
|
||||
|
||||
# browse files (each carries a presigned download URL)
|
||||
files = await list_files(ds.id, owner_tok)
|
||||
self.assertEqual(len(files), 2)
|
||||
self.assertTrue(all(f.downloadUrl for f in files))
|
||||
|
||||
# authz: a non-admin other user can't see or read it
|
||||
owner_list = await list_datasets("mine", owner_tok)
|
||||
self.assertIn(ds.id, [d.id for d in owner_list])
|
||||
other_list = await list_datasets("all", other_tok) # non-admin: scope=all ignored
|
||||
self.assertNotIn(ds.id, [d.id for d in other_list])
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
await get_dataset(ds.id, other_tok)
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
|
||||
# admin sees every dataset (the clinical data repository)
|
||||
admin_list = await list_datasets("all", admin_tok)
|
||||
self.assertIn(ds.id, [d.id for d in admin_list])
|
||||
|
||||
# version snapshot freezes the 2-file manifest
|
||||
ver = await create_version(ds.id, VersionCreateIn(message="phiên bản đầu"), owner_tok)
|
||||
self.assertEqual(ver.seq, 1)
|
||||
self.assertEqual(ver.fileCount, 2)
|
||||
versions = await list_versions(ds.id, owner_tok)
|
||||
self.assertEqual(len(versions), 1)
|
||||
|
||||
# audit trail recorded each mutation
|
||||
audit = await list_audit(ds.id, owner_tok)
|
||||
actions = [a.action for a in audit]
|
||||
self.assertIn("Tạo bộ dữ liệu", actions)
|
||||
self.assertIn("Tải tệp lên", actions)
|
||||
self.assertIn("Tạo phiên bản", actions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Unit tests for the ImageHub segmentation-linking domain service.
|
||||
|
||||
The service was built with INJECTED infrastructure (put_blob / sniff_meta / safe_name)
|
||||
precisely so the domain rules can be exercised with fakes — no Postgres, no MinIO.
|
||||
Covers: parent validation (bad uuid / not-found / not-an-image), the mask path
|
||||
namespacing, organ-label fallback, and the empty-payload guard.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from src.imagehub_segmentation import MaskUpload, SegmentationError, SegmentationService
|
||||
from src.initiative_db.models import ImagehubDataset, ImagehubDatasetFile
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal AsyncSession stand-in. The 1st execute() resolves the parent file;
|
||||
every later execute() resolves the 'existing mask at this path' lookup (None =
|
||||
create new). Records added rows."""
|
||||
|
||||
def __init__(self, parent=None, existing=None):
|
||||
self._parent = parent
|
||||
self._existing = existing
|
||||
self.added: list = []
|
||||
self.exec_calls = 0
|
||||
self.flushes = 0
|
||||
|
||||
async def execute(self, _stmt):
|
||||
self.exec_calls += 1
|
||||
return _FakeResult(self._parent if self.exec_calls == 1 else self._existing)
|
||||
|
||||
async def get(self, _model, _key):
|
||||
return None # blob absent → service will add it
|
||||
|
||||
def add(self, obj):
|
||||
self.added.append(obj)
|
||||
|
||||
async def flush(self):
|
||||
self.flushes += 1
|
||||
|
||||
|
||||
def _safe_name(name):
|
||||
base = (name or "").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
||||
return base or "file"
|
||||
|
||||
|
||||
async def _put_blob(data, media_type):
|
||||
return {
|
||||
"sha256": "deadbeef" + str(len(data)),
|
||||
"size": len(data),
|
||||
"bucket": "imagehub-blobs",
|
||||
"key": "blobs/de/ad/deadbeef",
|
||||
"media_type": media_type or "application/octet-stream",
|
||||
"deduped": False,
|
||||
}
|
||||
|
||||
|
||||
def _sniff(_filename, _data, _media):
|
||||
return {"format": "nifti", "shape": [4, 4, 4]}
|
||||
|
||||
|
||||
def _service(session):
|
||||
return SegmentationService(session, put_blob=_put_blob, sniff_meta=_sniff, safe_name=_safe_name)
|
||||
|
||||
|
||||
def _image_parent(dataset_id, logical_path="ct.nii.gz"):
|
||||
return ImagehubDatasetFile(
|
||||
id=uuid.uuid4(), dataset_id=dataset_id, logical_path=logical_path, file_kind="image"
|
||||
)
|
||||
|
||||
|
||||
def _mask(filename="liver.nii.gz", organ="Gan", data=b"xyz"):
|
||||
return MaskUpload(filename=filename, data=data, media_type="application/gzip", organ_label=organ)
|
||||
|
||||
|
||||
class TestMaskLogicalPath(unittest.TestCase):
|
||||
def test_namespaces_under_parent_stem(self):
|
||||
svc = _service(_FakeSession())
|
||||
self.assertEqual(svc._mask_logical_path("ct.nii.gz", "liver.nii.gz"), "ct.seg/liver.nii.gz")
|
||||
self.assertEqual(svc._mask_logical_path("scan.nii", "a.nii.gz"), "scan.seg/a.nii.gz")
|
||||
self.assertEqual(svc._mask_logical_path("study.dcm", "k.nii.gz"), "study.seg/k.nii.gz")
|
||||
|
||||
def test_mask_path_cannot_collide_with_a_real_image_path(self):
|
||||
# A real file's logical_path never contains '/', a mask path always does.
|
||||
svc = _service(_FakeSession())
|
||||
self.assertIn("/", svc._mask_logical_path("ct.nii.gz", "ct.nii.gz"))
|
||||
|
||||
|
||||
class TestLinkMasks(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.ds = ImagehubDataset(id=uuid.uuid4(), owner_user_id=uuid.uuid4())
|
||||
self.uid = uuid.uuid4()
|
||||
|
||||
async def test_happy_path_links_mask_to_image(self):
|
||||
parent = _image_parent(self.ds.id)
|
||||
sess = _FakeSession(parent=parent, existing=None)
|
||||
rows = await _service(sess).link_masks(self.ds, str(parent.id), [_mask()], self.uid)
|
||||
self.assertEqual(len(rows), 1)
|
||||
r = rows[0]
|
||||
self.assertEqual(r.file_kind, "segmentation")
|
||||
self.assertEqual(r.parent_file_id, parent.id)
|
||||
self.assertEqual(r.organ_label, "Gan")
|
||||
self.assertEqual(r.logical_path, "ct.seg/liver.nii.gz")
|
||||
self.assertEqual(r.dataset_id, self.ds.id)
|
||||
|
||||
async def test_organ_label_falls_back_to_filename(self):
|
||||
parent = _image_parent(self.ds.id)
|
||||
sess = _FakeSession(parent=parent)
|
||||
rows = await _service(sess).link_masks(self.ds, str(parent.id), [_mask(organ=" ")], self.uid)
|
||||
self.assertEqual(rows[0].organ_label, "liver.nii.gz")
|
||||
|
||||
async def test_bad_parent_uuid_is_404(self):
|
||||
with self.assertRaises(SegmentationError) as ctx:
|
||||
await _service(_FakeSession()).link_masks(self.ds, "not-a-uuid", [_mask()], self.uid)
|
||||
self.assertEqual(ctx.exception.status, 404)
|
||||
|
||||
async def test_parent_not_found_is_404(self):
|
||||
sess = _FakeSession(parent=None)
|
||||
with self.assertRaises(SegmentationError) as ctx:
|
||||
await _service(sess).link_masks(self.ds, str(uuid.uuid4()), [_mask()], self.uid)
|
||||
self.assertEqual(ctx.exception.status, 404)
|
||||
|
||||
async def test_attaching_to_a_mask_is_422(self):
|
||||
mask_parent = ImagehubDatasetFile(
|
||||
id=uuid.uuid4(), dataset_id=self.ds.id, logical_path="ct.seg/x.nii.gz", file_kind="segmentation"
|
||||
)
|
||||
sess = _FakeSession(parent=mask_parent)
|
||||
with self.assertRaises(SegmentationError) as ctx:
|
||||
await _service(sess).link_masks(self.ds, str(mask_parent.id), [_mask()], self.uid)
|
||||
self.assertEqual(ctx.exception.status, 422)
|
||||
|
||||
async def test_empty_payload_is_422(self):
|
||||
parent = _image_parent(self.ds.id)
|
||||
sess = _FakeSession(parent=parent)
|
||||
with self.assertRaises(SegmentationError) as ctx:
|
||||
await _service(sess).link_masks(self.ds, str(parent.id), [], self.uid)
|
||||
self.assertEqual(ctx.exception.status, 422)
|
||||
|
||||
async def test_all_empty_byte_masks_is_422(self):
|
||||
parent = _image_parent(self.ds.id)
|
||||
sess = _FakeSession(parent=parent)
|
||||
with self.assertRaises(SegmentationError) as ctx:
|
||||
await _service(sess).link_masks(self.ds, str(parent.id), [_mask(data=b"")], self.uid)
|
||||
self.assertEqual(ctx.exception.status, 422)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Unit tests for the ImageHub task-pipeline domain (project-workflow §3/§4 transitions).
|
||||
|
||||
The pipeline service is pure (plain functions over ``StageInfo`` lists — no Postgres, no FastAPI),
|
||||
so the whole state machine is exercised here with plain data. The thin HTTP wrappers in
|
||||
``imagehub_routes`` are verified live; this file owns the transition rules.
|
||||
|
||||
Covers: stage ordering, the new-task start state, TP1 finalize (advance / -> Ground Truth),
|
||||
TP2 accept + accept-with-corrections, TP3 reject (-> first stage), the guards (wrong-state finalize/
|
||||
review, bad decision), and RS1 reference-standard (Ground-Truth-only).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.imagehub_task_pipeline import (
|
||||
StageInfo,
|
||||
TaskPipelineError,
|
||||
compute_finalize,
|
||||
compute_review,
|
||||
first_stage,
|
||||
initial_transition,
|
||||
order_stages,
|
||||
stage_after,
|
||||
state_for_stage,
|
||||
validate_set_reference,
|
||||
)
|
||||
|
||||
# A canonical pipeline: Label(0) -> Review_1(1) -> Review_2(2). Deliberately unsorted in the list
|
||||
# so order_stages / first_stage / stage_after are actually exercised, not given pre-sorted input.
|
||||
LABEL = StageInfo(id="s-label", kind="label", seq=0)
|
||||
REV1 = StageInfo(id="s-rev1", kind="review", seq=1)
|
||||
REV2 = StageInfo(id="s-rev2", kind="review", seq=2)
|
||||
PIPELINE = [REV2, LABEL, REV1] # out of order on purpose
|
||||
|
||||
|
||||
class StageOrderingTests(unittest.TestCase):
|
||||
def test_order_stages_sorts_by_seq(self):
|
||||
self.assertEqual([s.id for s in order_stages(PIPELINE)], ["s-label", "s-rev1", "s-rev2"])
|
||||
|
||||
def test_first_stage_is_lowest_seq(self):
|
||||
self.assertEqual(first_stage(PIPELINE).id, "s-label")
|
||||
|
||||
def test_first_stage_empty_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
first_stage([])
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_stage_after_returns_next(self):
|
||||
self.assertEqual(stage_after(PIPELINE, "s-label").id, "s-rev1")
|
||||
self.assertEqual(stage_after(PIPELINE, "s-rev1").id, "s-rev2")
|
||||
|
||||
def test_stage_after_last_is_none(self):
|
||||
self.assertIsNone(stage_after(PIPELINE, "s-rev2"))
|
||||
|
||||
def test_stage_after_unknown_or_none_is_none(self):
|
||||
self.assertIsNone(stage_after(PIPELINE, "nope"))
|
||||
self.assertIsNone(stage_after(PIPELINE, None))
|
||||
|
||||
def test_state_for_stage(self):
|
||||
self.assertEqual(state_for_stage(LABEL), "inLabel")
|
||||
self.assertEqual(state_for_stage(REV1), "inReview")
|
||||
|
||||
|
||||
class InitialTransitionTests(unittest.TestCase):
|
||||
def test_new_task_starts_inlabel_at_first_stage(self):
|
||||
t = initial_transition(PIPELINE)
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-label")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_new_task_starts_inreview_when_first_stage_is_review(self):
|
||||
t = initial_transition([REV1])
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev1")
|
||||
|
||||
|
||||
class FinalizeTests(unittest.TestCase):
|
||||
def test_finalize_label_advances_to_first_review(self):
|
||||
t = compute_finalize("inLabel", "s-label", PIPELINE)
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev1")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_finalize_advances_label_to_next_label(self):
|
||||
# PreLabel(0,label) -> Label(1,label): finalizing the first stays inLabel at the next.
|
||||
prelabel = StageInfo(id="s-pre", kind="label", seq=0)
|
||||
label = StageInfo(id="s-lab", kind="label", seq=1)
|
||||
t = compute_finalize("inLabel", "s-pre", [prelabel, label])
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-lab")
|
||||
|
||||
def test_finalize_single_label_goes_to_ground_truth(self):
|
||||
t = compute_finalize("inLabel", "s-label", [LABEL])
|
||||
self.assertEqual(t.pipeline_state, "groundTruth")
|
||||
self.assertIsNone(t.current_stage_id)
|
||||
|
||||
def test_finalize_when_not_inlabel_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_finalize("inReview", "s-rev1", PIPELINE)
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_finalize_when_groundtruth_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError):
|
||||
compute_finalize("groundTruth", None, PIPELINE)
|
||||
|
||||
|
||||
class ReviewTests(unittest.TestCase):
|
||||
def test_accept_advances_to_next_review(self):
|
||||
t = compute_review("inReview", "s-rev1", PIPELINE, "accept")
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev2")
|
||||
|
||||
def test_accept_on_last_review_goes_to_ground_truth(self):
|
||||
t = compute_review("inReview", "s-rev2", PIPELINE, "accept")
|
||||
self.assertEqual(t.pipeline_state, "groundTruth")
|
||||
self.assertIsNone(t.current_stage_id)
|
||||
|
||||
def test_accept_with_corrections_advances(self):
|
||||
t = compute_review("inReview", "s-rev1", PIPELINE, "acceptWithCorrections")
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev2")
|
||||
|
||||
def test_reject_returns_to_first_stage(self):
|
||||
t = compute_review("inReview", "s-rev2", PIPELINE, "reject")
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-label")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_review_when_not_inreview_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_review("inLabel", "s-label", PIPELINE, "accept")
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_review_invalid_decision_raises_422(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_review("inReview", "s-rev1", PIPELINE, "maybe")
|
||||
self.assertEqual(ctx.exception.status, 422)
|
||||
|
||||
|
||||
class ReferenceStandardTests(unittest.TestCase):
|
||||
def test_set_reference_ok_on_ground_truth(self):
|
||||
# Should not raise.
|
||||
validate_set_reference("groundTruth", True)
|
||||
|
||||
def test_set_reference_blocked_off_ground_truth(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
validate_set_reference("inLabel", True)
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_unset_reference_allowed_in_any_state(self):
|
||||
# Removing the flag is always fine, even mid-pipeline.
|
||||
validate_set_reference("inLabel", False)
|
||||
validate_set_reference("inReview", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""`BẢN CAM KẾT` → `ban_cam_ket` matches `bieu_mau_sang_kien_template.json` keys (fe0)."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.be01.official_to_data_blank import official_to_data_blank
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_TEMPLATE_PATH = _REPO_ROOT / "fe0" / "public" / "assets" / "bieu_mau_sang_kien_template.json"
|
||||
|
||||
|
||||
def _minimal_official_with_bck() -> dict:
|
||||
if not _TEMPLATE_PATH.is_file():
|
||||
pytest.skip("fe0 template JSON not found at %s" % _TEMPLATE_PATH)
|
||||
raw = json.loads(_TEMPLATE_PATH.read_text(encoding="utf-8"))
|
||||
official = {"BẢN CAM KẾT": copy.deepcopy(raw["BẢN CAM KẾT"])}
|
||||
bck = official["BẢN CAM KẾT"]
|
||||
bck["Ngày ký"] = {"Ngày": "15", "Tháng": "4", "Năm": "2026"}
|
||||
i1 = bck["I. THÔNG TIN CHỦ THỂ CAM KẾT"]
|
||||
i1["Tác giả đăng ký sáng kiến"] = "Nguyễn Văn A"
|
||||
i1["CCCD/Hộ chiếu số"] = "079012345678"
|
||||
i1["Đơn vị"] = "Khoa X"
|
||||
i1["Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = "Bài báo thử nghiệm"
|
||||
i1["Năm xét công nhận sáng kiến"] = "2026"
|
||||
vt = i1["Vai trò đối với bài báo (☑ vào ô tương ứng)"]
|
||||
vt["Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = True
|
||||
vt["Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = False
|
||||
ii = bck["II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)"]
|
||||
q = ii["1. Quyền sở hữu đối với bài báo trong nước/quốc tế"]
|
||||
k1 = (
|
||||
"Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD"
|
||||
)
|
||||
k2 = (
|
||||
"Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD"
|
||||
)
|
||||
q[k1] = True
|
||||
q[k2] = False
|
||||
kd = "Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD"
|
||||
ii["2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế"][kd] = True
|
||||
ku = (
|
||||
"Cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu"
|
||||
)
|
||||
ii["3. Cam kết bài báo trong nước/quốc tế uy tín"][ku] = True
|
||||
kt = (
|
||||
"Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ"
|
||||
)
|
||||
ii["4. Tuân thủ pháp luật sở hữu trí tuệ"][kt] = True
|
||||
bck["Người cam kết (Ký tên, ghi rõ họ tên)"] = "Nguyễn Văn A"
|
||||
return official
|
||||
|
||||
|
||||
def test_ban_cam_ket_from_numbered_template_keys():
|
||||
out = official_to_data_blank(_minimal_official_with_bck())
|
||||
b = out["ban_cam_ket"]
|
||||
assert b["tac_gia_dang_ky"] == "Nguyễn Văn A"
|
||||
assert b["cccd"] == "079012345678"
|
||||
assert b["don_vi"] == "Khoa X"
|
||||
assert b["ten_bai_bao"] == "Bài báo thử nghiệm"
|
||||
assert b["nam_xet"] == "2026"
|
||||
assert b["ngay_ky"] == {"ngay": "15", "thang": "4", "nam": "2026"}
|
||||
assert b["vai_tro"]["tac_gia_chinh"] is True
|
||||
assert b["vai_tro"]["dong_tac_gia"] is False
|
||||
assert b["cam_ket"]["quyen_so_huu_1"] is True
|
||||
assert b["cam_ket"]["quyen_so_huu_2"] is False
|
||||
assert b["cam_ket"]["dong_thuan"] is True
|
||||
assert b["cam_ket"]["bai_bao_uy_tin"] is True
|
||||
assert b["cam_ket"]["tuan_thu_phap_luat"] is True
|
||||
assert b["nguoi_cam_ket"] == "Nguyễn Văn A"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Cover / Mẫu 02 don_vi resolution for legacy officialBieuMau JSON.
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_official_to_data_blank_don_vi -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.be01.official_to_data_blank import _resolve_don_vi_cong_tac, official_to_data_blank
|
||||
|
||||
|
||||
class OfficialToDataBlankDonViTests(unittest.TestCase):
|
||||
def test_resolve_prefers_explicit_cover(self) -> None:
|
||||
official = {
|
||||
"TRANG BÌA": {"Đơn vị công tác": " Phòng A "},
|
||||
"MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": {
|
||||
"Đơn vị": "",
|
||||
"Danh sách tác giả": [
|
||||
{"STT": "1", "Họ và tên": "X", "Nơi công tác": "Phòng B"},
|
||||
],
|
||||
},
|
||||
}
|
||||
self.assertEqual(_resolve_don_vi_cong_tac(official), "Phòng A")
|
||||
|
||||
def test_resolve_falls_back_to_first_author_workplace(self) -> None:
|
||||
official = {
|
||||
"TRANG BÌA": {"Đơn vị công tác": ""},
|
||||
"MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": {
|
||||
"Đơn vị": "",
|
||||
"Danh sách tác giả": [
|
||||
{
|
||||
"STT": "1",
|
||||
"Họ và tên": "Nguyễn Văn A",
|
||||
"Nơi công tác": "Trường Y",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
self.assertEqual(_resolve_don_vi_cong_tac(official), "Trường Y")
|
||||
|
||||
def test_official_to_data_blank_sets_trang_bia_and_mau02(self) -> None:
|
||||
official = {
|
||||
"TRANG BÌA": {
|
||||
"Tên sáng kiến (Tiếng Việt)": "SK1",
|
||||
"Tác giả/nhóm tác giả sáng kiến": "A",
|
||||
"Đơn vị công tác": "",
|
||||
"Thông tin liên hệ (Điện thoại, Email)": "",
|
||||
"Năm": "2026",
|
||||
},
|
||||
"MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": {
|
||||
"Đơn vị": "",
|
||||
"Danh sách tác giả": [
|
||||
{
|
||||
"STT": "1",
|
||||
"Họ và tên": "A",
|
||||
"Ngày tháng năm sinh": "",
|
||||
"Nơi công tác": "Khoa X",
|
||||
"Chức danh": "",
|
||||
"Trình độ chuyên môn": "",
|
||||
"Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến": "100",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
ctx = official_to_data_blank(official)
|
||||
self.assertEqual(ctx["trang_bia"]["don_vi"], "Khoa X")
|
||||
self.assertEqual(ctx["mau_02"]["don_vi"], "Khoa X")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Registration OTP API (PostgreSQL + mocked outbound mail).
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
# Ensure migrations through 014_registration_otp.sql are applied.
|
||||
cd be0 && python -m unittest tests.test_registration_otp -v
|
||||
|
||||
Optional live login smoke (**credentials must never be committed**):
|
||||
|
||||
export TEST_LIVE_AUTH_EMAIL="nltanh@ump.edu.vn"
|
||||
export TEST_LIVE_AUTH_PASSWORD='<paste password locally>'
|
||||
cd be0 && python -m unittest tests.test_registration_otp.LiveAuthLoginOptionalTests.test_login_with_env_credentials -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.auth_register_staff_fixture import register_staff_fields
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
|
||||
_TEST_PASSWORD = "Testpass1!"
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB,
|
||||
"Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives",
|
||||
)
|
||||
class RegistrationOtpApiTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def _delete_user(self, email: str) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import User
|
||||
|
||||
async with get_session() as session:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one_or_none()
|
||||
if user is not None:
|
||||
await session.delete(user)
|
||||
|
||||
async def test_register_verify_otp_then_login(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
email = f"otp-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
captured: list[str] = []
|
||||
|
||||
async def grab(_to: str, raw: str) -> None:
|
||||
captured.append(raw)
|
||||
|
||||
try:
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab):
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"fullName": "OTP Tester",
|
||||
"email": email,
|
||||
"password": _TEST_PASSWORD,
|
||||
"passwordConfirm": _TEST_PASSWORD,
|
||||
**register_staff_fields(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(captured)
|
||||
otp = captured[0]
|
||||
self.assertEqual(len(otp), 6)
|
||||
self.assertTrue(otp.isdigit())
|
||||
|
||||
with TestClient(app) as client:
|
||||
blocked = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 403, blocked.text)
|
||||
|
||||
bad = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": "000000"})
|
||||
self.assertEqual(bad.status_code, 400, bad.text)
|
||||
|
||||
ok = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": otp})
|
||||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
|
||||
lg = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(lg.status_code, 200, lg.text)
|
||||
self.assertTrue(lg.json().get("accessToken"))
|
||||
finally:
|
||||
await self._delete_user(email)
|
||||
|
||||
|
||||
_LIVE_PW = os.getenv("TEST_LIVE_AUTH_PASSWORD", "").strip()
|
||||
_LIVE_EMAIL = os.getenv("TEST_LIVE_AUTH_EMAIL", "nltanh@ump.edu.vn").strip().lower()
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB and bool(_LIVE_PW),
|
||||
"Set INITIATIVE_DATABASE_URL and TEST_LIVE_AUTH_PASSWORD for optional login smoke "
|
||||
"(use TEST_LIVE_AUTH_EMAIL to override default nltanh@ump.edu.vn).",
|
||||
)
|
||||
class LiveAuthLoginOptionalTests(unittest.TestCase):
|
||||
"""Uses secrets from env only — passwords must never appear in source control."""
|
||||
|
||||
def test_login_with_env_credentials(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": _LIVE_EMAIL, "password": _LIVE_PW},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue(r.json().get("accessToken"))
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Registration stack alignment: frontend-shaped payloads, API, PostgreSQL, and MinIO.
|
||||
|
||||
Mirrors the JSON body produced by fe0 ``src/lib/auth-service.ts`` (``register()``);
|
||||
when changing that client, update this test's payload builder if needed.
|
||||
|
||||
Flow:
|
||||
1. POST /api/v1/auth/register — expect verification flow (no JWT); DB rows match payload.
|
||||
2. Login blocked with 403 until verify-otp.
|
||||
3. POST verify-otp — DB email_verified, OTP row consumed.
|
||||
4. Login — JWT issued.
|
||||
5. Minimal draft save + evidence upload — MinIO attachments bucket contains object at ``storageKey`` (``head_object``).
|
||||
|
||||
Run (same env style as test_backup_e2e):
|
||||
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
||||
export S3_ENDPOINT_URL="http://127.0.0.1:19000"
|
||||
export S3_PUBLIC_ENDPOINT_URL="http://127.0.0.1:19000"
|
||||
export S3_ACCESS_KEY="minio_user"
|
||||
export S3_SECRET_KEY="minio_password"
|
||||
export S3_BUCKET_ATTACHMENTS="initiative-attachments"
|
||||
export S3_BUCKET_EXPORTS="initiative-exports"
|
||||
export S3_BUCKET_QUARANTINE="initiative-quarantine"
|
||||
export REGISTRATION_STACK_TEST=1
|
||||
cd be0 && python -m unittest tests.test_registration_stack_alignment -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.auth_register_staff_fixture import register_staff_fields
|
||||
from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle
|
||||
|
||||
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
||||
_S3_KEYS = (
|
||||
"S3_ENDPOINT_URL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_BUCKET_ATTACHMENTS",
|
||||
"S3_BUCKET_EXPORTS",
|
||||
"S3_BUCKET_QUARANTINE",
|
||||
)
|
||||
_HAS_S3 = all(os.getenv(k, "").strip() for k in _S3_KEYS)
|
||||
_RUN_ALIGN = os.getenv("REGISTRATION_STACK_TEST", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
_TEST_PASSWORD = "Testpass1!"
|
||||
_MIN_PDF = (
|
||||
b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n" + b"0" * 120
|
||||
)
|
||||
|
||||
|
||||
def _fe0_style_register_json(
|
||||
*,
|
||||
email: str,
|
||||
full_name: str,
|
||||
password: str,
|
||||
staff: dict[str, str],
|
||||
) -> dict:
|
||||
"""Same keys as fe0 auth-service.register() JSON body (no ``role``, no camelCase drift)."""
|
||||
return {
|
||||
"fullName": full_name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"passwordConfirm": password,
|
||||
"employeeId": staff["employeeId"],
|
||||
"academicTitleCode": staff["academicTitleCode"],
|
||||
"unitNameFreetext": staff["unitNameFreetext"],
|
||||
"jobTitle": staff["jobTitle"],
|
||||
}
|
||||
|
||||
|
||||
def _token_hash(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _s3_client():
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=os.environ["S3_ENDPOINT_URL"].strip(),
|
||||
aws_access_key_id=os.environ["S3_ACCESS_KEY"].strip(),
|
||||
aws_secret_access_key=os.environ["S3_SECRET_KEY"].strip(),
|
||||
region_name=os.getenv("S3_REGION", "us-east-1"),
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_RUN_DB and _HAS_S3 and _RUN_ALIGN,
|
||||
"Need INITIATIVE_DATABASE_URL, full S3_* env, REGISTRATION_STACK_TEST=1 (see module docstring).",
|
||||
)
|
||||
class RegistrationStackAlignmentTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
await eng.init_engine()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
from src.initiative_db import engine as eng
|
||||
|
||||
await eng.dispose_engine()
|
||||
|
||||
async def _delete_user_and_initiatives(self, email: str) -> None:
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import Initiative, User
|
||||
|
||||
async with get_session() as session:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
return
|
||||
inis = (
|
||||
await session.execute(select(Initiative).where(Initiative.owner_id == user.id))
|
||||
).scalars().all()
|
||||
for ini in inis:
|
||||
await session.delete(ini)
|
||||
await session.flush()
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
async def test_register_api_db_login_verify_minio_alignment(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.initiative_db.engine import get_session
|
||||
from src.initiative_db.models import (
|
||||
RegistrationOtpCode,
|
||||
User,
|
||||
UserRoleRow,
|
||||
UserStaffProfile,
|
||||
)
|
||||
|
||||
email = f"align-reg-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
||||
staff = register_staff_fields()
|
||||
full_name = "Alignment Register User"
|
||||
body = _fe0_style_register_json(
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
password=_TEST_PASSWORD,
|
||||
staff=staff,
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def grab_mail(_to: str, raw: str) -> None:
|
||||
captured.append(raw)
|
||||
|
||||
bucket = os.environ["S3_BUCKET_ATTACHMENTS"].strip()
|
||||
s3 = _s3_client()
|
||||
storage_key: str | None = None
|
||||
|
||||
try:
|
||||
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
||||
with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab_mail):
|
||||
with TestClient(app) as client:
|
||||
# --- Register (mirrors fe0 fetch body) ---
|
||||
r = client.post("/api/v1/auth/register", json=body)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
payload = r.json()
|
||||
self.assertTrue(payload.get("emailVerificationRequired"), payload)
|
||||
self.assertNotIn("accessToken", payload)
|
||||
self.assertEqual(payload.get("email"), email)
|
||||
self.assertIn("message", payload)
|
||||
|
||||
u_out = payload.get("user") or {}
|
||||
self.assertEqual(u_out.get("email"), email)
|
||||
self.assertEqual(u_out.get("name"), full_name)
|
||||
self.assertIs(u_out.get("emailVerified"), False)
|
||||
self.assertIn("viewer", u_out.get("roles") or [])
|
||||
sp_out = u_out.get("staffProfile") or {}
|
||||
self.assertEqual(sp_out.get("employeeId"), staff["employeeId"])
|
||||
self.assertEqual(sp_out.get("academicTitleCode"), staff["academicTitleCode"])
|
||||
self.assertEqual(sp_out.get("unitNameFreetext"), staff["unitNameFreetext"])
|
||||
self.assertEqual(sp_out.get("jobTitle"), staff["jobTitle"])
|
||||
|
||||
self.assertTrue(captured, "OTP should be issued")
|
||||
raw_otp = captured[0]
|
||||
self.assertEqual(len(raw_otp), 6, raw_otp)
|
||||
self.assertTrue(raw_otp.isdigit())
|
||||
|
||||
with TestClient(app) as client:
|
||||
blocked = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 403, blocked.text)
|
||||
|
||||
# --- PostgreSQL: user, profile, role, OTP row ---
|
||||
async with get_session() as session:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one()
|
||||
self.assertFalse(user.email_verified)
|
||||
self.assertEqual(user.full_name, full_name)
|
||||
|
||||
profile = await session.get(UserStaffProfile, user.id)
|
||||
self.assertIsNotNone(profile)
|
||||
assert profile is not None
|
||||
self.assertEqual(profile.employee_id, staff["employeeId"])
|
||||
self.assertEqual(profile.academic_title_code, staff["academicTitleCode"])
|
||||
self.assertEqual(profile.job_title, staff["jobTitle"])
|
||||
|
||||
roles = (
|
||||
await session.execute(
|
||||
select(UserRoleRow.role).where(UserRoleRow.user_id == user.id)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(sorted(roles), ["viewer"])
|
||||
|
||||
otps = (
|
||||
await session.execute(
|
||||
select(RegistrationOtpCode).where(
|
||||
RegistrationOtpCode.user_id == user.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(len(otps), 1)
|
||||
o = otps[0]
|
||||
self.assertIsNone(o.used_at)
|
||||
self.assertEqual(o.otp_hash, _token_hash(raw_otp))
|
||||
|
||||
# --- Verify OTP ---
|
||||
with TestClient(app) as client:
|
||||
vr = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": raw_otp})
|
||||
self.assertEqual(vr.status_code, 200, vr.text)
|
||||
|
||||
async with get_session() as session:
|
||||
user = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one()
|
||||
self.assertTrue(user.email_verified)
|
||||
otps = (
|
||||
await session.execute(
|
||||
select(RegistrationOtpCode).where(
|
||||
RegistrationOtpCode.user_id == user.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(len(otps), 1)
|
||||
self.assertIsNotNone(otps[0].used_at)
|
||||
|
||||
# --- Login ---
|
||||
with TestClient(app) as client:
|
||||
ok = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": _TEST_PASSWORD},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
token = ok.json()["accessToken"]
|
||||
self.assertTrue(token)
|
||||
|
||||
cr = client.post(
|
||||
"/api/applications/new",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "Alignment case"},
|
||||
)
|
||||
self.assertEqual(cr.status_code, 200, cr.text)
|
||||
shell = cr.json().get("application") or {}
|
||||
case_id = str(shell.get("draft_case_id") or "").strip()
|
||||
self.assertTrue(case_id, shell)
|
||||
|
||||
bundle = minimal_tabs_bundle(initiative_name="Align Initiative")
|
||||
for tab_name in ("report", "application", "contribution"):
|
||||
dr = client.post(
|
||||
"/api/v1/application-drafts",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"caseId": case_id, "tab": tab_name, "data": bundle[tab_name]},
|
||||
)
|
||||
self.assertEqual(dr.status_code, 200, dr.text)
|
||||
|
||||
er = client.post(
|
||||
f"/api/v1/application-drafts/{case_id}/evidence",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
data={"kind": "technical"},
|
||||
files={"file": ("align.pdf", io.BytesIO(_MIN_PDF), "application/pdf")},
|
||||
)
|
||||
self.assertEqual(er.status_code, 200, er.text, er.text)
|
||||
ev_body = er.json()
|
||||
storage_key = str(ev_body.get("storageKey") or "").strip()
|
||||
self.assertTrue(storage_key, ev_body)
|
||||
|
||||
# MinIO must contain bytes at the key the API recorded.
|
||||
assert storage_key is not None
|
||||
head = s3.head_object(Bucket=bucket, Key=storage_key)
|
||||
self.assertGreater(int(head["ContentLength"]), 0)
|
||||
|
||||
finally:
|
||||
await self._delete_user_and_initiatives(email)
|
||||
if storage_key:
|
||||
try:
|
||||
s3.delete_object(Bucket=bucket, Key=storage_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Unit tests for `repair_split_submission` merge logic (no PostgreSQL required).
|
||||
|
||||
DB integration for the full repair is gated on INITIATIVE_DATABASE_URL (see test_applications_db_integration.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.initiative_db.repair_split_submission import (
|
||||
merge_payload_for_case_repair,
|
||||
tabs_effectively_empty,
|
||||
)
|
||||
|
||||
|
||||
class RepairSplitSubmissionPureTests(unittest.TestCase):
|
||||
def test_tabs_effectively_empty_true(self) -> None:
|
||||
self.assertTrue(tabs_effectively_empty({}))
|
||||
self.assertTrue(tabs_effectively_empty({"report": {}, "application": {}, "contribution": {}}))
|
||||
self.assertTrue(tabs_effectively_empty(None))
|
||||
|
||||
def test_tabs_effectively_empty_false(self) -> None:
|
||||
self.assertFalse(tabs_effectively_empty({"report": {"x": 1}}))
|
||||
|
||||
def test_merge_prefers_good_tabs(self) -> None:
|
||||
good = {
|
||||
"tabs": {"application": {"initiativeName": "A"}, "report": {}, "contribution": {}},
|
||||
"caseId": "CASE-OLD",
|
||||
}
|
||||
bad = {
|
||||
"tabs": {},
|
||||
"submissionRecord": {"id": "sub-abc"},
|
||||
"submissionFile": {"url": "/submitted-initiatives/x.pdf", "type": "pdf"},
|
||||
}
|
||||
m = merge_payload_for_case_repair(
|
||||
target_case_code="CASE-OK",
|
||||
good_payload=good,
|
||||
bad_payload=bad,
|
||||
)
|
||||
self.assertEqual(m["caseId"], "CASE-OK")
|
||||
self.assertEqual(m["submissionRecord"]["id"], "sub-abc")
|
||||
self.assertEqual(m["submissionFile"]["url"], "/submitted-initiatives/x.pdf")
|
||||
self.assertEqual(m["tabs"]["application"]["initiativeName"], "A")
|
||||
|
||||
def test_merge_falls_back_to_bad_tabs_when_good_empty(self) -> None:
|
||||
good = {"tabs": {}, "caseId": "CASE-OK"}
|
||||
bad = {"tabs": {"application": {"k": "v"}}, "submissionRecord": {"id": "sub-x"}}
|
||||
m = merge_payload_for_case_repair(target_case_code="CASE-OK", good_payload=good, bad_payload=bad)
|
||||
self.assertEqual(m["tabs"]["application"]["k"], "v")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"]])
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Security regression tests for authenticated / removed routes (no Postgres required).
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_security_routes -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.security_token_fixture import mint_bearer_token
|
||||
|
||||
|
||||
class SecurityRoutesTests(unittest.TestCase):
|
||||
def _client(self):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_removed_upload_document_returns_404(self) -> None:
|
||||
client = self._client()
|
||||
r = client.post("/upload_document", files={"file": ("x.pdf", b"%PDF", "application/pdf")})
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_removed_get_page_returns_404(self) -> None:
|
||||
client = self._client()
|
||||
r = client.post("/get_page", data={"new_page_number": "1"})
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_list_applications_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.get("/api/applications")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_list_applications_rejects_viewer(self) -> None:
|
||||
client = self._client()
|
||||
headers = {"Authorization": mint_bearer_token(roles=("viewer",))}
|
||||
with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False):
|
||||
r = client.get("/api/applications", headers=headers)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_list_applications_allows_staff_without_db(self) -> None:
|
||||
client = self._client()
|
||||
headers = {"Authorization": mint_bearer_token(roles=("admin",))}
|
||||
with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False):
|
||||
with patch("main._load_submitted_items", return_value=[]):
|
||||
r = client.get("/api/applications", headers=headers)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertIn("data", r.json())
|
||||
|
||||
def test_get_application_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.get("/api/applications/sub-deadbeefdeadbeef")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_get_application_rejects_viewer_without_row(self) -> None:
|
||||
client = self._client()
|
||||
headers = {"Authorization": mint_bearer_token(roles=("viewer",), email="viewer@ump.edu.vn")}
|
||||
with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False):
|
||||
with patch("main._get_application_from_file_index", return_value=None):
|
||||
r = client.get("/api/applications/sub-deadbeefdeadbeef", headers=headers)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_review_documents_list_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.get("/api/v1/review-documents", params={"caseId": "CASE-1"})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_review_documents_create_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.post(
|
||||
"/api/v1/review-documents",
|
||||
json={"caseId": "CASE-1", "officialBieuMau": {}},
|
||||
)
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_chat_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.post("/api/v1/chat", json={"message": "hello"})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_analyze_compliance_requires_auth(self) -> None:
|
||||
client = self._client()
|
||||
r = client.post(
|
||||
"/analyze_compliance",
|
||||
json={"external_requirements": ["ext"], "internal_requirements": ["int"]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_test_ollama_requires_admin(self) -> None:
|
||||
client = self._client()
|
||||
viewer = {"Authorization": mint_bearer_token(roles=("viewer",))}
|
||||
r_viewer = client.post("/test_ollama", json={"prompt": "hi"}, headers=viewer)
|
||||
self.assertEqual(r_viewer.status_code, 403)
|
||||
|
||||
admin = {"Authorization": mint_bearer_token(roles=("admin",))}
|
||||
with patch(
|
||||
"main.ollama.chat",
|
||||
return_value={"message": {"content": "ok"}},
|
||||
):
|
||||
r_admin = client.post("/test_ollama", json={"prompt": "hi"}, headers=admin)
|
||||
self.assertEqual(r_admin.status_code, 200, r_admin.text)
|
||||
|
||||
def test_ideas_post_requires_admin(self) -> None:
|
||||
client = self._client()
|
||||
headers = {"Authorization": mint_bearer_token(roles=("viewer",))}
|
||||
r = client.post(
|
||||
"/api/v1/ideas",
|
||||
json={"title": "t", "description": "d"},
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_security_headers_on_health(self) -> None:
|
||||
client = self._client()
|
||||
r = client.get("/health")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.headers.get("x-content-type-options"), "nosniff")
|
||||
self.assertEqual(r.headers.get("x-frame-options"), "DENY")
|
||||
self.assertIn("referrer-policy", r.headers)
|
||||
|
||||
|
||||
class JwtSecretTests(unittest.TestCase):
|
||||
def test_production_requires_secret(self) -> None:
|
||||
from src.auth_jwt import jwt_secret
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("JWT_SECRET", "ENVIRONMENT")}
|
||||
env["ENVIRONMENT"] = "production"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
jwt_secret()
|
||||
|
||||
def test_development_allows_dev_fallback(self) -> None:
|
||||
from src.auth_jwt import jwt_secret
|
||||
|
||||
with patch.dict(os.environ, {"ENVIRONMENT": "development"}, clear=False):
|
||||
os.environ.pop("JWT_SECRET", None)
|
||||
secret = jwt_secret()
|
||||
self.assertGreaterEqual(len(secret), 32)
|
||||
|
||||
|
||||
class LoginRateLimitTests(unittest.TestCase):
|
||||
def test_login_rate_limit_blocks_after_threshold(self) -> None:
|
||||
from src.auth_rate_limit import allow_login
|
||||
|
||||
email = "ratelimit-test@ump.edu.vn"
|
||||
ip = "203.0.113.50"
|
||||
for _ in range(5):
|
||||
self.assertTrue(allow_login(email, ip))
|
||||
self.assertFalse(allow_login(email, ip))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Unit tests for staff_profile_domain (no database)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from src.initiative_db.models import User, UserStaffProfile
|
||||
from src.staff_profile_domain import (
|
||||
apply_reverify_from_verified,
|
||||
assert_complete_for_submission,
|
||||
assert_employee_id_shape,
|
||||
assert_unit_exclusive,
|
||||
material_staff_fields_changed,
|
||||
normalize_employee_id,
|
||||
staff_row_for_audit,
|
||||
)
|
||||
|
||||
|
||||
class StaffProfileDomainTests(unittest.TestCase):
|
||||
def test_normalize_employee_id(self) -> None:
|
||||
self.assertIsNone(normalize_employee_id(None))
|
||||
self.assertEqual(normalize_employee_id(" ab-12 "), "AB-12")
|
||||
|
||||
def test_employee_id_shape(self) -> None:
|
||||
assert_employee_id_shape(None)
|
||||
assert_employee_id_shape("ABC-123")
|
||||
with self.assertRaises(ValueError):
|
||||
assert_employee_id_shape("ab")
|
||||
|
||||
def test_unit_exclusive(self) -> None:
|
||||
uid = uuid.uuid4()
|
||||
user = User(
|
||||
id=uid,
|
||||
email="t@ump.edu.vn",
|
||||
password_hash="x",
|
||||
full_name="T",
|
||||
unit_id=uuid.uuid4(),
|
||||
)
|
||||
sp = UserStaffProfile(user_id=uid, unit_name_freetext=" Khoa X ")
|
||||
with self.assertRaises(ValueError):
|
||||
assert_unit_exclusive(user, sp)
|
||||
|
||||
def test_material_staff_fields_changed(self) -> None:
|
||||
a = staff_row_for_audit(
|
||||
UserStaffProfile(user_id=uuid.uuid4(), job_title="A"),
|
||||
None,
|
||||
)
|
||||
b = staff_row_for_audit(
|
||||
UserStaffProfile(user_id=uuid.uuid4(), job_title="B"),
|
||||
None,
|
||||
)
|
||||
self.assertTrue(material_staff_fields_changed(a, b))
|
||||
self.assertFalse(material_staff_fields_changed(a, a))
|
||||
|
||||
def test_apply_reverify_sets_pending(self) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sp = UserStaffProfile(
|
||||
user_id=uuid.uuid4(),
|
||||
profile_verification_status="verified",
|
||||
verified_at=datetime.now(timezone.utc),
|
||||
verified_by_user_id=uuid.uuid4(),
|
||||
rejection_reason=None,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
apply_reverify_from_verified(sp, now)
|
||||
self.assertEqual(sp.profile_verification_status, "pending")
|
||||
self.assertIsNone(sp.verified_at)
|
||||
self.assertEqual(sp.verification_submitted_at, now)
|
||||
|
||||
def test_assert_complete_for_submission(self) -> None:
|
||||
uid = uuid.uuid4()
|
||||
user = User(
|
||||
id=uid,
|
||||
email="t@ump.edu.vn",
|
||||
password_hash="x",
|
||||
full_name="T",
|
||||
unit_id=uuid.uuid4(),
|
||||
)
|
||||
sp = UserStaffProfile(
|
||||
user_id=uid,
|
||||
employee_id="CB-001",
|
||||
academic_title_code="master",
|
||||
job_title="GV",
|
||||
)
|
||||
assert_complete_for_submission(user, sp)
|
||||
|
||||
sp2 = UserStaffProfile(user_id=uid, employee_id=None)
|
||||
with self.assertRaises(ValueError):
|
||||
assert_complete_for_submission(user, sp2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for submit readiness validation (no database)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.initiative_db.submission_readiness import (
|
||||
ApplicationSubmissionNotReadyError,
|
||||
collect_submission_readiness_gaps,
|
||||
)
|
||||
|
||||
from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle
|
||||
|
||||
|
||||
class SubmissionReadinessTests(unittest.TestCase):
|
||||
def test_minimal_tabs_with_technical_evidence_ok(self) -> None:
|
||||
tabs = minimal_tabs_bundle()
|
||||
gaps = collect_submission_readiness_gaps(
|
||||
tabs,
|
||||
{"research": False, "textbook": False, "technical": True},
|
||||
)
|
||||
self.assertEqual(gaps, [])
|
||||
|
||||
def test_missing_evidence_fails(self) -> None:
|
||||
tabs = minimal_tabs_bundle()
|
||||
gaps = collect_submission_readiness_gaps(
|
||||
tabs,
|
||||
{"research": False, "textbook": False, "technical": False},
|
||||
)
|
||||
self.assertTrue(any("Nhóm 1" in g for g in gaps))
|
||||
|
||||
def test_missing_honesty_flags(self) -> None:
|
||||
tabs = minimal_tabs_bundle()
|
||||
tabs["report"]["honestyConfirmed"] = False
|
||||
gaps = collect_submission_readiness_gaps(
|
||||
tabs,
|
||||
{"research": False, "textbook": False, "technical": True},
|
||||
)
|
||||
self.assertTrue(any("Báo cáo" in g and "cam kết" in g for g in gaps))
|
||||
|
||||
def test_exception_carries_missing(self) -> None:
|
||||
exc = ApplicationSubmissionNotReadyError(["a", "b"])
|
||||
self.assertEqual(exc.missing, ["a", "b"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Projection of `tabs.application.researchEvidenceKind` onto list rows (no DB).
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_submissions_projection_research_kind -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class SubmissionsResearchEvidenceKindProjectionTests(unittest.TestCase):
|
||||
def test_poster_without_review_round_trips_on_api_row(self) -> None:
|
||||
from src.initiative_db.submissions import _as_submission_item
|
||||
|
||||
ini = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
case_code="CASE-PROJ-RK",
|
||||
status="submitted",
|
||||
submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
payload = {
|
||||
"submissionRecord": {
|
||||
"id": "sub-deadbeefcafe",
|
||||
"submittedDate": "2026-01-01T12:00:00.000Z",
|
||||
"name": "Test",
|
||||
},
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "research",
|
||||
"researchEvidenceKind": "poster-without-review",
|
||||
}
|
||||
},
|
||||
}
|
||||
row = _as_submission_item(ini, payload) # type: ignore[arg-type]
|
||||
self.assertEqual(row.get("researchEvidenceKind"), "poster-without-review")
|
||||
self.assertEqual(row.get("initiativeClassification"), "research")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for merit label derivation from draft JSON (notification body).
|
||||
|
||||
Run: cd be0 && python -m unittest tests.test_user_notifications_merit -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class MeritCategoryFromDraftTests(unittest.TestCase):
|
||||
def test_poster_without_review_is_trung_binh(self) -> None:
|
||||
from src.initiative_db.user_notifications import merit_category_label_from_draft_payload
|
||||
|
||||
payload = {
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "research",
|
||||
"researchEvidenceKind": "poster-without-review",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(merit_category_label_from_draft_payload(payload), "Trung bình")
|
||||
|
||||
def test_international_remains_xuat_sac(self) -> None:
|
||||
from src.initiative_db.user_notifications import merit_category_label_from_draft_payload
|
||||
|
||||
payload = {
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "research",
|
||||
"researchEvidenceKind": "international",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(merit_category_label_from_draft_payload(payload), "Xuất sắc")
|
||||
|
||||
def test_textbook_book_is_xuat_sac(self) -> None:
|
||||
from src.initiative_db.user_notifications import merit_category_label_from_draft_payload
|
||||
|
||||
payload = {
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "textbook",
|
||||
"textbookEvidenceKind": "book",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(merit_category_label_from_draft_payload(payload), "Xuất sắc")
|
||||
|
||||
def test_poster_with_review_still_kha_bucket(self) -> None:
|
||||
from src.initiative_db.user_notifications import merit_category_label_from_draft_payload
|
||||
|
||||
payload = {
|
||||
"tabs": {
|
||||
"application": {
|
||||
"initiativeClassification": "research",
|
||||
"researchEvidenceKind": "poster",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(merit_category_label_from_draft_payload(payload), "Khá")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user