fix: stabilize API routing and simplify delivery reports
This commit is contained in:
@@ -17,6 +17,8 @@ Expected:
|
|||||||
- original demand 10 remains visible and each model is queried exactly once with quantity 10; stock accounting is handled by the API, with no local inventory deduction, yield adjustment or batch rounding;
|
- original demand 10 remains visible and each model is queried exactly once with quantity 10; stock accounting is handled by the API, with no local inventory deduction, yield adjustment or batch rounding;
|
||||||
- the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the valid trigger is accepted and before the API query starts;
|
- the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the valid trigger is accepted and before the API query starts;
|
||||||
- the controlled script uses the macOS system trust store and keeps TLS verification enabled;
|
- the controlled script uses the macOS system trust store and keeps TLS verification enabled;
|
||||||
|
- the controlled script defaults to a direct HTTPS connection to `api.zeroerr-agent.com`, ignoring system/environment proxies for this API only; it does not change Feishu or other applications' proxy settings;
|
||||||
|
- an explicit `ZEROERR_AGENT_PROXY_MODE=system` uses the configured proxies; an invalid mode or a non-approved direct target stops before the request, and a failed request does not trigger an automatic proxy fallback or retry;
|
||||||
- no temporary TLS-bypass parameter is required for the normal happy path;
|
- no temporary TLS-bypass parameter is required for the normal happy path;
|
||||||
- API script runs;
|
- API script runs;
|
||||||
- output does not expose API key;
|
- output does not expose API key;
|
||||||
@@ -25,6 +27,7 @@ Expected:
|
|||||||
- output omits the `候选明细` heading and does not repeat a human-review line under each candidate;
|
- output omits the `候选明细` heading and does not repeat a human-review line under each candidate;
|
||||||
- model differences use the compact `字段 原值→新值` format and join multiple changes with `;`;
|
- model differences use the compact `字段 原值→新值` format and join multiple changes with `;`;
|
||||||
- Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold;
|
- Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold;
|
||||||
|
- in initial and continuation replies, the `X个工作日` and final delivery date values are bold in both `最快候选交期` and each model's `AM516预测交期`, using native Feishu rich-text styles without displaying Markdown asterisks;
|
||||||
- the judgment level and fixed disclaimer provide the global human-review boundary;
|
- the judgment level and fixed disclaimer provide the global human-review boundary;
|
||||||
- inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details;
|
- inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details;
|
||||||
- each usable model includes `缺料物料明细` listing only shortage names, preserving order and deduplicating names; use the API's `物料名称` / `缺料物料名称` first, then resolve `缺料物料` through `data/material_names.json`; the same behavior applies to continuation replies;
|
- each usable model includes `缺料物料明细` listing only shortage names, preserving order and deduplicating names; use the API's `物料名称` / `缺料物料名称` first, then resolve `缺料物料` through `data/material_names.json`; the same behavior applies to continuation replies;
|
||||||
@@ -244,7 +247,7 @@ python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query
|
|||||||
Expected:
|
Expected:
|
||||||
|
|
||||||
- the script creates an unverified SSL context only because the flag is explicitly present;
|
- the script creates an unverified SSL context only because the flag is explicitly present;
|
||||||
- the context is passed to the single controlled `urlopen` call;
|
- the context is passed to the request-local HTTPS handler used by the single controlled `opener.open` call;
|
||||||
- the result does not expose the API Key;
|
- the result does not expose the API Key;
|
||||||
- a successful response remains an internal candidate and requires human review;
|
- a successful response remains an internal candidate and requires human review;
|
||||||
- the temporary result does not remove the long-term certificate-repair requirement.
|
- the temporary result does not remove the long-term certificate-repair requirement.
|
||||||
@@ -275,5 +278,5 @@ Expected:
|
|||||||
- D=8/S=5: send 8; D=24/S=6: send 24; D=24/S=0: send 24; D=24/S>=24: still send 24 once and preserve the API response.
|
- D=8/S=5: send 8; D=24/S=6: send 24; D=24/S=0: send 24; D=24/S>=24: still send 24 once and preserve the API response.
|
||||||
- Different candidates display their own stock, but all use unchanged original demand. Initial and continuation batches query each current-batch model once.
|
- Different candidates display their own stock, but all use unchanged original demand. Initial and continuation batches query each current-batch model once.
|
||||||
- Missing/invalid stock is unknown, never zero; valid API workdays/date remain usable with an explicit inventory-review item. Missing/invalid workdays/date or failed requests retain existing error handling.
|
- Missing/invalid stock is unknown, never zero; valid API workdays/date remain usable with an explicit inventory-review item. Missing/invalid workdays/date or failed requests retain existing error handling.
|
||||||
- New report and record metadata contain actual API quantities, with no local net-demand or production-quantity fields. Historical records and existing candidate-table columns remain unchanged.
|
- Initial and continuation Feishu reports show demand quantity, lead time and inventory coverage, but omit `数量传递规则`, `库存覆盖口径` and per-model `API查询数量`. Internal record metadata still contains actual API quantities and quantity rules. No local net-demand or production-quantity fields are shown. Historical records and existing candidate-table columns remain unchanged.
|
||||||
- These checks use mocked responses locally. Runtime acceptance must verify actual API arguments and compare the displayed workdays/date with the same returned response; mocked dates do not constitute delivery predictions.
|
- These checks use mocked responses locally. Runtime acceptance must verify actual API arguments and compare the displayed workdays/date with the same returned response; mocked dates do not constitute delivery predictions.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ API quantity = D
|
|||||||
|
|
||||||
库存缺失或无效时显示待复核,不视作 0;如果 API 已返回有效工作日交期和最终交货日期,机器人保留该交期并标明库存覆盖无法判断。缺失有效交期字段或请求失败时继续执行原有异常处理。
|
库存缺失或无效时显示待复核,不视作 0;如果 API 已返回有效工作日交期和最终交货日期,机器人保留该交期并标明库存覆盖无法判断。缺失有效交期字段或请求失败时继续执行原有异常处理。
|
||||||
|
|
||||||
报告展示需求数量、API 查询数量、API 返回交期和库存覆盖情况,不再输出本地净需求或排产测算数量。新记录保存原始需求及每个型号实际 API 查询数量,历史记录不改写。
|
飞书报告展示需求数量、API 返回交期和库存覆盖情况。按 AR51 于 2026-10-16 的显示要求,首次及续查回复均不显示“数量传递规则”“库存覆盖口径”和各型号下的“API查询数量”,也不输出本地净需求或排产测算数量。内部记录仍保存数量规则、原始需求及每个型号实际 API 查询数量,历史记录不改写。
|
||||||
|
|
||||||
## 已取消的旧逻辑
|
## 已取消的旧逻辑
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,28 @@ python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query
|
|||||||
|
|
||||||
Do not run this check until AR51 confirms the functional test and creates a separate trusted approval record explicitly covering `api.zeroerr-agent.com`, the AM516 use case, and API-key use.
|
Do not run this check until AR51 confirms the functional test and creates a separate trusted approval record explicitly covering `api.zeroerr-agent.com`, the AM516 use case, and API-key use.
|
||||||
|
|
||||||
|
## API Network Route
|
||||||
|
|
||||||
|
The controlled query script defaults to `ZEROERR_AGENT_PROXY_MODE=direct` for
|
||||||
|
`https://api.zeroerr-agent.com`. It uses a request-local opener with an empty
|
||||||
|
proxy map, so this API does not inherit macOS or environment proxy settings.
|
||||||
|
Feishu, Codex and other applications retain their existing network settings.
|
||||||
|
The bridge launches the script for each query, so script updates take effect on
|
||||||
|
the next query without restarting the bridge or clearing continuation context.
|
||||||
|
|
||||||
|
On 2026-10-16, the proxy log for the 08:51 failed query showed a default-proxy
|
||||||
|
connection timeout to this API. AR51-approved comparison queries with the fixed
|
||||||
|
sample both succeeded later: system proxy 20.04 seconds, direct 4.79 seconds.
|
||||||
|
These are individual observations, not an availability or latency guarantee.
|
||||||
|
|
||||||
|
If another approved runtime requires a company proxy, configure
|
||||||
|
`ZEROERR_AGENT_PROXY_MODE=system` in that runtime's environment (or the bridge's
|
||||||
|
local `.env`, followed by a bridge restart). This selects the configured proxy
|
||||||
|
route explicitly. It does not authorize a new destination. Invalid modes and
|
||||||
|
direct requests outside the approved HTTPS hostname stop before the request.
|
||||||
|
There is no automatic proxy fallback or extra API retry, and TLS certificate
|
||||||
|
verification remains enabled.
|
||||||
|
|
||||||
## Normal TLS Trust Configuration
|
## Normal TLS Trust Configuration
|
||||||
|
|
||||||
The controlled query script injects `truststore` before making the request. Normal queries therefore keep TLS certificate verification enabled while using the macOS system trust store, including an AR51-approved company proxy root certificate installed and trusted in the system keychain.
|
The controlled query script injects `truststore` before making the request. Normal queries therefore keep TLS certificate verification enabled while using the macOS system trust store, including an AR51-approved company proxy root certificate installed and trusted in the system keychain.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ truststore.inject_into_ssl()
|
|||||||
|
|
||||||
DEFAULT_BASE_URL = os.getenv("ZEROERR_AGENT_BASE_URL", "https://api.zeroerr-agent.com").rstrip("/")
|
DEFAULT_BASE_URL = os.getenv("ZEROERR_AGENT_BASE_URL", "https://api.zeroerr-agent.com").rstrip("/")
|
||||||
DEFAULT_ENDPOINT = "/api/SaleAgent/DeliveryPrediction/WithTransit"
|
DEFAULT_ENDPOINT = "/api/SaleAgent/DeliveryPrediction/WithTransit"
|
||||||
APPROVED_SKIP_TLS_HOSTNAME = "api.zeroerr-agent.com"
|
APPROVED_API_HOSTNAME = "api.zeroerr-agent.com"
|
||||||
|
APPROVED_SKIP_TLS_HOSTNAME = APPROVED_API_HOSTNAME
|
||||||
|
PROXY_MODE_ENV = "ZEROERR_AGENT_PROXY_MODE"
|
||||||
API_KEY_ENV = "ZEROERR_AGENT_API_KEY"
|
API_KEY_ENV = "ZEROERR_AGENT_API_KEY"
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
LOCAL_API_KEY_FILE = PROJECT_ROOT / "configs" / "delivery_api_key.local"
|
LOCAL_API_KEY_FILE = PROJECT_ROOT / "configs" / "delivery_api_key.local"
|
||||||
@@ -66,6 +68,23 @@ def get_api_key() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_api_opener(url: str, *, skip_tls_verify: bool = False) -> urllib.request.OpenerDirector:
|
||||||
|
"""Keep the approved API independent of the desktop's changing proxy route."""
|
||||||
|
mode = os.getenv(PROXY_MODE_ENV, "direct").strip().lower()
|
||||||
|
if mode not in {"direct", "system"}:
|
||||||
|
raise ValueError("invalid API proxy mode: expected direct or system")
|
||||||
|
target = urllib.parse.urlsplit(url)
|
||||||
|
if mode == "direct" and (
|
||||||
|
target.scheme != "https" or target.hostname != APPROVED_API_HOSTNAME
|
||||||
|
):
|
||||||
|
raise ValueError("direct API connection requires the approved HTTPS hostname")
|
||||||
|
# An explicit empty proxy map disables both environment and macOS proxies
|
||||||
|
# for this opener only. Never alter the process-wide urllib opener or env.
|
||||||
|
proxy = urllib.request.ProxyHandler({}) if mode == "direct" else urllib.request.ProxyHandler()
|
||||||
|
context = ssl._create_unverified_context() if skip_tls_verify else ssl.create_default_context()
|
||||||
|
return urllib.request.build_opener(proxy, urllib.request.HTTPSHandler(context=context))
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
product = args.product.strip()
|
product = args.product.strip()
|
||||||
@@ -110,9 +129,13 @@ def main() -> int:
|
|||||||
}
|
}
|
||||||
|
|
||||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
context = ssl._create_unverified_context() if args.skip_tls_verify else None
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=30, context=context) as response:
|
opener = build_api_opener(url, skip_tls_verify=args.skip_tls_verify)
|
||||||
|
except ValueError as exc:
|
||||||
|
emit_error(str(exc))
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=30) as response:
|
||||||
raw_body = response.read().decode("utf-8")
|
raw_body = response.read().decode("utf-8")
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
body = exc.read().decode("utf-8", errors="replace")
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Stop if:
|
|||||||
- Inventory accounting and delivery calculation belong to the upstream API. Do not calculate a local production quantity, send a reduced or inflated quantity, or issue a second production query.
|
- Inventory accounting and delivery calculation belong to the upstream API. Do not calculate a local production quantity, send a reduced or inflated quantity, or issue a second production query.
|
||||||
- Display each model's own returned inventory coverage against original demand. A displayed inventory gap is informational and must not become a new API quantity. Missing or invalid stock means unknown coverage, never zero; do not discard valid API workdays/date solely because inventory display is unavailable.
|
- Display each model's own returned inventory coverage against original demand. A displayed inventory gap is informational and must not become a new API quantity. Missing or invalid stock means unknown coverage, never zero; do not discard valid API workdays/date solely because inventory display is unavailable.
|
||||||
- Remove the obsolete partial-stock/production-quantity shortcut rejection. API failures and missing or invalid workdays/date retain existing error handling.
|
- Remove the obsolete partial-stock/production-quantity shortcut rejection. API failures and missing or invalid workdays/date retain existing error handling.
|
||||||
- Replies show original demand and actual API query quantity per model, without local net-demand or production-quantity fields. New records preserve the same actual API quantities; leave historical records unchanged.
|
- Replies show original demand per model. Hide the technical explanation lines `数量传递规则`, `库存覆盖口径` and `API查询数量` in both initial and continuation replies, along with the obsolete local net-demand or production-quantity fields. New internal records preserve the actual API quantities and quantity rules for traceability; leave historical records unchanged.
|
||||||
|
|
||||||
## Feishu Message Trigger Gate
|
## Feishu Message Trigger Gate
|
||||||
|
|
||||||
@@ -179,8 +179,6 @@ Use this field order for a successful Feishu report:
|
|||||||
关节模组交期预估报告 | {generation time}
|
关节模组交期预估报告 | {generation time}
|
||||||
|
|
||||||
输入需求:{input model},{input quantity}台
|
输入需求:{input model},{input quantity}台
|
||||||
数量传递规则:原始需求原样传入API,库存及交期测算由API处理
|
|
||||||
库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况
|
|
||||||
数据时间戳:{API data timestamp or generation time}
|
数据时间戳:{API data timestamp or generation time}
|
||||||
状态:试运行版输出 / 内部候选
|
状态:试运行版输出 / 内部候选
|
||||||
判断等级:AM516算法预测参考,需人工复核
|
判断等级:AM516算法预测参考,需人工复核
|
||||||
@@ -195,7 +193,6 @@ Use this field order for a successful Feishu report:
|
|||||||
|
|
||||||
1. {model}({candidate type and reducer brand})
|
1. {model}({candidate type and reducer brand})
|
||||||
- 需求数量:{input quantity}台
|
- 需求数量:{input quantity}台
|
||||||
- API查询数量:{original demand}台
|
|
||||||
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
||||||
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
@@ -219,6 +216,12 @@ Use Feishu rich-text `post` output. Bold only:
|
|||||||
- each numbered candidate heading;
|
- each numbered candidate heading;
|
||||||
- `不可用候选:`.
|
- `不可用候选:`.
|
||||||
|
|
||||||
|
Also bold the `X个工作日` value and the final delivery date (`YYYY-MM-DD`)
|
||||||
|
in `最快候选交期` and each model's `AM516预测交期`, for both initial and
|
||||||
|
continuation replies. Use native Feishu text-element `style: ["bold"]`, keeping
|
||||||
|
the field labels and separating punctuation in normal weight. Dates in the
|
||||||
|
report title or data timestamp are not included in this emphasis.
|
||||||
|
|
||||||
Do not repeat a separate human-review line under each candidate. The global judgment level and fixed disclaimer carry the human-review boundary.
|
Do not repeat a separate human-review line under each candidate. The global judgment level and fixed disclaimer carry the human-review boundary.
|
||||||
|
|
||||||
If the current candidate-batch report exceeds the Feishu rich-text size limit, split it into multiple ordered `post` replies without dropping any candidate queried in that batch. Use the original report title on the first part and append `(续)` to continuation titles.
|
If the current candidate-batch report exceeds the Feishu rich-text size limit, split it into multiple ordered `post` replies without dropping any candidate queried in that batch. Use the original report title on the first part and append `(续)` to continuation titles.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
ZEROERR_AGENT_API_KEY=replace_with_approved_key
|
ZEROERR_AGENT_API_KEY=replace_with_approved_key
|
||||||
ZEROERR_AGENT_BASE_URL=https://api.zeroerr-agent.com
|
ZEROERR_AGENT_BASE_URL=https://api.zeroerr-agent.com
|
||||||
|
# Only this controlled API script uses the setting; Feishu and desktop apps keep their own routes.
|
||||||
|
# direct (default): approved HTTPS API host without system/environment proxies.
|
||||||
|
# system: use the runtime's configured proxies if an approved network requires them.
|
||||||
|
ZEROERR_AGENT_PROXY_MODE=direct
|
||||||
AM516_RECORD_FILE=capabilities/am516-delivery-prediction/records/delivery_prediction_records.md
|
AM516_RECORD_FILE=capabilities/am516-delivery-prediction/records/delivery_prediction_records.md
|
||||||
|
|
||||||
# Feishu custom-app credentials. Keep real values in the local .env file only.
|
# Feishu custom-app credentials. Keep real values in the local .env file only.
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ continuation wording is flexible: `还有吗`, `有没有别的`, `再推荐几
|
|||||||
bridge still requires both continuation intent and valid context, so unrelated
|
bridge still requires both continuation intent and valid context, so unrelated
|
||||||
phrases such as `more interesting` or `继续查询英语例句` do not trigger the old query.
|
phrases such as `more interesting` or `继续查询英语例句` do not trigger the old query.
|
||||||
|
|
||||||
The bridge invokes only the existing controlled delivery-prediction script. It
|
The bridge invokes only the existing controlled delivery-prediction script.
|
||||||
|
That script defaults to direct HTTPS for `api.zeroerr-agent.com` without
|
||||||
|
inheriting desktop proxy settings, while keeping certificate verification on.
|
||||||
|
`ZEROERR_AGENT_PROXY_MODE=system` explicitly restores the configured proxy route
|
||||||
|
for approved networks that require it. This setting affects only the API script,
|
||||||
|
not the Feishu connection. See the capability runbook for route diagnostics.
|
||||||
|
It
|
||||||
generates the full Cartesian
|
generates the full Cartesian
|
||||||
set of candidates allowed by the packaged Skill: brake upgrades, encoder
|
set of candidates allowed by the packaged Skill: brake upgrades, encoder
|
||||||
upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer
|
upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer
|
||||||
|
|||||||
@@ -837,7 +837,6 @@ def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str
|
|||||||
return [
|
return [
|
||||||
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
|
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
|
||||||
f"- 需求数量:{quantity}台",
|
f"- 需求数量:{quantity}台",
|
||||||
f"- API查询数量:{details['api_quantity']}台",
|
|
||||||
f"- {details['prediction']}",
|
f"- {details['prediction']}",
|
||||||
f"- 可用库存/覆盖情况:{details['inventory']}",
|
f"- 可用库存/覆盖情况:{details['inventory']}",
|
||||||
f"- 主导因素:{details['main_factor']}",
|
f"- 主导因素:{details['main_factor']}",
|
||||||
@@ -1114,8 +1113,6 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
f"输入需求:{request.model},{request.quantity}台",
|
f"输入需求:{request.model},{request.quantity}台",
|
||||||
"数量传递规则:原始需求原样传入API,库存及交期测算由API处理",
|
|
||||||
"库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况",
|
|
||||||
f"数据时间戳:{data_timestamp}",
|
f"数据时间戳:{data_timestamp}",
|
||||||
"状态:试运行版输出 / 内部候选",
|
"状态:试运行版输出 / 内部候选",
|
||||||
"判断等级:AM516算法预测参考,需人工复核",
|
"判断等级:AM516算法预测参考,需人工复核",
|
||||||
@@ -1157,6 +1154,19 @@ def message_text(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def post_paragraph(line: str) -> list[dict[str, Any]]:
|
def post_paragraph(line: str) -> list[dict[str, Any]]:
|
||||||
|
lead_time = re.fullmatch(
|
||||||
|
r"(- (?:最快候选交期|AM516预测交期):)(\d+个工作日)"
|
||||||
|
r"(,最终交货日期:)(\d{4}-\d{2}-\d{2})",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if lead_time:
|
||||||
|
label, workdays, date_label, delivery_date = lead_time.groups()
|
||||||
|
return [
|
||||||
|
{"tag": "text", "text": label},
|
||||||
|
{"tag": "text", "text": workdays, "style": ["bold"]},
|
||||||
|
{"tag": "text", "text": date_label},
|
||||||
|
{"tag": "text", "text": delivery_date, "style": ["bold"]},
|
||||||
|
]
|
||||||
element: dict[str, Any] = {"tag": "text", "text": line or " "}
|
element: dict[str, Any] = {"tag": "text", "text": line or " "}
|
||||||
if (
|
if (
|
||||||
line in {"总体判断:", "本批次判断:", "不可用候选:"}
|
line in {"总体判断:", "本批次判断:", "不可用候选:"}
|
||||||
|
|||||||
130
tests/test_delivery_prediction_network.py
Normal file
130
tests/test_delivery_prediction_network.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import ssl
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("delivery_query", SCRIPT)
|
||||||
|
query = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(query)
|
||||||
|
|
||||||
|
API_URL = "https://api.zeroerr-agent.com/api/SaleAgent/DeliveryPrediction/WithTransit"
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryNetworkTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.env = mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
self.env.start()
|
||||||
|
self.addCleanup(self.env.stop)
|
||||||
|
self.proxies = mock.patch.object(
|
||||||
|
urllib.request, "getproxies", return_value={"https": "http://127.0.0.1:7897"}
|
||||||
|
)
|
||||||
|
self.getproxies = self.proxies.start()
|
||||||
|
self.addCleanup(self.proxies.stop)
|
||||||
|
connection_patch = mock.patch.object(http.client, "HTTPSConnection")
|
||||||
|
self.connection = connection_patch.start()
|
||||||
|
self.addCleanup(connection_patch.stop)
|
||||||
|
response = self.connection.return_value.getresponse.return_value
|
||||||
|
response.status = response.code = 200
|
||||||
|
response.reason = "OK"
|
||||||
|
response.headers = {}
|
||||||
|
response.read.return_value = b'{"success": true, "data": {}}'
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
|
||||||
|
def test_default_direct_ignores_system_and_environment_proxy(self) -> None:
|
||||||
|
os.environ["https_proxy"] = "http://127.0.0.1:9999"
|
||||||
|
before = dict(os.environ)
|
||||||
|
global_opener = urllib.request._opener
|
||||||
|
|
||||||
|
query.build_api_opener(API_URL).open(API_URL, timeout=30)
|
||||||
|
|
||||||
|
self.assertEqual(self.connection.call_args.args[0], "api.zeroerr-agent.com")
|
||||||
|
self.connection.return_value.set_tunnel.assert_not_called()
|
||||||
|
self.getproxies.assert_not_called()
|
||||||
|
self.assertEqual(dict(os.environ), before)
|
||||||
|
self.assertIs(urllib.request._opener, global_opener)
|
||||||
|
context = self.connection.call_args.kwargs["context"]
|
||||||
|
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
|
||||||
|
self.assertTrue(context.check_hostname)
|
||||||
|
|
||||||
|
def test_system_mode_explicitly_uses_configured_proxy(self) -> None:
|
||||||
|
os.environ[query.PROXY_MODE_ENV] = "system"
|
||||||
|
with mock.patch.object(urllib.request, "proxy_bypass", return_value=False):
|
||||||
|
query.build_api_opener(API_URL).open(API_URL, timeout=30)
|
||||||
|
|
||||||
|
self.assertEqual(self.connection.call_args.args[0], "127.0.0.1:7897")
|
||||||
|
self.assertEqual(
|
||||||
|
self.connection.return_value.set_tunnel.call_args.args[0], "api.zeroerr-agent.com"
|
||||||
|
)
|
||||||
|
self.assertEqual(self.connection.call_args.kwargs["context"].verify_mode, ssl.CERT_REQUIRED)
|
||||||
|
|
||||||
|
def test_invalid_proxy_mode_stops_before_network_and_does_not_echo_value(self) -> None:
|
||||||
|
os.environ[query.PROXY_MODE_ENV] = "SECRET_MARKER"
|
||||||
|
with self.assertRaises(ValueError) as caught:
|
||||||
|
query.build_api_opener(API_URL)
|
||||||
|
self.assertNotIn("SECRET_MARKER", str(caught.exception))
|
||||||
|
self.connection.assert_not_called()
|
||||||
|
|
||||||
|
def test_direct_route_is_limited_to_approved_https_host(self) -> None:
|
||||||
|
for url in ("https://example.invalid/", "http://api.zeroerr-agent.com/",
|
||||||
|
"https://api.zeroerr-agent.com.example.invalid/"):
|
||||||
|
with self.subTest(url=url), self.assertRaises(ValueError):
|
||||||
|
query.build_api_opener(url)
|
||||||
|
self.connection.assert_not_called()
|
||||||
|
|
||||||
|
def run_main(self, *, skip_tls_verify: bool = False) -> tuple[int, dict]:
|
||||||
|
output = io.StringIO()
|
||||||
|
with (
|
||||||
|
mock.patch.object(query, "parse_args", return_value=SimpleNamespace(
|
||||||
|
product="eRob110H160I-FHM-18ET[V6]", quantity=10,
|
||||||
|
skip_tls_verify=skip_tls_verify,
|
||||||
|
)),
|
||||||
|
mock.patch.object(query, "get_api_key", return_value="test-key-not-a-real-secret"),
|
||||||
|
mock.patch.object(query, "DEFAULT_BASE_URL", "https://api.zeroerr-agent.com"),
|
||||||
|
contextlib.redirect_stdout(output),
|
||||||
|
):
|
||||||
|
code = query.main()
|
||||||
|
return code, json.loads(output.getvalue())
|
||||||
|
|
||||||
|
def test_controlled_script_sends_original_quantity_once_over_verified_direct_route(self) -> None:
|
||||||
|
code, payload = self.run_main()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertEqual(self.connection.call_args.args[0], "api.zeroerr-agent.com")
|
||||||
|
request = self.connection.return_value.request
|
||||||
|
request.assert_called_once()
|
||||||
|
method, path = request.call_args.args[:2]
|
||||||
|
self.assertEqual(method, "GET")
|
||||||
|
self.assertEqual(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query), {
|
||||||
|
"product_specification": ["eRob110H160I-FHM-18ET[V6]"], "quantity": ["10"],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_certificate_failure_does_not_retry_or_fall_back_to_proxy(self) -> None:
|
||||||
|
self.connection.return_value.request.side_effect = ssl.SSLCertVerificationError(
|
||||||
|
1, "CERTIFICATE_VERIFY_FAILED"
|
||||||
|
)
|
||||||
|
code, payload = self.run_main()
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertEqual(payload["error"], "TLS certificate verification failed")
|
||||||
|
self.connection.assert_called_once()
|
||||||
|
self.connection.return_value.set_tunnel.assert_not_called()
|
||||||
|
self.assertEqual(self.connection.call_args.kwargs["context"].verify_mode, ssl.CERT_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user