api-test-toolkit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/PKG-INFO +2 -1
  2. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/pyproject.toml +2 -1
  3. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/client.py +20 -1
  4. api_test_toolkit-0.3.0/src/api_test_kit/config.py +185 -0
  5. api_test_toolkit-0.3.0/src/api_test_kit/db/__init__.py +8 -0
  6. api_test_toolkit-0.3.0/src/api_test_kit/db/config.py +30 -0
  7. api_test_toolkit-0.3.0/src/api_test_kit/db/database.py +138 -0
  8. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/scaffold.py +1 -0
  9. api_test_toolkit-0.3.0/src/api_test_kit/templates/tests/e2e/__init__.py +0 -0
  10. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/PKG-INFO +2 -1
  11. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/SOURCES.txt +6 -0
  12. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/requires.txt +1 -0
  13. api_test_toolkit-0.3.0/tests/test_config.py +56 -0
  14. api_test_toolkit-0.3.0/tests/test_db.py +108 -0
  15. api_test_toolkit-0.2.0/src/api_test_kit/config.py +0 -82
  16. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/README.md +0 -0
  17. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/setup.cfg +0 -0
  18. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/__init__.py +0 -0
  19. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/assertions.py +0 -0
  20. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/cli.py +0 -0
  21. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/__init__.py +0 -0
  22. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/context.py +0 -0
  23. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/helpers.py +0 -0
  24. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/scenario.py +0 -0
  25. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/step.py +0 -0
  26. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/endpoints/__init__.py +0 -0
  27. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/endpoints/_base.py +0 -0
  28. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/logger.py +0 -0
  29. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/AGENTS.md +0 -0
  30. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/README.md +0 -0
  31. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/pyproject.toml +0 -0
  32. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/pytest.ini +0 -0
  33. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/scripts/new-endpoint.sh +0 -0
  34. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/__init__.py +0 -0
  35. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/conftest.py +0 -0
  36. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/dependency_links.txt +0 -0
  37. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/entry_points.txt +0 -0
  38. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/top_level.txt +0 -0
  39. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_assertions.py +0 -0
  40. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_client.py +0 -0
  41. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_context.py +0 -0
  42. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_scenario.py +0 -0
  43. {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_step.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-test-toolkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: AI 友好的 API 自动化测试工具包
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests>=2.31
@@ -19,3 +19,4 @@ Requires-Dist: responses>=0.25; extra == "dev"
19
19
  Requires-Dist: faker>=22.0; extra == "dev"
20
20
  Requires-Dist: ruff>=0.3; extra == "dev"
21
21
  Requires-Dist: mypy>=1.8; extra == "dev"
22
+ Requires-Dist: pymysql>=1.1; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__"
4
4
 
5
5
  [project]
6
6
  name = "api-test-toolkit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "AI 友好的 API 自动化测试工具包"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -33,4 +33,5 @@ dev = [
33
33
  "faker>=22.0",
34
34
  "ruff>=0.3",
35
35
  "mypy>=1.8",
36
+ "pymysql>=1.1",
36
37
  ]
@@ -9,7 +9,7 @@ from typing import Any
9
9
  import requests
10
10
  from requests.adapters import HTTPAdapter, Retry
11
11
 
12
- from api_test_kit.config import HarnessConfig
12
+ from api_test_kit.config import DomainConfig, HarnessConfig
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -83,6 +83,11 @@ class BaseClient:
83
83
  client = BaseClient(config)
84
84
  client.authenticate(BearerTokenProvider("xxx"))
85
85
  resp: ApiResponse = client.get("/api/v1/users")
86
+
87
+ 从域名配置创建::
88
+
89
+ domain = config.get_domain("order")
90
+ client = BaseClient.from_domain(domain)
86
91
  """
87
92
 
88
93
  def __init__(self, config: HarnessConfig) -> None:
@@ -92,6 +97,20 @@ class BaseClient:
92
97
  self._token_provider: TokenProvider | None = None
93
98
  self._token: str | None = None
94
99
 
100
+ @classmethod
101
+ def from_domain(cls, domain: DomainConfig) -> BaseClient:
102
+ """从 ``DomainConfig`` 创建客户端(用于跨系统 E2E)。"""
103
+ config = HarnessConfig(
104
+ base_url=domain.base_url,
105
+ api_prefix=domain.api_prefix,
106
+ auth_type=domain.auth_type,
107
+ auth_header_name=domain.auth_header_name,
108
+ client_secret=domain.client_secret,
109
+ timeout=domain.timeout,
110
+ max_retries=domain.max_retries,
111
+ )
112
+ return cls(config)
113
+
95
114
  # ── 认证 ────────────────────────────────────────────────────
96
115
 
97
116
  def authenticate(self, provider: TokenProvider) -> None:
@@ -0,0 +1,185 @@
1
+ """通过 Pydantic Settings 实现的多环境配置。
2
+
3
+ 使用方法::
4
+
5
+ config = HarnessConfig.from_env("staging")
6
+ config.base_url # -> "https://staging-api.example.com"
7
+
8
+ 多域名支持(E2E 跨系统调用)::
9
+
10
+ # config.staging.env 中配置:
11
+ # DOMAIN_ORDER__BASE_URL=https://order-api.example.com
12
+ # DOMAIN_INVENTORY__BASE_URL=https://inv-api.example.com
13
+
14
+ config.get_domain("order") # -> DomainConfig(base_url="...")
15
+ config.list_domains() # -> ["inventory", "order"]
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Literal
23
+
24
+ from pydantic import Field
25
+ from pydantic_settings import BaseSettings, SettingsConfigDict
26
+
27
+
28
+ # ── 域名配置 ──────────────────────────────────────────────
29
+
30
+
31
+ @dataclass
32
+ class DomainConfig:
33
+ """单个 API 域名的连接配置。
34
+
35
+ 通过 ``DOMAIN_{NAME}__{KEY}`` 环境变量驱动。
36
+ """
37
+ name: str
38
+ base_url: str = ""
39
+ api_prefix: str = ""
40
+ auth_type: str = "bearer"
41
+ auth_header_name: str = "authorization"
42
+ client_secret: str | None = None
43
+ timeout: int = 30
44
+ max_retries: int = 3
45
+
46
+
47
+ class HarnessConfig(BaseSettings):
48
+ """从环境文件或环境变量加载的应用配置。
49
+
50
+ 优先级:环境变量 > 环境文件 > 默认值。
51
+ """
52
+
53
+ model_config = SettingsConfigDict(
54
+ env_file=".env",
55
+ env_file_encoding="utf-8",
56
+ extra="ignore",
57
+ frozen=True,
58
+ )
59
+
60
+ # --- 核心 ---
61
+ env_name: str = Field(default="dev", description="Environment name (dev/staging/prod)")
62
+ base_url: str = Field(default="http://localhost:8000", description="API base URL")
63
+ api_prefix: str = Field(default="/api/v1", description="API version prefix")
64
+
65
+ # --- 认证 ---
66
+ auth_type: Literal["none", "bearer", "api_key", "basic", "oauth2"] = Field(
67
+ default="bearer",
68
+ )
69
+ auth_header_name: str = Field(
70
+ default="authorization",
71
+ description="HTTP header name for the auth token "
72
+ "(default: 'authorization' for bearer, 'x-api-key' for api_key)",
73
+ )
74
+ auth_token_url: str | None = Field(default=None, description="Token endpoint for OAuth2")
75
+ client_id: str | None = Field(default=None)
76
+ client_secret: str | None = Field(default=None, description="Also used as bearer token value")
77
+
78
+ # --- HTTP 客户端 ---
79
+ timeout: int = Field(default=30, ge=1, le=300)
80
+ max_retries: int = Field(default=3, ge=0, le=10)
81
+ retry_backoff: float = Field(default=1.0, ge=0.1)
82
+
83
+ # --- 日志 ---
84
+ log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR)$")
85
+ log_requests: bool = Field(default=True)
86
+ log_responses: bool = Field(default=True)
87
+
88
+ # --- 派生 ---
89
+ @property
90
+ def api_url(self) -> str:
91
+ """完整的 API 基础 URL(base_url + api_prefix)。"""
92
+ return f"{self.base_url}{self.api_prefix}"
93
+
94
+ # ── 域名发现 ──────────────────────────────────────────
95
+
96
+ _domain_cache: dict[str, DomainConfig] | None = None
97
+
98
+ def get_domain(self, name: str) -> DomainConfig | None:
99
+ """按名称获取域名配置。
100
+
101
+ 从环境变量读取 ``DOMAIN_{NAME}__{KEY}`` 模式的值::
102
+
103
+ config.get_domain("order")
104
+ # -> DomainConfig(name="order", base_url="https://order-api.example.com", ...)
105
+
106
+ 如果未配置返回 ``None``。
107
+ """
108
+ prefix = f"DOMAIN_{name.upper()}__"
109
+ base_url = os.getenv(f"{prefix}BASE_URL")
110
+ if not base_url:
111
+ return None
112
+ return DomainConfig(
113
+ name=name.lower(),
114
+ base_url=base_url,
115
+ api_prefix=os.getenv(f"{prefix}API_PREFIX", ""),
116
+ auth_type=os.getenv(f"{prefix}AUTH_TYPE", "bearer"),
117
+ auth_header_name=os.getenv(f"{prefix}AUTH_HEADER_NAME", "authorization"),
118
+ client_secret=os.getenv(f"{prefix}CLIENT_SECRET"),
119
+ timeout=int(os.getenv(f"{prefix}TIMEOUT", "30")),
120
+ max_retries=int(os.getenv(f"{prefix}MAX_RETRIES", "3")),
121
+ )
122
+
123
+ def list_domains(self) -> list[str]:
124
+ """列出所有已配置的域名。"""
125
+ names: set[str] = set()
126
+ for key, val in os.environ.items():
127
+ if key.startswith("DOMAIN_") and "__" in key:
128
+ name = key.split("__")[0].replace("DOMAIN_", "").lower()
129
+ names.add(name)
130
+ return sorted(names)
131
+
132
+ # ── 数据库配置发现 ─────────────────────────────────────
133
+
134
+ def get_db_config(self, name: str) -> DatabaseConfig | None:
135
+ """按名称获取数据库配置。
136
+
137
+ 从环境变量读取 ``DB_{NAME}__{KEY}`` 模式的值::
138
+
139
+ config.get_db_config("order")
140
+ # -> DatabaseConfig(name="order", engine="mysql", host="...")
141
+ """
142
+ from api_test_kit.db.config import DatabaseConfig
143
+
144
+ prefix = f"DB_{name.upper()}__"
145
+ engine = os.getenv(f"{prefix}ENGINE")
146
+ if not engine:
147
+ return None
148
+ return DatabaseConfig(
149
+ name=name.lower(),
150
+ engine=engine.lower(),
151
+ host=os.getenv(f"{prefix}HOST", "localhost"),
152
+ port=int(os.getenv(f"{prefix}PORT", "3306")),
153
+ user=os.getenv(f"{prefix}USER", "root"),
154
+ password=os.getenv(f"{prefix}PASSWORD", ""),
155
+ database=os.getenv(f"{prefix}DATABASE", name.lower()),
156
+ )
157
+
158
+ def list_db_configs(self) -> list[str]:
159
+ """列出所有已配置的数据库名称。"""
160
+ names: set[str] = set()
161
+ for key, val in os.environ.items():
162
+ if key.startswith("DB_") and "__" in key:
163
+ name = key.split("__")[0].replace("DB_", "").lower()
164
+ names.add(name)
165
+ return sorted(names)
166
+
167
+ @classmethod
168
+ def from_env(cls, env_name: str = "dev") -> HarnessConfig:
169
+ """加载指定环境的配置。
170
+
171
+ 查找项目根目录下的 ``config.{env_name}.env``,
172
+ 如果不存在则回退到 ``.env``。
173
+ """
174
+ project_root = cls._find_project_root()
175
+ env_file = project_root / f"config.{env_name}.env"
176
+ return cls(_env_file=str(env_file) if env_file.exists() else None)
177
+
178
+ @staticmethod
179
+ def _find_project_root() -> Path:
180
+ """从当前工作目录向上查找项目根目录(包含 pyproject.toml 的目录)。"""
181
+ start = Path.cwd()
182
+ for parent in [start] + list(start.parents):
183
+ if (parent / "pyproject.toml").exists():
184
+ return parent
185
+ return start
@@ -0,0 +1,8 @@
1
+ """数据库操作 — 多数据源连接、查询、写入、数据提取。"""
2
+ from api_test_kit.db.config import DatabaseConfig
3
+ from api_test_kit.db.database import Database
4
+
5
+ __all__ = [
6
+ "DatabaseConfig",
7
+ "Database",
8
+ ]
@@ -0,0 +1,30 @@
1
+ """数据库连接配置。"""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class DatabaseConfig:
10
+ """单个数据库的连接配置。
11
+
12
+ 通过 ``DB_{NAME}__{KEY}`` 环境变量驱动。
13
+ """
14
+
15
+ name: str
16
+ engine: str = "mysql" # mysql / postgresql / sqlite
17
+ host: str = "localhost"
18
+ port: int = 3306
19
+ user: str = "root"
20
+ password: str = ""
21
+ database: str = ""
22
+
23
+ @property
24
+ def is_sqlite(self) -> bool:
25
+ return self.engine == "sqlite"
26
+
27
+ @classmethod
28
+ def sqlite_in_memory(cls, name: str = "test") -> DatabaseConfig:
29
+ """创建一个内存 SQLite 配置(用于单元测试)。"""
30
+ return cls(name=name, engine="sqlite", database=":memory:")
@@ -0,0 +1,138 @@
1
+ """数据库操作封装 — 连接、查询、写入、数据提取。
2
+
3
+ 支持 MySQL、PostgreSQL、SQLite。MySQL 需要安装 ``pymysql``。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from api_test_kit.db.config import DatabaseConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Database:
16
+ """数据库操作封装。
17
+
18
+ 用法::
19
+
20
+ config = DatabaseConfig(name="order", engine="mysql",
21
+ host="localhost", database="orders")
22
+ db = Database(config)
23
+ rows = db.query("SELECT * FROM orders WHERE id = %(id)s", {"id": 1})
24
+ affected = db.execute("UPDATE orders SET status = %(s)s", {"s": "shipped"})
25
+ val = db.extract("SELECT name FROM users WHERE id = %(id)s", "name", {"id": 1})
26
+ """
27
+
28
+ def __init__(self, config: DatabaseConfig) -> None:
29
+ self.config = config
30
+ self._conn: Any = None
31
+
32
+ # ── 连接 ──────────────────────────────────────────────
33
+
34
+ @property
35
+ def conn(self) -> Any:
36
+ """惰性获取数据库连接。"""
37
+ if self._conn is None:
38
+ self._conn = self._create_connection()
39
+ logger.info("[DB] 已连接: %s/%s", self.config.engine, self.config.database)
40
+ return self._conn
41
+
42
+ def _create_connection(self) -> Any:
43
+ cfg = self.config
44
+ if cfg.is_sqlite:
45
+ import sqlite3
46
+ conn = sqlite3.connect(cfg.database)
47
+ conn.row_factory = sqlite3.Row
48
+ return conn
49
+ elif cfg.engine == "mysql":
50
+ return self._connect_mysql(cfg)
51
+ elif cfg.engine == "postgresql":
52
+ return self._connect_postgres(cfg)
53
+ raise ValueError(f"不支持的数据库引擎: {cfg.engine}")
54
+
55
+ def _connect_mysql(self, cfg: DatabaseConfig) -> Any:
56
+ try:
57
+ import pymysql
58
+ import pymysql.cursors
59
+ except ImportError:
60
+ raise ImportError(
61
+ "MySQL 支持需要安装 pymysql: pip install pymysql"
62
+ )
63
+ return pymysql.connect(
64
+ host=cfg.host,
65
+ port=cfg.port,
66
+ user=cfg.user,
67
+ password=cfg.password,
68
+ database=cfg.database,
69
+ charset="utf8mb4",
70
+ cursorclass=pymysql.cursors.DictCursor,
71
+ )
72
+
73
+ def _connect_postgres(self, cfg: DatabaseConfig) -> Any:
74
+ try:
75
+ import psycopg2
76
+ import psycopg2.extras
77
+ except ImportError:
78
+ raise ImportError(
79
+ "PostgreSQL 支持需要安装 psycopg2: pip install psycopg2-binary"
80
+ )
81
+ return psycopg2.connect(
82
+ host=cfg.host,
83
+ port=cfg.port,
84
+ user=cfg.user,
85
+ password=cfg.password,
86
+ dbname=cfg.database,
87
+ cursor_factory=psycopg2.extras.RealDictCursor,
88
+ )
89
+
90
+ # ── 操作 ──────────────────────────────────────────────
91
+
92
+ def query(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
93
+ """执行查询,返回字典列表。"""
94
+ cursor = self.conn.cursor()
95
+ cursor.execute(sql, params or {})
96
+ rows = cursor.fetchall()
97
+ cols = [d[0] for d in cursor.description] if cursor.description else []
98
+ cursor.close()
99
+ result: list[dict[str, Any]] = []
100
+ for r in rows:
101
+ if isinstance(r, dict):
102
+ result.append(r)
103
+ elif hasattr(r, "keys"):
104
+ result.append({k: r[k] for k in r.keys()})
105
+ else:
106
+ result.append(dict(zip(cols, r)) if cols else {})
107
+ return result
108
+
109
+ def execute(self, sql: str, params: dict[str, Any] | None = None) -> int:
110
+ """执行写入(insert/update/delete),返回影响行数。"""
111
+ cursor = self.conn.cursor()
112
+ cursor.execute(sql, params or {})
113
+ self.conn.commit()
114
+ affected = cursor.rowcount
115
+ cursor.close()
116
+ return affected
117
+
118
+ def extract(self, sql: str, column: str, params: dict[str, Any] | None = None) -> Any:
119
+ """查询并提取某一列的值(用于 E2E 数据传递)。"""
120
+ rows = self.query(sql, params)
121
+ if rows:
122
+ return rows[0].get(column)
123
+ return None
124
+
125
+ def close(self) -> None:
126
+ """关闭数据库连接。"""
127
+ if self._conn:
128
+ self._conn.close()
129
+ self._conn = None
130
+ logger.info("[DB] 已断开: %s/%s", self.config.engine, self.config.database)
131
+
132
+ # ── 上下文管理器 ───────────────────────────────────────
133
+
134
+ def __enter__(self) -> Database:
135
+ return self
136
+
137
+ def __exit__(self, *args: Any) -> None:
138
+ self.close()
@@ -50,6 +50,7 @@ TEMPLATE_FILES: list[str] = [
50
50
 
51
51
  EMPTY_DIRS: list[str] = [
52
52
  "tests/endpoints",
53
+ "tests/e2e",
53
54
  "reports",
54
55
  ]
55
56
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-test-toolkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: AI 友好的 API 自动化测试工具包
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests>=2.31
@@ -19,3 +19,4 @@ Requires-Dist: responses>=0.25; extra == "dev"
19
19
  Requires-Dist: faker>=22.0; extra == "dev"
20
20
  Requires-Dist: ruff>=0.3; extra == "dev"
21
21
  Requires-Dist: mypy>=1.8; extra == "dev"
22
+ Requires-Dist: pymysql>=1.1; extra == "dev"
@@ -7,6 +7,9 @@ src/api_test_kit/client.py
7
7
  src/api_test_kit/config.py
8
8
  src/api_test_kit/logger.py
9
9
  src/api_test_kit/scaffold.py
10
+ src/api_test_kit/db/__init__.py
11
+ src/api_test_kit/db/config.py
12
+ src/api_test_kit/db/database.py
10
13
  src/api_test_kit/e2e/__init__.py
11
14
  src/api_test_kit/e2e/context.py
12
15
  src/api_test_kit/e2e/helpers.py
@@ -21,6 +24,7 @@ src/api_test_kit/templates/pytest.ini
21
24
  src/api_test_kit/templates/scripts/new-endpoint.sh
22
25
  src/api_test_kit/templates/tests/__init__.py
23
26
  src/api_test_kit/templates/tests/conftest.py
27
+ src/api_test_kit/templates/tests/e2e/__init__.py
24
28
  src/api_test_toolkit.egg-info/PKG-INFO
25
29
  src/api_test_toolkit.egg-info/SOURCES.txt
26
30
  src/api_test_toolkit.egg-info/dependency_links.txt
@@ -29,6 +33,8 @@ src/api_test_toolkit.egg-info/requires.txt
29
33
  src/api_test_toolkit.egg-info/top_level.txt
30
34
  tests/test_assertions.py
31
35
  tests/test_client.py
36
+ tests/test_config.py
37
+ tests/test_db.py
32
38
  tests/test_e2e_context.py
33
39
  tests/test_e2e_scenario.py
34
40
  tests/test_e2e_step.py
@@ -15,3 +15,4 @@ responses>=0.25
15
15
  faker>=22.0
16
16
  ruff>=0.3
17
17
  mypy>=1.8
18
+ pymysql>=1.1
@@ -0,0 +1,56 @@
1
+ """配置模块单元测试。"""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ import pytest
7
+
8
+ from api_test_kit.config import DomainConfig, HarnessConfig
9
+
10
+
11
+ class TestDomainConfig:
12
+ """DomainConfig — 多域名配置。"""
13
+
14
+ def test_get_domain_exists(self) -> None:
15
+ os.environ["DOMAIN_ORDER__BASE_URL"] = "https://order.test.com"
16
+ os.environ["DOMAIN_ORDER__API_PREFIX"] = "/api/v2"
17
+ try:
18
+ c = HarnessConfig()
19
+ d = c.get_domain("order")
20
+ assert d is not None
21
+ assert d.base_url == "https://order.test.com"
22
+ assert d.api_prefix == "/api/v2"
23
+ assert d.name == "order"
24
+ finally:
25
+ del os.environ["DOMAIN_ORDER__BASE_URL"]
26
+ del os.environ["DOMAIN_ORDER__API_PREFIX"]
27
+
28
+ def test_get_domain_missing(self) -> None:
29
+ c = HarnessConfig()
30
+ assert c.get_domain("nonexistent") is None
31
+
32
+ def test_list_domains(self) -> None:
33
+ os.environ["DOMAIN_A__BASE_URL"] = "https://a.test.com"
34
+ os.environ["DOMAIN_B__BASE_URL"] = "https://b.test.com"
35
+ try:
36
+ c = HarnessConfig()
37
+ domains = c.list_domains()
38
+ assert "a" in domains
39
+ assert "b" in domains
40
+ finally:
41
+ del os.environ["DOMAIN_A__BASE_URL"]
42
+ del os.environ["DOMAIN_B__BASE_URL"]
43
+
44
+ def test_get_db_config(self) -> None:
45
+ os.environ["DB_ORDER__ENGINE"] = "mysql"
46
+ os.environ["DB_ORDER__HOST"] = "order-db.test.com"
47
+ try:
48
+ c = HarnessConfig()
49
+ dbc = c.get_db_config("order")
50
+ assert dbc is not None
51
+ assert dbc.engine == "mysql"
52
+ assert dbc.host == "order-db.test.com"
53
+ assert dbc.port == 3306
54
+ finally:
55
+ del os.environ["DB_ORDER__ENGINE"]
56
+ del os.environ["DB_ORDER__HOST"]
@@ -0,0 +1,108 @@
1
+ """Database 单元测试(使用 SQLite,无需外部数据库)。"""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from api_test_kit.db.config import DatabaseConfig
7
+ from api_test_kit.db.database import Database
8
+
9
+
10
+ @pytest.fixture
11
+ def db() -> Database:
12
+ """创建内存 SQLite 数据库并初始化测试表。"""
13
+ config = DatabaseConfig.sqlite_in_memory()
14
+ database = Database(config)
15
+ database.execute(
16
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
17
+ )
18
+ database.execute(
19
+ "INSERT INTO users (name, email) VALUES (:name, :email)",
20
+ {"name": "Alice", "email": "alice@test.com"},
21
+ )
22
+ database.execute(
23
+ "INSERT INTO users (name, email) VALUES (:name, :email)",
24
+ {"name": "Bob", "email": "bob@test.com"},
25
+ )
26
+ return database
27
+
28
+
29
+ class TestDatabase:
30
+ """Database — 数据库操作封装。"""
31
+
32
+ def test_query_returns_dicts(self, db: Database) -> None:
33
+ rows = db.query("SELECT * FROM users ORDER BY id")
34
+ assert len(rows) == 2
35
+ assert rows[0]["name"] == "Alice"
36
+ assert rows[0]["email"] == "alice@test.com"
37
+
38
+ def test_query_with_params(self, db: Database) -> None:
39
+ rows = db.query(
40
+ "SELECT * FROM users WHERE name = :name",
41
+ {"name": "Bob"},
42
+ )
43
+ assert len(rows) == 1
44
+ assert rows[0]["email"] == "bob@test.com"
45
+
46
+ def test_execute_insert(self, db: Database) -> None:
47
+ affected = db.execute(
48
+ "INSERT INTO users (name, email) VALUES (:name, :email)",
49
+ {"name": "Charlie", "email": "charlie@test.com"},
50
+ )
51
+ assert affected == 1
52
+ rows = db.query("SELECT * FROM users")
53
+ assert len(rows) == 3
54
+
55
+ def test_execute_update(self, db: Database) -> None:
56
+ affected = db.execute(
57
+ "UPDATE users SET email = :email WHERE name = :name",
58
+ {"email": "alice@new.com", "name": "Alice"},
59
+ )
60
+ assert affected == 1
61
+ row = db.query("SELECT * FROM users WHERE name = 'Alice'")
62
+ assert row[0]["email"] == "alice@new.com"
63
+
64
+ def test_execute_delete(self, db: Database) -> None:
65
+ affected = db.execute("DELETE FROM users WHERE name = :name", {"name": "Alice"})
66
+ assert affected == 1
67
+ rows = db.query("SELECT * FROM users")
68
+ assert len(rows) == 1
69
+
70
+ def test_extract_column_value(self, db: Database) -> None:
71
+ val = db.extract(
72
+ "SELECT email FROM users WHERE name = :name",
73
+ "email",
74
+ {"name": "Alice"},
75
+ )
76
+ assert val == "alice@test.com"
77
+
78
+ def test_extract_returns_none_for_missing(self, db: Database) -> None:
79
+ val = db.extract(
80
+ "SELECT email FROM users WHERE name = :name",
81
+ "email",
82
+ {"name": "Nonexistent"},
83
+ )
84
+ assert val is None
85
+
86
+ def test_context_manager(self) -> None:
87
+ config = DatabaseConfig.sqlite_in_memory()
88
+ with Database(config) as db:
89
+ db.execute("CREATE TABLE t (x INTEGER)")
90
+ db.execute("INSERT INTO t VALUES (42)")
91
+ rows = db.query("SELECT * FROM t")
92
+ assert rows[0]["x"] == 42
93
+ # 退出上下文后连接应关闭
94
+ assert db._conn is None
95
+
96
+ def test_config_sqlite_in_memory(self) -> None:
97
+ config = DatabaseConfig.sqlite_in_memory("testdb")
98
+ assert config.name == "testdb"
99
+ assert config.engine == "sqlite"
100
+ assert config.database == ":memory:"
101
+ assert config.is_sqlite is True
102
+
103
+ def test_config_mysql_defaults(self) -> None:
104
+ config = DatabaseConfig(name="order", engine="mysql", host="db.example.com")
105
+ assert config.host == "db.example.com"
106
+ assert config.port == 3306
107
+ assert config.user == "root"
108
+ assert config.is_sqlite is False
@@ -1,82 +0,0 @@
1
- """通过 Pydantic Settings 实现的多环境配置。
2
-
3
- 使用方法::
4
-
5
- config = HarnessConfig.from_env("staging")
6
- config.base_url # -> "https://staging-api.example.com"
7
- """
8
- from __future__ import annotations
9
-
10
- from pathlib import Path
11
- from typing import Literal
12
-
13
- from pydantic import Field
14
- from pydantic_settings import BaseSettings, SettingsConfigDict
15
-
16
-
17
- class HarnessConfig(BaseSettings):
18
- """从环境文件或环境变量加载的应用配置。
19
-
20
- 优先级:环境变量 > 环境文件 > 默认值。
21
- """
22
-
23
- model_config = SettingsConfigDict(
24
- env_file=".env",
25
- env_file_encoding="utf-8",
26
- extra="ignore",
27
- frozen=True,
28
- )
29
-
30
- # --- 核心 ---
31
- env_name: str = Field(default="dev", description="Environment name (dev/staging/prod)")
32
- base_url: str = Field(default="http://localhost:8000", description="API base URL")
33
- api_prefix: str = Field(default="/api/v1", description="API version prefix")
34
-
35
- # --- 认证 ---
36
- auth_type: Literal["none", "bearer", "api_key", "basic", "oauth2"] = Field(
37
- default="bearer",
38
- )
39
- auth_header_name: str = Field(
40
- default="authorization",
41
- description="HTTP header name for the auth token "
42
- "(default: 'authorization' for bearer, 'x-api-key' for api_key)",
43
- )
44
- auth_token_url: str | None = Field(default=None, description="Token endpoint for OAuth2")
45
- client_id: str | None = Field(default=None)
46
- client_secret: str | None = Field(default=None, description="Also used as bearer token value")
47
-
48
- # --- HTTP 客户端 ---
49
- timeout: int = Field(default=30, ge=1, le=300)
50
- max_retries: int = Field(default=3, ge=0, le=10)
51
- retry_backoff: float = Field(default=1.0, ge=0.1)
52
-
53
- # --- 日志 ---
54
- log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR)$")
55
- log_requests: bool = Field(default=True)
56
- log_responses: bool = Field(default=True)
57
-
58
- # --- 派生 ---
59
- @property
60
- def api_url(self) -> str:
61
- """完整的 API 基础 URL(base_url + api_prefix)。"""
62
- return f"{self.base_url}{self.api_prefix}"
63
-
64
- @classmethod
65
- def from_env(cls, env_name: str = "dev") -> HarnessConfig:
66
- """加载指定环境的配置。
67
-
68
- 查找项目根目录下的 ``config.{env_name}.env``,
69
- 如果不存在则回退到 ``.env``。
70
- """
71
- project_root = cls._find_project_root()
72
- env_file = project_root / f"config.{env_name}.env"
73
- return cls(_env_file=str(env_file) if env_file.exists() else None)
74
-
75
- @staticmethod
76
- def _find_project_root() -> Path:
77
- """从当前工作目录向上查找项目根目录(包含 pyproject.toml 的目录)。"""
78
- start = Path.cwd()
79
- for parent in [start] + list(start.parents):
80
- if (parent / "pyproject.toml").exists():
81
- return parent
82
- return start