"""回归:时间列统一走 ``UTCDateTime`` —— 读出来必须带时区,写进去必须存 UTC。 背景:库里存的是 naive UTC,读出来也是裸时间,于是每一处比较/输出都要自己记得 补时区。项目里因此散了三份 ``_as_utc`` 和一堆 ``replace(tzinfo=UTC)``,还踩过一次 TypeError: can't compare offset-naive and offset-aware datetimes (P1a 的 CK 巡检排下一轮时间时炸的)。现在把边界收在 ``UTCDateTime``: 写时 aware→UTC 裸值、naive 视为 UTC;读时一律补 UTC 时区。 这组测试盯三件事:读出来是 aware、能和 ``datetime.now(UTC)`` 直接比、写进去 落的是 UTC 而不是本地时间。 """ from datetime import UTC, datetime, timedelta, timezone from sqlalchemy import text from tests.support.db import get_test_engine, make_test_session from web.backend.database import Base, UTCDateTime from web.backend.models import Account, AutomationRun, LoginTask, User class TestUtcDateTime: def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) def teardown_method(self): self.session.close() def _row(self, started_at): self.session.add( AutomationRun(job_key="k", started_at=started_at, status="success") ) self.session.commit() return self.session.query(AutomationRun).one() def test_read_back_is_timezone_aware(self): row = self._row(datetime(2026, 10, 2, 12, 0, tzinfo=UTC)) assert row.started_at.tzinfo is not None assert row.started_at.utcoffset() == timedelta(0) def test_read_back_value_can_compare_with_now(self): """这条就是当初炸掉的写法:库里的时间直接和 datetime.now(UTC) 比。""" row = self._row(datetime.now(UTC) - timedelta(minutes=1)) assert row.started_at < datetime.now(UTC) # 不再 TypeError def test_write_converts_local_aware_to_utc(self): """带 +08:00 的时间要按 UTC 落库,不能把本地壁钟直接存进去。""" local = datetime(2026, 10, 2, 20, 0, tzinfo=timezone(timedelta(hours=8))) row = self._row(local) assert row.started_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC) raw = self.session.execute( text("SELECT started_at FROM automation_runs") ).scalar() assert str(raw).startswith("2026-10-02 12:00") def test_naive_write_is_treated_as_utc(self): """库里读出来的裸值再写回去(常见于搬迁/复制)不能被当成本地时间再减 8 小时。""" naive_utc = datetime(2026, 10, 2, 12, 0) # noqa: DTZ001 刻意构造裸 UTC row = self._row(naive_utc) assert row.started_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC) def test_none_stays_none(self): assert UTCDateTime().process_bind_param(None, None) is None assert UTCDateTime().process_result_value(None, None) is None def test_all_model_datetime_columns_use_utc_type(self): """新增时间列若忘了用 UTCDateTime,这里直接失败。""" offenders = [] for table in Base.metadata.sorted_tables: for column in table.columns: if isinstance(column.type, UTCDateTime): continue if str(column.type).upper().startswith("DATETIME"): offenders.append(f"{table.name}.{column.name}") assert not offenders, f"这些时间列没用 UTCDateTime: {offenders}" def test_login_task_attempt_at_round_trips_as_utc(self): """真实模型抽查:写 aware、读 aware、能直接比较。""" user = User(username="u", password_hash="p", role="admin") account = Account(username="a", password="p", email="e", email_password="ep") self.session.add_all([user, account]) self.session.commit() task = LoginTask( batch_id="b", account_id=account.id, created_by=user.id, attempt_at=datetime(2026, 10, 2, 12, 0, tzinfo=UTC), ) self.session.add(task) self.session.commit() self.session.refresh(task) assert task.attempt_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC) # 模型侧保证 aware,所以业务代码可以直接比较 assert task.attempt_at < datetime.now(UTC)