Files
obsidian-wiki/tests/test_kingdee_api.py
T
Codex 4e34e5b3fe feat(business): split Kingdee token body config
Allow Kingdee token requests to be built from app key, app secret, configurable field names, optional timestamp fields, and extra JSON. Keep raw KINGDEE_TOKEN_BODY_JSON as an advanced override with current-time placeholders.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
2026-07-06 20:03:28 +00:00

289 lines
11 KiB
Python

import json
import os
import stat
import tempfile
import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from obsidian_wiki.kingdee_api import (
DEFAULT_SALORDER_API_URL,
ensure_kingdee_config,
fetch_kingdee_sales_orders,
get_kingdee_access_token,
)
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
class RecordingUrlopen:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def __call__(self, request, timeout=60):
self.calls.append({"request": request, "timeout": timeout})
if not self.responses:
raise AssertionError("unexpected HTTP request")
return FakeResponse(self.responses.pop(0))
class KingdeeApiTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.root = Path(self.tempdir.name)
def tearDown(self):
self.tempdir.cleanup()
def test_config_command_creates_private_template_without_overwriting(self):
config_path = self.root / ".runtime" / "kingdee.env"
created = ensure_kingdee_config(config_path)
config_path.write_text(config_path.read_text(encoding="utf-8") + "\nKINGDEE_TOKEN_URL=https://token.example\n", encoding="utf-8")
created_again = ensure_kingdee_config(config_path)
self.assertTrue(created)
self.assertFalse(created_again)
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
text = config_path.read_text(encoding="utf-8")
self.assertIn(f"KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL}", text)
self.assertIn("KINGDEE_TOKEN_BODY_JSON=", text)
self.assertIn("KINGDEE_TOKEN_URL=https://token.example", text)
self.assertIn("是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。", text)
self.assertIn("用途:销售订单 API 要求的 x-acgw-identity 请求头。", text)
self.assertIn("是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。", text)
self.assertIn("是否必填:可选,默认从 data.access_token 读取。", text)
self.assertIn("变量:KINGDEE_APP_KEY", text)
self.assertIn("变量:KINGDEE_TOKEN_TIMESTAMP_FIELD", text)
self.assertIn("支持占位符:{now_ms}", text)
def test_default_config_path_accepts_none(self):
cwd = Path.cwd()
try:
os.chdir(self.root)
created = ensure_kingdee_config(None)
finally:
os.chdir(cwd)
self.assertTrue(created)
self.assertTrue((self.root / ".runtime" / "kingdee.env").is_file())
def test_cli_config_kingdee_writes_template(self):
from obsidian_wiki.cli import main
config_path = self.root / ".runtime" / "kingdee.env"
output = StringIO()
with redirect_stdout(output):
code = main(["business", "config", "kingdee", "--config", str(config_path)])
self.assertEqual(code, 0)
self.assertTrue(config_path.is_file())
self.assertIn("Kingdee config", output.getvalue())
def test_access_token_is_fetched_and_reused_from_private_cache(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc","app_secret":"secret"}',
"KINGDEE_TOKEN_HEADERS_JSON": '{"X-App":"demo"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
"KINGDEE_TOKEN_EXPIRES_IN_FIELD": "data.expires_in",
"KINGDEE_REFRESH_TOKEN_FIELD": "data.refresh_token",
}
http = RecordingUrlopen(
[
{
"data": {
"access_token": "access-1",
"expires_in": 3600,
"refresh_token": "refresh-1",
}
}
]
)
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
cached = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1100)
self.assertEqual(token, "access-1")
self.assertEqual(cached, "access-1")
self.assertEqual(len(http.calls), 1)
request = http.calls[0]["request"]
self.assertEqual(request.get_method(), "POST")
self.assertEqual(request.full_url, "https://token.example/access")
self.assertEqual(json.loads(request.data.decode("utf-8"))["app_key"], "abc")
self.assertEqual(request.get_header("X-app"), "demo")
self.assertEqual(stat.S_IMODE(cache_path.stat().st_mode), 0o600)
def test_access_token_body_can_be_built_from_split_fields_with_current_time(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_APP_KEY": "abc",
"KINGDEE_APP_SECRET": "secret",
"KINGDEE_TOKEN_APP_KEY_FIELD": "client_id",
"KINGDEE_TOKEN_APP_SECRET_FIELD": "client_secret",
"KINGDEE_TOKEN_TIMESTAMP_FIELD": "timestamp",
"KINGDEE_TOKEN_TIMESTAMP_FORMAT": "epoch_ms",
"KINGDEE_TOKEN_BODY_EXTRA_JSON": '{"tenant":"demo"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
}
http = RecordingUrlopen([{"data": {"access_token": "access-1"}}])
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000.5)
self.assertEqual(token, "access-1")
body = json.loads(http.calls[0]["request"].data.decode("utf-8"))
self.assertEqual(body["client_id"], "abc")
self.assertEqual(body["client_secret"], "secret")
self.assertEqual(body["timestamp"], 1000500)
self.assertEqual(body["tenant"], "demo")
def test_token_body_json_supports_current_time_placeholders(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc","timestamp":{now_ms},"requested_at":"{now_datetime_utc}"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
}
http = RecordingUrlopen([{"data": {"access_token": "access-1"}}])
get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
body = json.loads(http.calls[0]["request"].data.decode("utf-8"))
self.assertEqual(body["timestamp"], 1000000)
self.assertEqual(body["requested_at"], "1970-01-01 00:16:40")
def test_expired_token_uses_refresh_endpoint(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps(
{
"access_token": "old-token",
"refresh_token": "refresh-1",
"expires_at": 999,
"obtained_at": 100,
}
),
encoding="utf-8",
)
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc"}',
"KINGDEE_REFRESH_URL": "https://token.example/refresh",
"KINGDEE_REFRESH_BODY_JSON": '{"refresh_token":"{refresh_token}"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
"KINGDEE_TOKEN_EXPIRES_IN_FIELD": "data.expires_in",
"KINGDEE_REFRESH_TOKEN_FIELD": "data.refresh_token",
}
http = RecordingUrlopen(
[
{
"data": {
"access_token": "new-token",
"expires_in": 3600,
"refresh_token": "refresh-2",
}
}
]
)
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
self.assertEqual(token, "new-token")
request = http.calls[0]["request"]
self.assertEqual(request.full_url, "https://token.example/refresh")
self.assertEqual(json.loads(request.data.decode("utf-8"))["refresh_token"], "refresh-1")
def test_sales_order_fetch_posts_filters_headers_and_paginates(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps({"access_token": "access-1", "expires_at": 9999, "obtained_at": 1000}),
encoding="utf-8",
)
config = {
"KINGDEE_SALORDER_API_URL": "https://kingdee.example/querySalOrder",
"KINGDEE_ACGW_IDENTITY": "identity-1",
"KINGDEE_PAGE_SIZE": "2",
}
page_1 = {
"status": True,
"errorCode": "",
"message": "",
"data": {
"pageNo": 1,
"pageSize": 2,
"rows": [{"id": "1", "billno": "SO-001"}, {"id": "2", "billno": "SO-002"}],
"totalCount": 3,
},
}
page_2 = {
"status": True,
"errorCode": "",
"message": "",
"data": {
"pageNo": 2,
"pageSize": 2,
"rows": [{"id": "3", "billno": "SO-003"}],
"totalCount": 3,
},
}
http = RecordingUrlopen([page_1, page_2])
rows, payload = fetch_kingdee_sales_orders(
config,
filters={"start_bizdate": "2026-06-01", "billstatus": ["C"]},
token_cache_path=cache_path,
urlopen=http,
now_epoch=1000,
)
self.assertEqual([row["billno"] for row in rows], ["SO-001", "SO-002", "SO-003"])
self.assertEqual(payload["pages_fetched"], 2)
self.assertEqual(len(http.calls), 2)
first = http.calls[0]["request"]
self.assertEqual(first.get_method(), "POST")
self.assertEqual(first.get_header("Accesstoken"), "access-1")
self.assertEqual(first.get_header("X-acgw-identity"), "identity-1")
body = json.loads(first.data.decode("utf-8"))
self.assertEqual(body["data"]["start_bizdate"], "2026-06-01")
self.assertEqual(body["data"]["billstatus"], ["C"])
self.assertEqual(body["pageNo"], 1)
self.assertEqual(body["pageSize"], 2)
def test_sales_order_api_failure_reports_code_and_message(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps({"access_token": "access-1", "expires_at": 9999, "obtained_at": 1000}),
encoding="utf-8",
)
config = {
"KINGDEE_SALORDER_API_URL": "https://kingdee.example/querySalOrder",
"KINGDEE_ACGW_IDENTITY": "identity-1",
}
http = RecordingUrlopen([{"status": False, "errorCode": "E001", "message": "bad filter"}])
with self.assertRaisesRegex(RuntimeError, "E001.*bad filter"):
fetch_kingdee_sales_orders(config, filters={}, token_cache_path=cache_path, urlopen=http, now_epoch=1000)
if __name__ == "__main__":
unittest.main()