You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iTi-Flask/tests/test_exchange.py

361 lines
11 KiB
Python

from __future__ import annotations
from io import BytesIO
from dataclasses import dataclass
import httpx
from fastapi.testclient import TestClient
from iti import create_app
from iti.config import BaseConfig
from iti.db import Base, reset_db
from iti.exchange import (
ExchangeField,
ExchangePlaceholder,
ExchangePlan,
ExchangeTemplateBinding,
ExchangeTemplateKind,
ExchangeTemplateSource,
ExchangeTemplateSourceKind,
LocalExchangeSource,
MappingExchangeSource,
RemoteExchangeSource,
get_exchange_registry,
register_exchange_source,
)
from iti.exchange.excel import ExcelTemplateCodec, ExcelWorkbookCodec
from iti.exchange.service import ExchangeService
from iti.exchange.base import ExchangeTemplateSnapshot
from iti.service_client import register_service_client
def make_app(*, exchange_enabled: bool = True):
config = BaseConfig(
database_url="sqlite+pysqlite:///:memory:",
testing=True,
exchange_enabled=exchange_enabled,
)
app = create_app(config_mapping=config)
Base.metadata.create_all(app.state.db_engine)
return app
def test_exchange_module_is_auto_registered():
app = make_app()
assert app.state.iti_modules.get("exchange") is not None
assert "exchange:template:list" in app.state.iti_modules.permissions
def test_template_version_workbook_roundtrip():
snapshot = ExchangeTemplateSnapshot(
id="v1",
version="1.0.0",
template_id="tpl1",
template_kind="import",
bindings=(
ExchangeTemplateBinding(entity="order", template_kind="import", title="订单"),
),
fields=(
ExchangeField(key="name", label="名称", placeholder="{{name}}", source="name"),
),
placeholders=(
ExchangePlaceholder(key="tenant", label="租户", example="demo"),
),
meta={"title": "订单模板", "sheet_name": "模板"},
)
codec = ExcelTemplateCodec()
content = codec.dump(snapshot)
parsed = codec.load(content)
assert parsed["title"] == "订单模板"
assert parsed["sheet_name"] == "模板"
assert parsed["bindings"][0]["entity"] == "order"
assert parsed["fields"][0]["key"] == "name"
assert parsed["placeholders"][0]["key"] == "tenant"
def test_exchange_service_create_publish_and_task_flow():
reset_db()
app = make_app()
service = ExchangeService(app, app.state.db_sessionmaker())
template = service.create_template(
code="order",
name="订单",
template_kind="import",
entity="order",
)
assert template.code == "order"
version = service.publish_version(
template_id=template.id,
version="1.0.0",
bindings=[
ExchangeTemplateBinding(entity="order", template_kind="import", title="订单")
],
fields=[ExchangeField(key="name", label="名称", source="name")],
placeholders=[ExchangePlaceholder(key="tenant", label="租户")],
)
assert version.version == "1.0.0"
task = service.create_task(
template_id=template.id,
version_id=version.id,
task_kind="import",
input_payload={"source": "upload"},
)
assert task.task_kind == "import"
assert task.template_version_id == version.id
workbook = ExcelWorkbookCodec().export_rows_with_template(
fields=[ExchangeField(key="name", label="名称", source="name")],
rows=[{"name": "A"}],
sheet_name="导出",
)
rows = ExcelWorkbookCodec().import_rows(workbook)
assert rows[0]["名称"] == "A"
def test_mapping_source_supports_template_less_workbook_roundtrip():
source = MappingExchangeSource()
plan = source.resolve_plan(
template_kind="import",
fields=[
ExchangeField(key="name", label="名称", placeholder="{{name}}", source="name"),
ExchangeField(key="age", label="年龄", source="age"),
],
placeholders=[ExchangePlaceholder(key="tenant", label="租户")],
title="映射模板",
sheet_name="映射",
)
codec = ExcelTemplateCodec()
workbook = codec.dump(plan)
parsed = codec.load(workbook)
assert parsed["title"] == "映射模板"
assert parsed["sheet_name"] == "映射"
assert parsed["fields"][0]["source"] == "name"
rows = ExcelWorkbookCodec().export_rows_with_plan(
plan=plan,
rows=[{"name": "Alice", "age": 18}],
)
imported = ExcelWorkbookCodec().import_rows_with_fields(rows, fields=list(plan.fields))
assert imported[0]["name"] == "Alice"
assert imported[0]["age"] == 18
def test_excel_workbook_codec_roundtrip_uses_pandas_and_preserves_empty_cells():
codec = ExcelWorkbookCodec()
content = codec.export_rows(
headers=["名称", "年龄"],
rows=[{"名称": "Alice", "年龄": None}],
sheet_name="导出",
)
rows = codec.import_rows(content)
assert rows == [{"名称": "Alice", "年龄": None}]
def test_excel_workbook_codec_with_fields_maps_headers_to_target_keys():
codec = ExcelWorkbookCodec()
fields = [
ExchangeField(key="name", label="名称", source="name"),
ExchangeField(key="age", label="年龄", target="age_import"),
]
content = codec.export_rows_with_template(
fields=fields,
rows=[{"name": "Alice", "age": 18}],
sheet_name="映射",
)
rows = codec.import_rows_with_fields(content, fields=fields)
assert rows == [{"name": "Alice", "age_import": 18}]
def test_remote_source_resolves_plan_and_template_bytes():
app = create_app(config_mapping=BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True))
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/exchange/template-versions/v1":
return httpx.Response(
200,
json={
"data": {
"id": "v1",
"template_id": "tpl1",
"version": "1.0.0",
"template_kind": "import",
"bindings": [
{"entity": "order", "template_kind": "import", "title": "订单"}
],
"fields": [
{"key": "name", "label": "名称", "source": "name"}
],
"placeholders": [
{"key": "tenant", "label": "租户"}
],
"meta": {"title": "订单模板", "sheet_name": "模板"},
},
"code": 200,
"message": "成功",
},
)
if request.url.path == "/exchange/template-versions/v1/download":
content = ExcelTemplateCodec().dump(
ExchangePlan.from_mapping(
template_kind=ExchangeTemplateKind.IMPORT,
template_id="tpl1",
version_id="v1",
version="1.0.0",
fields=[ExchangeField(key="name", label="名称", source="name")],
title="订单模板",
sheet_name="模板",
)
)
return httpx.Response(
200,
content=content,
headers={
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
},
)
raise AssertionError(f"unexpected path: {request.url.path}")
register_service_client(
app,
"template_center",
{"base_url": "https://template-center.local"},
transport=httpx.MockTransport(handler),
)
source = RemoteExchangeSource(app, service_name="template_center")
plan = source.resolve_plan(template_kind="import", version_id="v1")
assert plan.template_id == "tpl1"
assert plan.title == "订单模板"
assert plan.fields[0].source == "name"
content = source.load_template_file(plan)
assert content is not None
assert len(content) > 0
def test_custom_registered_source_can_drive_plan_and_file():
@dataclass
class CustomSource:
plan: ExchangePlan
content: bytes
def resolve_plan(self, **kwargs):
return self.plan
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
return self.content
app = make_app()
custom_plan = ExchangePlan.from_mapping(
template_kind="import",
template_id="tpl-custom",
version_id="v-custom",
version="1.0.0",
fields=[ExchangeField(key="name", label="名称", source="name")],
title="自定义模板",
)
register_exchange_source(
app,
"custom-center",
CustomSource(plan=custom_plan, content=b"custom-template"),
)
assert get_exchange_registry(app).get_source("custom-center") is not None
client = TestClient(app)
response = client.post(
"/exchange/plans/resolve",
json={
"templateKind": "import",
"sourceName": "custom-center",
},
)
assert response.status_code == 200
assert response.json()["data"]["template_id"] == "tpl-custom"
file_resp = client.post(
"/exchange/plans/template-file",
json={
"templateKind": "import",
"sourceName": "custom-center",
},
)
assert file_resp.status_code == 200
assert file_resp.content == b"custom-template"
def test_exchange_routes_are_available():
app = make_app()
client = TestClient(app)
created = client.post(
"/exchange/templates",
json={
"code": "order",
"name": "订单",
"template_kind": "import",
"entity": "order",
},
)
assert created.status_code == 200
template_id = created.json()["data"]["id"]
version = client.post(
f"/exchange/templates/{template_id}/versions",
json={
"version": "1.0.0",
"bindings": [],
"fields": [],
"placeholders": [],
},
)
assert version.status_code == 200
listed = client.get("/exchange/templates")
assert listed.status_code == 200
assert listed.json()["data"][0]["code"] == "order"
def test_exchange_plan_routes_support_mapping_source():
app = make_app()
client = TestClient(app)
response = client.post(
"/exchange/plans/resolve",
json={
"taskKind": "import",
"sourceKind": "mapping",
"fields": [
{"key": "name", "label": "名称", "source": "name"},
],
"title": "映射",
"sheetName": "映射",
},
)
assert response.status_code == 200
assert response.json()["data"]["title"] == "映射"
file_resp = client.post(
"/exchange/plans/template-file",
json={
"taskKind": "import",
"sourceKind": "mapping",
"fields": [
{"key": "name", "label": "名称", "source": "name"},
],
"title": "映射",
"sheetName": "映射",
},
)
assert file_resp.status_code == 200