"""Project attribution isolation and history contract, using an in-memory database.""" import importlib.util import unittest from sqlalchemy import create_engine from sqlalchemy.orm import Session from fastapi import HTTPException class ProjectManagementTest(unittest.TestCase): def setUp(self): self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing') from app.services import project_management as svc from app.models.project_management import Project, ProjectTeam, ProjectTeamMember from app.models.base import Base self.svc, self.Project, self.Team = svc, Project, ProjectTeam self.engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(self.engine) self.db = Session(self.engine) self.user = {'user_id': 'u', 'tenant_no': 't', 'role': 'super_admin'} self.db.add_all([ProjectTeam(id='g', tenant_no='t', team_name='Team', leader_user_id='u'), Project(id='p', tenant_no='t', project_no='P1', project_name='Project', team_id='g', leader_user_id='u', biz_types='cell_storage,presign', status='active')]) self.db.commit() def tearDown(self): if hasattr(self, 'db'): self.db.close() self.engine.dispose() def test_assignment_and_tenant_boundary(self): self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage'), 'p') with self.assertRaises(HTTPException): self.svc.validate_assignment(self.db, 'other', self.user, 'p', 'cell_storage') with self.assertRaises(HTTPException): self.svc.validate_assignment(self.db, 't', self.user, 'p', 'basic_experiment') def test_legacy_edit_keeps_disabled_project(self): self.db.get(self.Project, 'p').status = 'disabled' self.db.commit() self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage', current_project_id='p'), 'p') with self.assertRaises(HTTPException): self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', current_project_id='p') with self.assertRaises(HTTPException): self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', required=True) def test_statistics_excludes_foreign_and_deleted(self): from app.models.models import BizCollectionProtocol self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'), BizCollectionProtocol(id='b', protocol_no='B', tenant_no='other', project_id='p'), BizCollectionProtocol(id='c', protocol_no='C', tenant_no='t', project_id='p', del_flag=1), BizCollectionProtocol(id='d', protocol_no='D', tenant_no='t')]) self.db.commit() result = self.svc.statistics(self.db, 't', self.user) self.assertEqual(result['totals']['cell_storage'], 2) self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2) self.assertEqual(result['unassignedTotals']['cell_storage'], 1) self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage', projectId='p')['total'], 1) def test_assignment_preview_and_atomic_snapshot(self): from app.models.models import BizCollectionProtocol, BizPresignOrder self.db.add_all([BizPresignOrder(id='s', tenant_no='t', project_id=None, converted_protocol_id='a'), BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')]) self.db.commit() result = self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p') self.assertEqual(len(result['records']), 2) with self.assertRaises(HTTPException): self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', []) self.db.rollback() self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id) self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', result['records']) self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p') self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1) def test_presign_statistics_column_groups_totals_and_drill_down(self): from datetime import datetime from app.models.models import BizCollectionProtocol, BizPresignOrder self.db.add_all([ self.Team(id='g2', tenant_no='t', team_name='Presign only', leader_user_id='u'), self.Project(id='p2', tenant_no='t', project_no='P2', project_name='Presign only', team_id='g2', leader_user_id='u', biz_types='cell_storage', status='active'), BizCollectionProtocol(id='protocol', tenant_no='t', protocol_no='CP', project_id='p', create_time=datetime(2026, 11, 4)), ]) for row_id, project_id, status, tenant, deleted, day in ( ('active', 'p', 'active', 't', 0, 4), ('converted', 'p', 'converted', 't', 0, 4), ('draft', 'p2', 'draft', 't', 0, 4), ('void', None, 'void', 't', 0, 4), ('foreign', 'p', 'active', 'foreign', 0, 4), ('deleted', 'p', 'active', 't', 1, 4), ('old', 'p', 'active', 't', 0, 1), ): self.db.add(BizPresignOrder( id=row_id, tenant_no=tenant, protocol_no=row_id, project_id=project_id, order_status=status, del_flag=deleted, create_by='u', create_time=datetime(2026, 11, day), converted_protocol_id='protocol' if status == 'converted' else None, )) self.db.commit() filters = dict(startDate='2026-11-04', endDate='2026-11-04') for group_by, expected in (('project', {'p': 2, 'p2': 1}), ('team', {'g': 2, 'g2': 1})): with self.subTest(group_by=group_by): result = self.svc.statistics(self.db, 't', self.user, groupBy=group_by, **filters) self.assertEqual(result['totals']['presign'], 4) self.assertEqual(result['presignTotal'], 4) self.assertEqual(result['unassignedTotals']['presign'], 1) self.assertEqual(result['totals']['cell_storage'], 1) self.assertEqual({r['id']: r['presign'] for r in result['items']}, expected) for row in result['items']: drill = {('teamId' if group_by == 'team' else 'projectId'): row['id']} self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', **drill, **filters)['total'], row['presign']) for selection in (dict(teamId='g'), dict(projectId='p')): result = self.svc.statistics(self.db, 't', self.user, **selection, **filters) self.assertEqual(result['totals']['presign'], 2) self.assertEqual(result['unassignedTotals']['presign'], 0) self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', unassigned=True, **filters)['total'], 1) denied = self.svc.statistics(self.db, 't', dict(user_id='outsider', role='admin'), **filters) self.assertEqual(denied['totals']['presign'], 0) self.assertEqual(denied['items'], []) self.assertNotIn('presign', self.svc.COUNT_TYPES) def test_team_child_ids_survive_edits(self): from app.models.models import SysUser self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User')) self.db.commit() body = dict(teamName='Team', leaderUserId='u', members=[{'userId': 'u'}]) first = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g') second = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g') self.assertEqual(first['members'][0]['id'], second['members'][0]['id']) with self.assertRaises(HTTPException): self.svc.delete_entity(self.db, 't', self.user, self.Team, 'g') def test_non_member_admin_cannot_choose_project(self): # An ordinary admin with no enabled-role permissions is not a substitute recorder. with self.assertRaises(HTTPException) as error: self.svc.validate_assignment(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'p', 'cell_storage') self.assertEqual(error.exception.status_code, 403) def test_non_member_data_scope_does_not_expand(self): from app.models.models import BizCollectionProtocol self.db.add(BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p')) self.db.commit() user = {'user_id': 'u', 'role': 'admin'} self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 0) self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 0) def test_empty_date_input_is_optional(self): from app.models.models import SysUser self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User')) self.db.commit() result = self.svc.save_entity(self.db, 't', self.user, self.Project, dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate='')) self.assertTrue(result['projectNo'].startswith('XM')) self.assertIsNone(result['startDate']) def test_conversion_table_reverse_graph_and_audit(self): from app.models.models import BizCollectionProtocol from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert self.db.get(self.Project, 'p').biz_types = 'cell_storage,research_donation' self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t'), BizResearchDonation(id='d', donation_no='D', tenant_no='t'), BizProtocolDonationConvert(id='c', protocol_id='a', research_donation_id='d', biz_no='A', convert_type='research_to_storage', tenant_no='t')]) self.db.commit() preview = self.svc.preview_assignment(self.db, 't', self.user, 'research_donation', 'd', 'p') self.assertEqual({r['bizType'] for r in preview['records']}, {'cell_storage', 'research_donation'}) self.svc.assign(self.db, 't', self.user, 'research_donation', 'd', 'p', 'reason', preview['records']) self.assertEqual(self.db.get(BizCollectionProtocol, 'a').project_id, 'p') self.assertEqual(self.db.get(BizResearchDonation, 'd').project_id, 'p') def test_preview_rejects_inaccessible_link_without_writes(self): from app.models.models import BizCollectionProtocol, BizPresignOrder self.db.add_all([BizPresignOrder(id='s', tenant_no='other', converted_protocol_id='a'), BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')]) self.db.commit() with self.assertRaises(HTTPException): self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p') self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id) def test_date_end_inclusive_and_business_types_separate(self): from datetime import datetime from app.models.models import BizPilotApplication self.db.add_all([BizPilotApplication(id='a', tenant_no='t', project_id='p', biz_type='basic_experiment', channel_service_order_id='', create_time=datetime(2026, 1, 2, 23, 59)), BizPilotApplication(id='b', tenant_no='t', project_id='p', biz_type='pilot_service', channel_service_order_id='', create_time=datetime(2026, 1, 3)), BizPilotApplication(id='c', tenant_no='t', project_id='p', biz_type='clinical_application', channel_service_order_id='', create_time=datetime(2026, 1, 2))]) self.db.commit() result = self.svc.statistics(self.db, 't', self.user, startDate='2026-01-02', endDate='2026-01-02') self.assertEqual(result['totals']['basic_experiment'], 1) self.assertEqual(result['totals']['pilot_service'], 0) self.assertEqual(sum(result['totals'].values()), 1) def test_routes_exist_and_protect_management(self): from app.api.project_management import router paths = {r.path for r in router.routes} self.assertIn('/project-management/options', paths) self.assertIn('/web/admin/project-management/assignments/preview', paths) with self.assertRaises(HTTPException) as error: self.svc.check_permission(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'project:team:list') self.assertEqual(error.exception.status_code, 403) def test_enabled_role_union_authorizes_substitute_and_keeps_data_scope(self): from app.models.models import SysRole, SysUserRole, SysRoleMenu, SysMenu, BizCollectionProtocol self.db.add_all([SysRole(id='role1', role_name='reader', tenant_no='t', data_scope='4'), SysRole(id='role2', role_name='assignment', tenant_no='t', data_scope='4'), SysUserRole(id='ur1', user_id='outsider', role_id='role1', tenant_no='t'), SysUserRole(id='ur2', user_id='outsider', role_id='role2', tenant_no='t'), SysMenu(id='menu1', tenant_no='t', menu_name='全部项目', perms='project:project:all'), SysRoleMenu(id='rm1', tenant_no='t', role_id='role2', menu_id='menu1'), BizCollectionProtocol(id='mine', protocol_no='Mine', tenant_no='t', create_by='outsider', project_id='p'), BizCollectionProtocol(id='foreign', protocol_no='Other', tenant_no='t', create_by='u', project_id='p')]) self.db.commit() user = dict(user_id='outsider', role='admin') self.assertEqual(self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage'), 'p') self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 1) self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 1) self.db.get(SysRole, 'role2').status = '0' self.db.commit() with self.assertRaises(HTTPException): self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage') def test_mysql_migration_has_repeat_guards_and_menu_parent_structure(self): from pathlib import Path root = Path(__file__).resolve().parents[2] migration = (root / 'docs/sql/migrations/2026-11-02-project_management.sql').read_text() self.assertEqual(migration.count('CREATE TABLE IF NOT EXISTS'), 6) self.assertEqual(migration.count('information_schema.columns'), 9) self.assertEqual(migration.count('information_schema.statistics'), 4) self.assertNotIn(' BEFORE ', migration) self.assertNotIn('JSON_TABLE', migration) self.assertIn('INTO @project_charset, @project_collation', migration) self.assertEqual(migration.count('ADD COLUMN project_id VARCHAR(64) CHARACTER SET'), 4) self.assertEqual(migration.count('MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET'), 4) menus = (root / 'docs/sql/migrations/2026-11-02-project_management_menu_seed.sql').read_text() self.assertEqual(menus.count('AND NOT EXISTS'), 6) self.assertEqual(menus.count("parent.perms='menu:market'"), 4) self.assertIn("parent.perms='project:team:list'", menus) self.assertIn("parent.perms='project:project:list'", menus) self.assertNotIn('INSERT INTO sys_role_menu', menus) if __name__ == '__main__': unittest.main()