"""D1 回填脚本:把官方补录事件的 `subject_key` 补上商品 ID。 脚本是一次性数据修复,但它是**生产数据**的修复 —— 写错了会污染台账, 所以守住三件事:幂等、只填空值、dry-run 不写库。 """ from __future__ import annotations import importlib.util import sys from datetime import UTC, datetime from pathlib import Path import pytest from tests.support.db import get_test_engine, make_test_session from web.backend.models import Account, AccountEvent SCRIPT_PATH = ( Path(__file__).resolve().parents[1] / "scripts" / "backfill_official_exchange_subject_key.py" ) NOW = datetime(2026, 10, 7, 3, 0, 0, tzinfo=UTC) def _load_script(): spec = importlib.util.spec_from_file_location( "backfill_official_exchange_subject_key", SCRIPT_PATH ) assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module @pytest.fixture def script(): return _load_script() def _run(script, monkeypatch, *argv: str) -> int: monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH), *argv]) return script.main() class TestBackfillOfficialExchangeSubjectKey: def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.account = Account( username="bf-acc", password="p", email="b@e.com", email_password="ep" ) self.session.add(self.account) self.session.flush() self.account_id = self.account.id def teardown_method(self): self.session.close() def _add_event(self, *, commodity_id: str | None, subject_key: str = "") -> int: event = AccountEvent( account_id=self.account_id, event_type="exchange", category="business", occurred_at=NOW, title="兑换 x", amount=-1280, subject_type="goods", subject_key=subject_key, detail=( {"source": "official_sync", "commodity_id": commodity_id} if commodity_id is not None else {"source": "official_sync"} ), source="official_sync", ) self.session.add(event) self.session.commit() return event.id def _reload(self, event_id: int) -> AccountEvent: """重新读一行 —— **必须先 rollback 结束当前事务**。 脚本用的是**另一个会话**,在 MySQL REPEATABLE READ 下,本会话若沿用旧快照, `expire_all()` 也只会拿到事务开始时的老值:脚本改没改都显示没改, 于是「dry-run 不写库」这类断言会**假绿**。 """ self.session.rollback() row = self.session.get(AccountEvent, event_id) assert row is not None return row def test_dry_run_writes_nothing(self, script, monkeypatch): event_id = self._add_event(commodity_id="20260120QYOOB_bag10") assert _run(script, monkeypatch, "--dry-run") == 0 assert self._reload(event_id).subject_key == "", "dry-run 不该写库" def test_apply_fills_subject_key(self, script, monkeypatch): event_id = self._add_event(commodity_id="20260120QYOOB_bag10") assert _run(script, monkeypatch, "--apply") == 0 row = self._reload(event_id) assert row.subject_key == "20260120QYOOB_bag10" assert row.subject_type == "commodity" def test_is_idempotent(self, script, monkeypatch): event_id = self._add_event(commodity_id="20260120QYOOB_bag10") assert _run(script, monkeypatch, "--apply") == 0 # 第二遍没有可回填的行,应当直接退出且不改动 assert _run(script, monkeypatch, "--apply") == 0 assert self._reload(event_id).subject_key == "20260120QYOOB_bag10" def test_does_not_overwrite_existing_subject_key(self, script, monkeypatch): """只填空值 —— 已有值(哪怕看着不对)不由脚本判断。""" event_id = self._add_event( commodity_id="20260120QYOOB_bag10", subject_key="手工填的值" ) assert _run(script, monkeypatch, "--apply") == 0 assert self._reload(event_id).subject_key == "手工填的值" def test_skips_events_without_commodity_id(self, script, monkeypatch): """没有 `detail.commodity_id` 的行不动,也不该报失败退出码。""" event_id = self._add_event(commodity_id=None) # 没有可回填的行 → 正常退出(不因为「缺 commodity_id」而误报) assert _run(script, monkeypatch, "--apply") == 0 assert self._reload(event_id).subject_key == ""