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