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