AutoRpa/rpa_rebate/tests/test_oracle_repo.py

369 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 返利 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。
from __future__ import annotations
from contextlib import contextmanager
import os
import pytest
from rpa_oracle import RepoUnavailable, load_job_env
from persist import USER_NAME, GroupSave
from repo import (
LEASE_MINUTES,
OracleRebateRepo,
_SELECT_DRAFTS,
_SELECT_DRAFTS_LIMIT,
_SELECT_PROTOCOLS,
_SELECT_PROTOCOLS_LIMIT,
)
# 返利 .env 空缺时补 oms .env,已有非空环境变量不覆盖。
def test_load_runtime_env_fills_blank_from_oms_env(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
rebate_env = tmp_path / "rebate.env"
oms_env = tmp_path / "oms.env"
rebate_env.write_text(
"RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-rebate\n",
encoding="utf-8",
)
oms_env.write_text(
"RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n",
encoding="utf-8",
)
load_job_env(tmp_path, [rebate_env, oms_env])
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
# 缺 HOST/用户/密码时 from_env 抛数据库不可用。
def test_from_env_without_user_raises(monkeypatch):
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
with pytest.raises(RepoUnavailable):
OracleRebateRepo.from_env()
# 录 SQL 的假游标,按表名返回预设领取行。
class _FakeCursor:
# 挂上共享的假连接。
def __init__(self, conn):
self._conn = conn
self._rows: list = []
self.description = None
self.rowcount = 1
# 进入 with,返回自身。
def __enter__(self):
return self
# 退出 with,不吞异常。
def __exit__(self, *args):
return False
# 记下一次批量 SQL 和全部 bind。
def executemany(self, sql, seq):
self._conn.many.append((sql, [dict(item) for item in seq]))
# 记下 SQL 和 bind;SELECT 按表切领取结果。一条 IN 超过 1000 个 fid 时抛错。
def execute(self, sql, params=None):
params = params or {}
_reject_oversized_in(params)
self._conn.executed.append((sql, params))
text = sql.lstrip().lower()
if text.startswith("select") and "rpa_contract_agreement_result" in text:
self._rows = self._conn.draft_rows
self.description = self._conn.draft_description
elif text.startswith("select") and "rpa_purchase_contract_data" in text:
self._rows = self._conn.protocol_rows
self.description = self._conn.protocol_description
else:
self._rows = []
# 返回本次 SELECT 预设行的副本。
def fetchall(self):
return list(self._rows)
# 假连接:同一实例上的 cursor 共享 executed 列表。
class _FakeConn:
# 可选带上表 4 起草行和表 3 只录协议行。
def __init__(self, *, draft_rows=None, protocol_rows=None):
self.executed: list[tuple[str, dict]] = []
self.many: list[tuple[str, list]] = []
self.committed = False
self.commits = 0
self.draft_rows = draft_rows or []
self.protocol_rows = protocol_rows or []
self.draft_description = _DRAFT_DESCRIPTION
self.protocol_description = _PROTOCOL_DESCRIPTION
# 新开一个录 SQL 的游标。
def cursor(self):
return _FakeCursor(self)
# 记下已提交。
def commit(self):
self.committed = True
self.commits += 1
# 本套用例不应回滚。
def rollback(self):
raise AssertionError("不应 rollback")
# 归还时由池调用,假连接不计数。
def close(self):
return None
# 只交出一条连接,并记下借了几次。
class _FakePool:
# 挂上要借出的那条假连接。
def __init__(self, conn):
self._conn = conn
self.acquires = 0
# 借出连接并给 acquires 加一。
@contextmanager
def acquire(self):
self.acquires += 1
yield self._conn
_DRAFT_DESCRIPTION = (
("FID",),
("OMS_ID",),
("ACTION_CODE",),
("CONTRACT_STATUS",),
("CONTRACT_FAIL_REASON",),
("CONTRACT_NO",),
("AGREEMENT_STATUS",),
("AGREEMENT_FAIL_REASON",),
("AGREEMENT_ID",),
("AGREEMENT_DTL_ID",),
("ORG_CODE",),
("PUSH_STATUS",),
("PROCESS_STATUS",),
)
_PROTOCOL_DESCRIPTION = (
("FID",),
("OMS_ID",),
("CONTRACT_NO",),
("CONTRACT_ENTRY_STATUS",),
("PROCESS_STATUS",),
("ORG_CODE",),
("PROCESS_RESULT",),
)
# 构造表 4 领取查询返回的一行元组。
def _draft_fetched(fid: int = 11, oms_id: int = 2026100501, contract_no: str = "HT1"):
"""构造表 4 领取查询返回的一行元组。"""
return (
fid,
oms_id,
1,
1,
None,
contract_no,
None,
None,
None,
None,
"901",
0,
0,
)
# 构造表 3 只录协议领取查询返回的一行元组。
def _protocol_fetched(fid: int = 21, oms_id: int = 2026100503):
return (fid, oms_id, "HT-OLD", 0, 0, "901", "")
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领,同号未过租约整组排除。
def test_claim_sql_uses_skip_locked_and_lease():
draft_sql = " ".join(_SELECT_DRAFTS.split()).lower()
assert "for update skip locked" in draft_sql
assert "process_status = 0" in draft_sql
assert "process_status = 3" in draft_sql
assert "not exists" in draft_sql
assert "numtodsinterval(:lease_minutes" in draft_sql
assert "rownum" not in draft_sql
assert "skip locked" in " ".join(_SELECT_DRAFTS_LIMIT.split()).lower()
protocol_sql = " ".join(_SELECT_PROTOCOLS.split()).lower()
assert "for update skip locked" in protocol_sql
assert "process_status = 0" in protocol_sql
assert "process_status = 3" in protocol_sql
assert "process_time" in protocol_sql
assert "numtodsinterval(:lease_minutes" in protocol_sql
assert "skip locked" in " ".join(_SELECT_PROTOCOLS_LIMIT.split()).lower()
assert LEASE_MINUTES == 30
# 领取一次借连接:SKIP LOCKED 选出后表 4、表 3 都改成 process_status=3。
def test_claim_pending_locks_and_stamps_in_one_acquire():
conn = _FakeConn(
draft_rows=[_draft_fetched()],
protocol_rows=[_protocol_fetched()],
)
pool = _FakePool(conn)
repo = OracleRebateRepo(pool)
claimed = repo.claim_pending(limit=5)
assert pool.acquires == 1
assert [row["fid"] for row in claimed.drafts] == [11]
assert claimed.drafts[0]["process_status"] == 3
assert claimed.drafts[0]["update_user_name"] == USER_NAME
assert [row["fid"] for row in claimed.protocols] == [21]
assert claimed.protocols[0]["process_status"] == 3
assert conn.committed is True
selects = [item for item in conn.executed if item[0].lstrip().lower().startswith("select")]
updates = [item for item in conn.executed if item[0].lstrip().lower().startswith("update")]
assert len(selects) == 2
assert len(updates) == 2
assert "skip locked" in selects[0][0].lower()
assert selects[0][1]["lease_minutes"] == 30
assert "limit" not in selects[0][1]
assert selects[1][1]["limit"] == 4
assert "skip locked" in selects[1][0].lower()
draft_update = " ".join(updates[0][0].split()).lower()
assert "process_status = 3" in draft_update
assert "系统管理员" in updates[0][0]
protocol_update = " ".join(updates[1][0].split()).lower()
assert "process_status = 3" in protocol_update
# 表 4 更新/释放/插入和表 3 更新各一次 executemany,同一连接同一事务。
def test_save_group_writes_in_one_acquire():
conn = _FakeConn()
pool = _FakePool(conn)
repo = OracleRebateRepo(pool)
payload = GroupSave(
table4_updates=[
{"fid": 1, "contract_status": 2, "process_status": 1},
{
"fid": 3,
"contract_status": 1,
"process_status": 0,
"agreement_fail_reason": "送审失败",
},
],
table4_inserts=[
{
"oms_id": 2026100501,
"action_code": 2,
"contract_status": None,
"contract_fail_reason": None,
"contract_no": "HT1",
"agreement_status": 1,
"agreement_fail_reason": None,
"agreement_id": "AGR1",
"agreement_dtl_id": "AGRD1",
"process_status": 1,
"org_code": "901",
},
{
"oms_id": 2026100502,
"action_code": 2,
"contract_status": None,
"contract_fail_reason": None,
"contract_no": "HT1",
"agreement_status": 1,
"agreement_fail_reason": None,
"agreement_id": "AGR1",
"agreement_dtl_id": "AGRD2",
"process_status": 1,
"org_code": "901",
},
],
table4_release_fids=[2, 4],
table3_updates=[
{"fid": 10, "process_status": 1, "process_result": "协议已录入"},
{"fid": 11, "process_status": 0, "process_result": "下一班再领"},
],
)
repo.save_group(payload)
assert pool.acquires == 1
assert conn.committed is True
assert conn.executed == []
assert len(conn.many) == 4
draft_sql, draft_binds = conn.many[0]
release_sql, release_binds = conn.many[1]
insert_sql, insert_binds = conn.many[2]
protocol_sql, protocol_binds = conn.many[3]
assert "agreement_fail_reason" in draft_sql.lower()
assert [row["fid"] for row in draft_binds] == [1, 3]
assert draft_binds[0]["agreement_fail_reason"] is None
assert draft_binds[1]["agreement_fail_reason"] == "送审失败"
assert [row["fid"] for row in release_binds] == [2, 4]
assert "process_status = 0" in " ".join(release_sql.split()).lower()
assert "insert into rpa_contract_agreement_result" in insert_sql.lower()
assert [row["oms_id"] for row in insert_binds] == [2026100501, 2026100502]
assert "rpa_purchase_contract_data" in protocol_sql.lower()
assert [row["fid"] for row in protocol_binds] == [10, 11]
assert [row["process_status"] for row in protocol_binds] == [1, 0]
# 班次 session 内领取加落表只从池里借一次连接。
def test_session_claim_and_save_acquire_once():
conn = _FakeConn(draft_rows=[_draft_fetched()])
pool = _FakePool(conn)
repo = OracleRebateRepo(pool)
payload = GroupSave(
table4_updates=[
{"fid": 11, "contract_status": 2, "process_status": 1},
],
table4_inserts=[],
table4_release_fids=[],
table3_updates=[],
)
with repo.session():
claimed = repo.claim_pending()
assert [row["fid"] for row in claimed.drafts] == [11]
repo.save_group(payload)
assert pool.acquires == 1
assert conn.commits >= 2
def _fid_bind_count(params: dict) -> int:
"""数出本段 bind 里的 fid 个数。"""
return sum(
1
for key in params
if isinstance(key, str) and key.startswith("id") and key[2:].isdigit()
)
def _reject_oversized_in(params: dict) -> None:
"""一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。"""
if _fid_bind_count(params) > 1000:
raise RuntimeError("ORA-01795")
def test_claim_one_contract_over_1000_rows_keeps_the_whole_group():
"""一个合同号超过 1000 行仍整组领走,更新按 500 分段,不报数据库不可用。"""
rows = [
_draft_fetched(fid, oms_id=2026100500 + fid, contract_no="HT-BIG")
for fid in range(1, 1002)
]
conn = _FakeConn(draft_rows=rows)
pool = _FakePool(conn)
repo = OracleRebateRepo(pool)
claimed = repo.claim_pending(limit=10)
assert pool.acquires == 1
assert len(claimed.drafts) == 1001
assert {row["contract_no"] for row in claimed.drafts} == {"HT-BIG"}
assert all(row["process_status"] == 3 for row in claimed.drafts)
assert claimed.protocols == []
updates = [
item for item in conn.executed if item[0].lstrip().lower().startswith("update")
]
assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1]
assert conn.committed is True