P1 通用查询:viewsets / app / menu / mail / gitea / mc / stats / sync_apps 等视图与命令的裸查询改委托 SDK Manager(Role/Company/Department/Position/Menu/Tenant/App)。 P2 App 体系:app_manager.py 与 app_manifests.py 实现零裸 ORM,职务多值/虚拟职务/公司下拉全部走 SDK。 P3 工具下沉:money/json_encoder/celery_utils/exception_handler/redis_lock 改为纯转发展开;ip_filter/storage 转发并注入 app.ini 配置源;common 下 DateTime/String/Naming/UUID 转发 SDK。 删除死代码 commission/strategies/engine.py(SDK 已有同物且零引用)与 payment/models.py 的 FromTenantAndPlatform;单测 patch 目标同步为 gvsdsdk.*,test_attack_detect fixture 补齐限频去重状态。
202 lines
5.8 KiB
Python
202 lines
5.8 KiB
Python
"""模块:celery_utils.py 单元测试
|
|
|
|
覆盖 RollbackOnFailure / RetryOnFailure / LogTask / SafeDecimal
|
|
四个装饰器,无 Celery 环境下验证同步执行与异常处理。
|
|
"""
|
|
from decimal import Decimal
|
|
from unittest import mock
|
|
|
|
from django.test import SimpleTestCase
|
|
|
|
from gvsdsdk import celery_utils
|
|
|
|
|
|
class RollbackOnFailureTestCase(SimpleTestCase):
|
|
"""RollbackOnFailure 事务回滚装饰器。"""
|
|
|
|
def testNormalReturn_PassesThrough(self):
|
|
@celery_utils.RollbackOnFailure
|
|
def Task():
|
|
return 'ok'
|
|
|
|
self.assertEqual(Task(), 'ok')
|
|
|
|
def testException_MarksRollbackAndReRaises(self):
|
|
@celery_utils.RollbackOnFailure
|
|
def Task():
|
|
raise ValueError('boom')
|
|
|
|
with mock.patch('django.db.transaction.set_rollback') as mock_set_rollback:
|
|
with self.assertRaises(ValueError):
|
|
Task()
|
|
mock_set_rollback.assert_called_once_with(True)
|
|
|
|
def testException_TransactionSetRollbackFails_SilentlyReRaises(self):
|
|
@celery_utils.RollbackOnFailure
|
|
def Task():
|
|
raise ValueError('boom')
|
|
|
|
with mock.patch('django.db.transaction.set_rollback', side_effect=RuntimeError):
|
|
with self.assertRaises(ValueError):
|
|
Task() # 不应抛 RuntimeError
|
|
|
|
def testPreservesFuncMetadata(self):
|
|
@celery_utils.RollbackOnFailure
|
|
def ImportantTask():
|
|
"""Important."""
|
|
|
|
self.assertEqual(ImportantTask.__name__, 'ImportantTask')
|
|
self.assertEqual(ImportantTask.__doc__, 'Important.')
|
|
|
|
|
|
class RetryOnFailureTestCase(SimpleTestCase):
|
|
"""RetryOnFailure 重试装饰器。"""
|
|
|
|
def testSuccessOnFirstAttempt_NoRetry(self):
|
|
CallCount = {'n': 0}
|
|
|
|
@celery_utils.RetryOnFailure(max_retries=3, delay=0)
|
|
def Task():
|
|
CallCount['n'] += 1
|
|
return 'ok'
|
|
|
|
self.assertEqual(Task(), 'ok')
|
|
self.assertEqual(CallCount['n'], 1)
|
|
|
|
def testSuccessAfterRetries(self):
|
|
CallCount = {'n': 0}
|
|
|
|
@celery_utils.RetryOnFailure(max_retries=3, delay=0)
|
|
def Task():
|
|
CallCount['n'] += 1
|
|
if CallCount['n'] < 3:
|
|
raise ValueError('retry')
|
|
return 'ok'
|
|
|
|
self.assertEqual(Task(), 'ok')
|
|
self.assertEqual(CallCount['n'], 3)
|
|
|
|
def testAllRetriesExhausted_RaisesLastException(self):
|
|
CallCount = {'n': 0}
|
|
|
|
@celery_utils.RetryOnFailure(max_retries=2, delay=0)
|
|
def Task():
|
|
CallCount['n'] += 1
|
|
raise ValueError('always fail')
|
|
|
|
with self.assertRaises(ValueError):
|
|
Task()
|
|
# 1 次初始 + 2 次重试 = 3 次
|
|
self.assertEqual(CallCount['n'], 3)
|
|
|
|
def testOnlyRetriesOnSpecifiedExceptions(self):
|
|
CallCount = {'n': 0}
|
|
|
|
@celery_utils.RetryOnFailure(max_retries=3, delay=0, exceptions=(ValueError,))
|
|
def Task():
|
|
CallCount['n'] += 1
|
|
raise TypeError('not retried')
|
|
|
|
with self.assertRaises(TypeError):
|
|
Task()
|
|
# TypeError 不在重试列表 → 立即抛出,只调用 1 次
|
|
self.assertEqual(CallCount['n'], 1)
|
|
|
|
def testZeroMaxRetries_NoRetry(self):
|
|
CallCount = {'n': 0}
|
|
|
|
@celery_utils.RetryOnFailure(max_retries=0, delay=0)
|
|
def Task():
|
|
CallCount['n'] += 1
|
|
raise ValueError('fail')
|
|
|
|
with self.assertRaises(ValueError):
|
|
Task()
|
|
self.assertEqual(CallCount['n'], 1)
|
|
|
|
|
|
class LogTaskTestCase(SimpleTestCase):
|
|
"""LogTask 日志装饰器。"""
|
|
|
|
def testSuccess_ReturnsResult(self):
|
|
@celery_utils.LogTask
|
|
def Task():
|
|
return 42
|
|
|
|
self.assertEqual(Task(), 42)
|
|
|
|
def testException_ReRaises(self):
|
|
@celery_utils.LogTask
|
|
def Task():
|
|
raise RuntimeError('boom')
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
Task()
|
|
|
|
def testPreservesFuncMetadata(self):
|
|
@celery_utils.LogTask
|
|
def NamedTask():
|
|
"""Named."""
|
|
|
|
self.assertEqual(NamedTask.__name__, 'NamedTask')
|
|
|
|
|
|
class SafeDecimalTestCase(SimpleTestCase):
|
|
"""SafeDecimal 安全运算装饰器。"""
|
|
|
|
def testNormalReturn_PassesThrough(self):
|
|
@celery_utils.SafeDecimal(default=Decimal('-1'))
|
|
def Calc():
|
|
return Decimal('100')
|
|
|
|
self.assertEqual(Calc(), Decimal('100'))
|
|
|
|
def testArithmeticError_ReturnsDefault(self):
|
|
@celery_utils.SafeDecimal(default=Decimal('-1'))
|
|
def Calc():
|
|
raise ArithmeticError('div by zero')
|
|
|
|
self.assertEqual(Calc(), Decimal('-1'))
|
|
|
|
def testTypeError_ReturnsDefault(self):
|
|
@celery_utils.SafeDecimal(default=Decimal('0'))
|
|
def Calc():
|
|
raise TypeError('bad type')
|
|
|
|
self.assertEqual(Calc(), Decimal('0'))
|
|
|
|
def testValueError_ReturnsDefault(self):
|
|
@celery_utils.SafeDecimal()
|
|
def Calc():
|
|
raise ValueError('bad value')
|
|
|
|
self.assertEqual(Calc(), Decimal('0'))
|
|
|
|
def testNonListedException_ReRaises(self):
|
|
@celery_utils.SafeDecimal()
|
|
def Calc():
|
|
raise RuntimeError('not caught')
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
Calc()
|
|
|
|
|
|
class IsCeleryTaskTestCase(SimpleTestCase):
|
|
"""_IsCeleryTask 判断逻辑。"""
|
|
|
|
def testPlainFunc_ReturnsFalseWhenNoCelery(self):
|
|
def PlainFunc():
|
|
pass
|
|
|
|
# 无 Celery 时返回 False
|
|
with mock.patch.object(celery_utils, '_HAS_CELERY', False):
|
|
self.assertFalse(celery_utils._IsCeleryTask(PlainFunc))
|
|
|
|
def testFuncWithApplyAsync_ReturnsTrue(self):
|
|
class FakeTask:
|
|
def apply_async(self):
|
|
pass
|
|
|
|
with mock.patch.object(celery_utils, '_HAS_CELERY', True):
|
|
self.assertTrue(celery_utils._IsCeleryTask(FakeTask()))
|