|
| 1 | +"""Offline HTTP regressions for TAPD 1078034 using real routers and handlers. |
| 2 | +
|
| 3 | +Identity, audit persistence and database sessions are test adapters. No application |
| 4 | +startup, external database, provider or deployed service is used. |
| 5 | +""" |
| 6 | + |
| 7 | +import io |
| 8 | +from types import SimpleNamespace |
| 9 | +from unittest.mock import AsyncMock, Mock |
| 10 | + |
| 11 | +import pandas as pd |
| 12 | +import pytest |
| 13 | +import sqlbot_xpack # noqa: F401 # Initialize the published wheel before API imports. |
| 14 | +from fastapi import FastAPI, HTTPException |
| 15 | +from fastapi.testclient import TestClient |
| 16 | +from starlette.exceptions import HTTPException as StarletteHTTPException |
| 17 | + |
| 18 | +from apps.data_training.api import data_training |
| 19 | +from apps.settings.api import base |
| 20 | +from apps.system.api import user |
| 21 | +from apps.system.crud import user_excel |
| 22 | +from apps.system.schemas.permission import RequestContextMiddleware |
| 23 | +from apps.terminology.api import terminology |
| 24 | +from common.audit.schemas.logger_decorator import SystemLogger |
| 25 | +from common.audit.schemas.request_context import RequestContextMiddlewareCommon |
| 26 | +from common.core.db import get_session |
| 27 | +from common.core.deps import get_i18n |
| 28 | +from common.core.response_middleware import ResponseMiddleware, exception_handler |
| 29 | + |
| 30 | +UPLOAD_URLS = [ |
| 31 | + "/system/terminology/uploadExcel", |
| 32 | + "/system/data-training/uploadExcel", |
| 33 | + "/user/batchImport", |
| 34 | +] |
| 35 | + |
| 36 | + |
| 37 | +@pytest.fixture |
| 38 | +def api(monkeypatch, tmp_path): |
| 39 | + app = FastAPI() |
| 40 | + for module in (terminology, data_training, base, user): |
| 41 | + app.include_router(module.router) |
| 42 | + app.add_exception_handler( |
| 43 | + StarletteHTTPException, exception_handler.http_exception_handler |
| 44 | + ) |
| 45 | + app.add_exception_handler(Exception, exception_handler.global_exception_handler) |
| 46 | + app.add_middleware(ResponseMiddleware) |
| 47 | + app.add_middleware(RequestContextMiddleware) |
| 48 | + app.add_middleware(RequestContextMiddlewareCommon) |
| 49 | + |
| 50 | + @app.middleware("http") |
| 51 | + async def identity(request, call_next): |
| 52 | + request.state.current_user = SimpleNamespace( |
| 53 | + isAdmin=True, weight=1, id=1, oid=1, account="test", name="test" |
| 54 | + ) |
| 55 | + return await call_next(request) |
| 56 | + |
| 57 | + session = Mock() |
| 58 | + app.dependency_overrides[get_session] = lambda: session |
| 59 | + app.dependency_overrides[get_i18n] = lambda: lambda key: key |
| 60 | + monkeypatch.setattr(SystemLogger, "create_log_record", AsyncMock()) |
| 61 | + for module in (terminology, data_training, base): |
| 62 | + monkeypatch.setattr(module, "path", str(tmp_path)) |
| 63 | + monkeypatch.setattr(user_excel, "_TEMP_FILE_MAP", {}) |
| 64 | + monkeypatch.setattr(user_excel.tempfile, "gettempdir", lambda: str(tmp_path)) |
| 65 | + # ResponseMiddleware tracks live instances globally in the application. |
| 66 | + monkeypatch.setattr(ResponseMiddleware, "instances", []) |
| 67 | + with TestClient(app, raise_server_exceptions=False) as client: |
| 68 | + yield client, session |
| 69 | + |
| 70 | + |
| 71 | +def workbook(columns): |
| 72 | + buffer = io.BytesIO() |
| 73 | + pd.DataFrame(columns=columns).to_excel(buffer, index=False) |
| 74 | + return buffer.getvalue() |
| 75 | + |
| 76 | + |
| 77 | +@pytest.mark.parametrize("url", UPLOAD_URLS) |
| 78 | +@pytest.mark.parametrize("filename", ["bad.csv", "bad", "bad.xlsx.csv"]) |
| 79 | +def test_unsupported_upload_returns_400(api, tmp_path, url, filename): |
| 80 | + client, session = api |
| 81 | + response = client.post(url, files={"file": (filename, b"invalid spreadsheet")}) |
| 82 | + assert response.status_code == 400, response.text |
| 83 | + assert response.json() == "Only support .xlsx/.xls" |
| 84 | + assert list(tmp_path.iterdir()) == [] |
| 85 | + session.add_all.assert_not_called() |
| 86 | + session.commit.assert_not_called() |
| 87 | + |
| 88 | + |
| 89 | +def test_invalid_user_excel_headers_return_400(api): |
| 90 | + client, session = api |
| 91 | + response = client.post( |
| 92 | + "/user/batchImport", files={"file": ("users.xlsx", workbook(["wrong"]))} |
| 93 | + ) |
| 94 | + assert response.status_code == 400, response.text |
| 95 | + assert response.json() == "Excel header validation failed" |
| 96 | + session.add_all.assert_not_called() |
| 97 | + session.commit.assert_not_called() |
| 98 | + |
| 99 | + |
| 100 | +def test_valid_empty_user_workbook_is_accepted(api): |
| 101 | + client, session = api |
| 102 | + response = client.post( |
| 103 | + "/user/batchImport", |
| 104 | + files={"file": ("users.xlsx", workbook(user_excel.get_i18n_head_list()))}, |
| 105 | + ) |
| 106 | + assert response.status_code == 200, response.text |
| 107 | + assert response.json()["data"] == { |
| 108 | + "successCount": 0, |
| 109 | + "errorCount": 0, |
| 110 | + "dataKey": None, |
| 111 | + } |
| 112 | + session.commit.assert_not_called() |
| 113 | + |
| 114 | + |
| 115 | +@pytest.mark.parametrize( |
| 116 | + "module,batch_name,column_count", |
| 117 | + [ |
| 118 | + (terminology, "batch_create_terminology", 6), |
| 119 | + (data_training, "batch_create_training", 4), |
| 120 | + ], |
| 121 | +) |
| 122 | +@pytest.mark.parametrize("filename", ["valid.xlsx", "valid.XLSX"]) |
| 123 | +def test_valid_knowledge_workbook_reaches_batch_import( |
| 124 | + api, monkeypatch, module, batch_name, column_count, filename |
| 125 | +): |
| 126 | + client, session = api |
| 127 | + monkeypatch.setattr(module, "session_maker", lambda: session) |
| 128 | + batch = Mock( |
| 129 | + return_value={ |
| 130 | + "failed_records": [], |
| 131 | + "success_count": 0, |
| 132 | + "duplicate_count": 0, |
| 133 | + "original_count": 0, |
| 134 | + } |
| 135 | + ) |
| 136 | + monkeypatch.setattr(module, batch_name, batch) |
| 137 | + response = client.post( |
| 138 | + module.router.prefix + "/uploadExcel", |
| 139 | + files={"file": (filename, workbook(range(column_count)))}, |
| 140 | + ) |
| 141 | + assert response.status_code == 200, response.text |
| 142 | + assert response.json()["data"]["failed_count"] == 0 |
| 143 | + batch.assert_called_once() |
| 144 | + assert batch.call_args.args[:3] == (session, [], 1) |
| 145 | + |
| 146 | + |
| 147 | +@pytest.mark.parametrize( |
| 148 | + "filename,exists,status,message", |
| 149 | + [ |
| 150 | + ("missing_error.xlsx", False, 404, "File Not Exists"), |
| 151 | + ("bad.csv", True, 400, "Only support _error.xlsx"), |
| 152 | + ], |
| 153 | +) |
| 154 | +def test_knowledge_error_download_returns_client_error( |
| 155 | + api, tmp_path, filename, exists, status, message |
| 156 | +): |
| 157 | + client, _session = api |
| 158 | + if exists: |
| 159 | + (tmp_path / filename).write_bytes(b"test file") |
| 160 | + response = client.post("/system/download-fail-info", json={"file": filename}) |
| 161 | + assert response.status_code == status, response.text |
| 162 | + assert response.json() == message |
| 163 | + |
| 164 | + |
| 165 | +def test_user_error_download_requires_file_id(): |
| 166 | + with pytest.raises(HTTPException) as raised: |
| 167 | + user_excel.download_error_file("") |
| 168 | + assert raised.value.status_code == 400 |
| 169 | + assert raised.value.detail == "file_id required" |
| 170 | + |
| 171 | + |
| 172 | +@pytest.mark.parametrize("registered", [False, True]) |
| 173 | +def test_missing_user_error_file_returns_404(api, tmp_path, registered): |
| 174 | + client, _session = api |
| 175 | + if registered: |
| 176 | + user_excel._TEMP_FILE_MAP["missing"] = str(tmp_path / "missing.xlsx") |
| 177 | + response = client.get("/user/errorRecord/missing") |
| 178 | + assert response.status_code == 404, response.text |
| 179 | + assert response.json() == "File not found" |
| 180 | + |
| 181 | + |
| 182 | +def test_user_error_file_outside_temp_directory_returns_403(api, tmp_path, monkeypatch): |
| 183 | + client, _session = api |
| 184 | + monkeypatch.setattr( |
| 185 | + user_excel.tempfile, "gettempdir", lambda: str(tmp_path / "allowed") |
| 186 | + ) |
| 187 | + outside = tmp_path / "outside.xlsx" |
| 188 | + outside.write_bytes(b"private data") |
| 189 | + user_excel._TEMP_FILE_MAP["outside"] = str(outside) |
| 190 | + response = client.get("/user/errorRecord/outside") |
| 191 | + assert response.status_code == 403, response.text |
| 192 | + assert response.json() == "Unauthorized file access" |
| 193 | + assert outside.read_bytes() == b"private data" |
| 194 | + |
| 195 | + |
| 196 | +@pytest.mark.parametrize("kind", ["knowledge", "user"]) |
| 197 | +def test_valid_error_file_can_be_downloaded(api, tmp_path, kind): |
| 198 | + client, _session = api |
| 199 | + content = workbook(["error"]) |
| 200 | + target = tmp_path / "valid_error.xlsx" |
| 201 | + target.write_bytes(content) |
| 202 | + if kind == "knowledge": |
| 203 | + response = client.post("/system/download-fail-info", json={"file": target.name}) |
| 204 | + else: |
| 205 | + user_excel._TEMP_FILE_MAP["valid"] = str(target) |
| 206 | + response = client.get("/user/errorRecord/valid") |
| 207 | + assert response.status_code == 200, response.text |
| 208 | + assert response.content == content |
0 commit comments