sefrone-api-e2e 1.3.8__tar.gz → 1.4.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.
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/PKG-INFO +1 -1
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/__init__.py +4 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/4_workspace_endpoints.yaml +2 -23
- sefrone_api_e2e-1.4.0/sefrone_api_e2e/runner_mysql.py +247 -0
- sefrone_api_e2e-1.4.0/sefrone_api_e2e/runner_postgresql.py +220 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/PKG-INFO +1 -1
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/SOURCES.txt +2 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/setup.py +1 -1
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/README.md +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/api_e2e_manager.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/0_check_features.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/1_normal_auth_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/2_account_endpoints.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/3_admin_account_endpoints.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/5_apikey_endpoints.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/6_project_endpoints.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/7_email_2fa_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/8_temporary_passwords_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/9_project_account_access_control_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Oidc/1_relying_party_admin.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Oidc/2_authorization_code_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Oidc/3_disable_propagation_flow.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Oidc/4_multi_project_isolation.yaml +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e_scenarios_manager.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/env_patch.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/json_mock_server.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_assets.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_csv.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_email_mock.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_excel.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_rest_api.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_satim_mock.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_webhook_mock.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/step_runner.py +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/dependency_links.txt +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/requires.txt +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/top_level.txt +0 -0
- {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/setup.cfg +0 -0
|
@@ -7,6 +7,8 @@ from .runner_assets import AssetsRunner
|
|
|
7
7
|
from .runner_satim_mock import SatimMockRunner
|
|
8
8
|
from .runner_csv import CsvRunner
|
|
9
9
|
from .runner_excel import ExcelRunner
|
|
10
|
+
from .runner_postgresql import PostgresqlRunner
|
|
11
|
+
from .runner_mysql import MysqlRunner
|
|
10
12
|
from .json_mock_server import JsonMockServer
|
|
11
13
|
from .e2e_scenarios_manager import E2EScenariosManager
|
|
12
14
|
|
|
@@ -23,6 +25,8 @@ __all__ = [
|
|
|
23
25
|
"SatimMockRunner",
|
|
24
26
|
"CsvRunner",
|
|
25
27
|
"ExcelRunner",
|
|
28
|
+
"PostgresqlRunner",
|
|
29
|
+
"MysqlRunner",
|
|
26
30
|
"JsonMockServer",
|
|
27
31
|
"E2EScenariosManager",
|
|
28
32
|
]
|
|
@@ -19,36 +19,15 @@ steps:
|
|
|
19
19
|
body:
|
|
20
20
|
response:
|
|
21
21
|
token: string
|
|
22
|
+
projectId: string
|
|
22
23
|
save:
|
|
23
24
|
admin_token: "{{body.response.token}}"
|
|
25
|
+
seed_project_id: "{{body.response.projectId}}"
|
|
24
26
|
|
|
25
27
|
- name: "Generate unique test suffix"
|
|
26
28
|
randomize:
|
|
27
29
|
seed_account_id: 8
|
|
28
30
|
|
|
29
|
-
- name: "List workspaces to obtain project ID"
|
|
30
|
-
method: "GET"
|
|
31
|
-
endpoint: "/api/v1/apiAuth/workspace/$var(AUTH_PROJECT_NAME)/list"
|
|
32
|
-
request_headers:
|
|
33
|
-
- x-api-token: "{$stored.admin_token}"
|
|
34
|
-
expect:
|
|
35
|
-
status: 200
|
|
36
|
-
body:
|
|
37
|
-
errorCode: number
|
|
38
|
-
errorCodeStr: string
|
|
39
|
-
message: string
|
|
40
|
-
totalCount: number
|
|
41
|
-
response:
|
|
42
|
-
- id: string
|
|
43
|
-
projectId: string
|
|
44
|
-
name: string
|
|
45
|
-
description: string
|
|
46
|
-
isActive: boolean
|
|
47
|
-
save:
|
|
48
|
-
seed_project_id: "{{body.response[0].projectId}}"
|
|
49
|
-
assertions:
|
|
50
|
-
- "{{body.totalCount}} >= 1"
|
|
51
|
-
|
|
52
31
|
- name: "Create new workspace"
|
|
53
32
|
method: "POST"
|
|
54
33
|
endpoint: "/api/v1/apiAuth/workspace"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from sefrone_api_e2e import StepRunner, RunContext, StepResult
|
|
5
|
+
|
|
6
|
+
# mysql-connector-python is an optional, bootstrap-installed dependency — only pulled
|
|
7
|
+
# in by setup() below, so importing this module (e.g. via package __init__) never
|
|
8
|
+
# installs it for consumers who never register MysqlRunner.
|
|
9
|
+
mysql = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MysqlRunner(StepRunner):
|
|
13
|
+
"""
|
|
14
|
+
Runner for direct SQL access to the e2e MySQL database, for state a scenario can't reach or
|
|
15
|
+
observe through the HTTP API alone (fast-forwarding a due-date column, asserting a background
|
|
16
|
+
job wrote back the row it was supposed to, seeding data too tedious to build through endpoints).
|
|
17
|
+
|
|
18
|
+
Takes a ready-made MySqlConnector/Pomelo-style connection string via the constructor —
|
|
19
|
+
typically the caller's own SCAFFOLD_DB_CONNECTION_STRING value from the e2e .env file, the same
|
|
20
|
+
host-reachable connection string the EF Core scaffold tool uses for repos on
|
|
21
|
+
Pomelo.EntityFrameworkCore.MySql, already pointed at the docker-exposed port (see
|
|
22
|
+
DB_IMAGE_PORT_MAPPING in compose-template/.env). Nothing needs to be patched or injected into
|
|
23
|
+
docker-compose.yml; the database is already reachable from the host once DB_SERVICE is up.
|
|
24
|
+
|
|
25
|
+
Step syntax — execute (INSERT/UPDATE/DELETE, no result rows)::
|
|
26
|
+
|
|
27
|
+
- name: "Fast-forward the no-product reminder due date"
|
|
28
|
+
runner: mysql
|
|
29
|
+
mysql:
|
|
30
|
+
action: execute
|
|
31
|
+
statement: >
|
|
32
|
+
UPDATE store_reminder SET remind_at = NOW() - INTERVAL 1 MINUTE
|
|
33
|
+
WHERE store_id = '{$stored.store_id}' AND type = 'no_product'
|
|
34
|
+
expect_rows_affected: 1 # optional — fails the step if rowcount doesn't match
|
|
35
|
+
|
|
36
|
+
Step syntax — query (SELECT, populates {{mysql.*}} for assertions/save)::
|
|
37
|
+
|
|
38
|
+
- name: "Verify the reminder was marked sent"
|
|
39
|
+
runner: mysql
|
|
40
|
+
mysql:
|
|
41
|
+
action: query
|
|
42
|
+
statement: >
|
|
43
|
+
SELECT remind_at, sent_at FROM store_reminder
|
|
44
|
+
WHERE store_id = '{$stored.store_id}' AND type = 'no_product'
|
|
45
|
+
assertions:
|
|
46
|
+
- "{{mysql.row_count}} == 1"
|
|
47
|
+
- "{{mysql.rows[0].sent_at}} != None"
|
|
48
|
+
save:
|
|
49
|
+
reminder_sent_at: "{{mysql.rows[0].sent_at}}"
|
|
50
|
+
|
|
51
|
+
``{$stored...}`` / ``{{...}}`` placeholders in ``statement`` are substituted before the
|
|
52
|
+
statement reaches MySQL — quote substituted values yourself (as in the examples above) the
|
|
53
|
+
same way you would in an HTTP step's body.
|
|
54
|
+
|
|
55
|
+
``mysql.rows`` is a list of dicts keyed by column name; timestamps/decimals/etc. come back as
|
|
56
|
+
their native Python types (via mysql-connector-python), matching how the driver would hand them
|
|
57
|
+
to any other caller — stringify explicitly in an assertion/save expression if you need string
|
|
58
|
+
comparison.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, connection_string: str):
|
|
62
|
+
if not connection_string or not connection_string.strip():
|
|
63
|
+
raise ValueError("MysqlRunner requires a non-empty connection_string")
|
|
64
|
+
self._connect_kwargs = self._parse_connection_string(connection_string)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return "mysql"
|
|
69
|
+
|
|
70
|
+
def handles(self, step: dict) -> bool:
|
|
71
|
+
return "mysql" in step and "method" not in step
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------ #
|
|
74
|
+
# Lifecycle
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
76
|
+
|
|
77
|
+
def setup(self, env_file_path: str) -> None:
|
|
78
|
+
global mysql
|
|
79
|
+
from sefrone_bootstrap.bootstrap_package import PackageManager
|
|
80
|
+
PackageManager.setup([("mysql-connector-python", "8.4.0")])
|
|
81
|
+
import mysql.connector
|
|
82
|
+
|
|
83
|
+
self._wait_for_db_ready()
|
|
84
|
+
|
|
85
|
+
def teardown(self) -> None:
|
|
86
|
+
pass # each execute() opens and closes its own short-lived connection.
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
# Execute
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
|
|
92
|
+
def execute(self, step: dict, ctx: RunContext) -> StepResult:
|
|
93
|
+
try:
|
|
94
|
+
spec = step.get("mysql")
|
|
95
|
+
if not isinstance(spec, dict):
|
|
96
|
+
return StepResult.failed("mysql step requires a 'mysql' object")
|
|
97
|
+
|
|
98
|
+
action = str(spec.get("action", "query")).strip().lower()
|
|
99
|
+
statement = spec.get("statement")
|
|
100
|
+
if not isinstance(statement, str) or not statement.strip():
|
|
101
|
+
return StepResult.failed("mysql.statement is required and must be a non-empty string")
|
|
102
|
+
|
|
103
|
+
rendered_statement = ctx.substitute_stored(statement)
|
|
104
|
+
|
|
105
|
+
if action == "execute":
|
|
106
|
+
return self._execute(step, rendered_statement)
|
|
107
|
+
if action == "query":
|
|
108
|
+
return self._query(step, spec, rendered_statement, ctx)
|
|
109
|
+
|
|
110
|
+
return StepResult.failed(f"Unsupported mysql action: '{action}'. Supported: 'execute', 'query'")
|
|
111
|
+
except AssertionError as e:
|
|
112
|
+
return StepResult.failed(str(e))
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return StepResult.failed(str(e))
|
|
115
|
+
|
|
116
|
+
def _execute(self, step: dict, statement: str) -> StepResult:
|
|
117
|
+
conn = mysql.connector.connect(**self._connect_kwargs)
|
|
118
|
+
try:
|
|
119
|
+
conn.autocommit = True
|
|
120
|
+
cur = conn.cursor()
|
|
121
|
+
try:
|
|
122
|
+
cur.execute(statement)
|
|
123
|
+
row_count = cur.rowcount
|
|
124
|
+
finally:
|
|
125
|
+
cur.close()
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
spec = step.get("mysql", {})
|
|
130
|
+
if "expect_rows_affected" in spec:
|
|
131
|
+
expected = int(spec["expect_rows_affected"])
|
|
132
|
+
if row_count != expected:
|
|
133
|
+
return StepResult.failed(
|
|
134
|
+
f"Expected {expected} row(s) affected, got {row_count}. Statement: {statement}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
print(f"[mysql] Executed ({row_count} row(s) affected): {statement}")
|
|
138
|
+
return StepResult.passed()
|
|
139
|
+
|
|
140
|
+
def _query(self, step: dict, spec: Dict[str, Any], statement: str, ctx: RunContext) -> StepResult:
|
|
141
|
+
conn = mysql.connector.connect(**self._connect_kwargs)
|
|
142
|
+
try:
|
|
143
|
+
conn.autocommit = True
|
|
144
|
+
cur = conn.cursor(dictionary=True)
|
|
145
|
+
try:
|
|
146
|
+
cur.execute(statement)
|
|
147
|
+
rows = list(cur.fetchall())
|
|
148
|
+
finally:
|
|
149
|
+
cur.close()
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
print(f"[mysql] Queried ({len(rows)} row(s)): {statement}")
|
|
154
|
+
|
|
155
|
+
template_context: Dict[str, Any] = {
|
|
156
|
+
"mysql": {
|
|
157
|
+
"rows": rows,
|
|
158
|
+
"row_count": len(rows),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if "assertions" in step:
|
|
163
|
+
for expr in step["assertions"]:
|
|
164
|
+
try:
|
|
165
|
+
rendered_expr = ctx.substitute_stored(expr)
|
|
166
|
+
rendered = ctx.render_template(rendered_expr, template_context, add_quotes=True)
|
|
167
|
+
ctx.try_eval(rendered, rendered_expr)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{e}")
|
|
170
|
+
|
|
171
|
+
if "save" in step:
|
|
172
|
+
for key, path_expr in step["save"].items():
|
|
173
|
+
rendered_path = ctx.substitute_stored(str(path_expr))
|
|
174
|
+
rendered = ctx.render_template(rendered_path, template_context)
|
|
175
|
+
ctx.store_value(key, rendered)
|
|
176
|
+
|
|
177
|
+
return StepResult.passed()
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ #
|
|
180
|
+
# Internal helpers
|
|
181
|
+
# ------------------------------------------------------------------ #
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _parse_connection_string(raw: str) -> Dict[str, Any]:
|
|
185
|
+
"""
|
|
186
|
+
Parse a MySqlConnector/Pomelo-style 'Key=Value;Key=Value;' connection string (as produced
|
|
187
|
+
by compose-template/.env's SCAFFOLD_DB_CONNECTION_STRING) into mysql-connector-python
|
|
188
|
+
connect() kwargs. Accepts the common key aliases MySqlConnector recognizes for each field.
|
|
189
|
+
"""
|
|
190
|
+
fields: Dict[str, str] = {}
|
|
191
|
+
for part in raw.split(";"):
|
|
192
|
+
part = part.strip()
|
|
193
|
+
if not part or "=" not in part:
|
|
194
|
+
continue
|
|
195
|
+
key, _, value = part.partition("=")
|
|
196
|
+
fields[key.strip().lower()] = value.strip()
|
|
197
|
+
|
|
198
|
+
def first(*keys: str) -> Optional[str]:
|
|
199
|
+
for key in keys:
|
|
200
|
+
if key in fields:
|
|
201
|
+
return fields[key]
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
host = first("server", "host", "data source")
|
|
205
|
+
database = first("database", "initial catalog")
|
|
206
|
+
user = first("user", "uid", "user id", "username")
|
|
207
|
+
password = first("password", "pwd")
|
|
208
|
+
port = first("port")
|
|
209
|
+
|
|
210
|
+
missing = [
|
|
211
|
+
name for name, value in [("server/host", host), ("database", database), ("user/uid", user), ("password/pwd", password)]
|
|
212
|
+
if value is None
|
|
213
|
+
]
|
|
214
|
+
if missing:
|
|
215
|
+
raise RuntimeError(f"[mysql] Connection string missing expected key(s) {missing}: '{raw}'")
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"host": host,
|
|
219
|
+
"port": int(port) if port else 3306,
|
|
220
|
+
"database": database,
|
|
221
|
+
"user": user,
|
|
222
|
+
"password": password,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def _wait_for_db_ready(self, timeout_seconds: int = 30) -> None:
|
|
226
|
+
deadline = time.time() + timeout_seconds
|
|
227
|
+
last_error: Optional[Exception] = None
|
|
228
|
+
while time.time() < deadline:
|
|
229
|
+
conn = None
|
|
230
|
+
try:
|
|
231
|
+
conn = mysql.connector.connect(**self._connect_kwargs)
|
|
232
|
+
cur = conn.cursor()
|
|
233
|
+
try:
|
|
234
|
+
cur.execute("SELECT 1")
|
|
235
|
+
finally:
|
|
236
|
+
cur.close()
|
|
237
|
+
print(f"[mysql] Database ready at {self._connect_kwargs['host']}:{self._connect_kwargs['port']}")
|
|
238
|
+
return
|
|
239
|
+
except Exception as e:
|
|
240
|
+
last_error = e
|
|
241
|
+
time.sleep(1)
|
|
242
|
+
finally:
|
|
243
|
+
if conn is not None:
|
|
244
|
+
conn.close()
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"[mysql] Database did not become ready within {timeout_seconds}s: {last_error}"
|
|
247
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from sefrone_api_e2e import StepRunner, RunContext, StepResult
|
|
5
|
+
|
|
6
|
+
# psycopg2 is an optional, bootstrap-installed dependency — only pulled in by setup()
|
|
7
|
+
# below, so importing this module (e.g. via package __init__) never installs it for
|
|
8
|
+
# consumers who never register PostgresqlRunner.
|
|
9
|
+
psycopg2 = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PostgresqlRunner(StepRunner):
|
|
13
|
+
"""
|
|
14
|
+
Runner for direct SQL access to the e2e Postgres database, for state a scenario can't reach or
|
|
15
|
+
observe through the HTTP API alone (fast-forwarding a due-date column, asserting a background
|
|
16
|
+
job wrote back the row it was supposed to, seeding data too tedious to build through endpoints).
|
|
17
|
+
|
|
18
|
+
Takes a ready-made Npgsql-style connection string via the constructor — typically the caller's
|
|
19
|
+
own SCAFFOLD_DB_CONNECTION_STRING value from the e2e .env file, the same host-reachable
|
|
20
|
+
connection string the EF Core scaffold tool uses, already pointed at the docker-exposed port
|
|
21
|
+
(see DB_IMAGE_PORT_MAPPING in compose-template/.env). Nothing needs to be patched or injected
|
|
22
|
+
into docker-compose.yml; the database is already reachable from the host once DB_SERVICE is up.
|
|
23
|
+
|
|
24
|
+
Step syntax — execute (INSERT/UPDATE/DELETE, no result rows)::
|
|
25
|
+
|
|
26
|
+
- name: "Fast-forward the no-product reminder due date"
|
|
27
|
+
runner: postgresql
|
|
28
|
+
postgresql:
|
|
29
|
+
action: execute
|
|
30
|
+
statement: >
|
|
31
|
+
UPDATE store_reminder SET remind_at = NOW() - INTERVAL '1 minute'
|
|
32
|
+
WHERE store_id = '{$stored.store_id}' AND type = 'no_product'
|
|
33
|
+
expect_rows_affected: 1 # optional — fails the step if rowcount doesn't match
|
|
34
|
+
|
|
35
|
+
Step syntax — query (SELECT, populates {{postgresql.*}} for assertions/save)::
|
|
36
|
+
|
|
37
|
+
- name: "Verify the reminder was marked sent"
|
|
38
|
+
runner: postgresql
|
|
39
|
+
postgresql:
|
|
40
|
+
action: query
|
|
41
|
+
statement: >
|
|
42
|
+
SELECT remind_at, sent_at FROM store_reminder
|
|
43
|
+
WHERE store_id = '{$stored.store_id}' AND type = 'no_product'
|
|
44
|
+
assertions:
|
|
45
|
+
- "{{postgresql.row_count}} == 1"
|
|
46
|
+
- "{{postgresql.rows[0].sent_at}} != None"
|
|
47
|
+
save:
|
|
48
|
+
reminder_sent_at: "{{postgresql.rows[0].sent_at}}"
|
|
49
|
+
|
|
50
|
+
``{$stored...}`` / ``{{...}}`` placeholders in ``statement`` are substituted before the
|
|
51
|
+
statement reaches Postgres — quote substituted values yourself (as in the examples above) the
|
|
52
|
+
same way you would in an HTTP step's body.
|
|
53
|
+
|
|
54
|
+
``postgresql.rows`` is a list of dicts keyed by column name; timestamps/UUIDs/etc. come back as
|
|
55
|
+
their native Python types (via psycopg2), matching how the driver would hand them to any other
|
|
56
|
+
caller — stringify explicitly in an assertion/save expression if you need string comparison.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, connection_string: str):
|
|
60
|
+
if not connection_string or not connection_string.strip():
|
|
61
|
+
raise ValueError("PostgresqlRunner requires a non-empty connection_string")
|
|
62
|
+
self._connect_kwargs = self._parse_npgsql_connection_string(connection_string)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
return "postgresql"
|
|
67
|
+
|
|
68
|
+
def handles(self, step: dict) -> bool:
|
|
69
|
+
return "postgresql" in step and "method" not in step
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------ #
|
|
72
|
+
# Lifecycle
|
|
73
|
+
# ------------------------------------------------------------------ #
|
|
74
|
+
|
|
75
|
+
def setup(self, env_file_path: str) -> None:
|
|
76
|
+
global psycopg2
|
|
77
|
+
from sefrone_bootstrap.bootstrap_package import PackageManager
|
|
78
|
+
PackageManager.setup([("psycopg2-binary", "2.9.9")])
|
|
79
|
+
import psycopg2
|
|
80
|
+
import psycopg2.extras
|
|
81
|
+
|
|
82
|
+
self._wait_for_db_ready()
|
|
83
|
+
|
|
84
|
+
def teardown(self) -> None:
|
|
85
|
+
pass # each execute() opens and closes its own short-lived connection.
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
# Execute
|
|
89
|
+
# ------------------------------------------------------------------ #
|
|
90
|
+
|
|
91
|
+
def execute(self, step: dict, ctx: RunContext) -> StepResult:
|
|
92
|
+
try:
|
|
93
|
+
spec = step.get("postgresql")
|
|
94
|
+
if not isinstance(spec, dict):
|
|
95
|
+
return StepResult.failed("postgresql step requires a 'postgresql' object")
|
|
96
|
+
|
|
97
|
+
action = str(spec.get("action", "query")).strip().lower()
|
|
98
|
+
statement = spec.get("statement")
|
|
99
|
+
if not isinstance(statement, str) or not statement.strip():
|
|
100
|
+
return StepResult.failed("postgresql.statement is required and must be a non-empty string")
|
|
101
|
+
|
|
102
|
+
rendered_statement = ctx.substitute_stored(statement)
|
|
103
|
+
|
|
104
|
+
if action == "execute":
|
|
105
|
+
return self._execute(step, rendered_statement)
|
|
106
|
+
if action == "query":
|
|
107
|
+
return self._query(step, spec, rendered_statement, ctx)
|
|
108
|
+
|
|
109
|
+
return StepResult.failed(f"Unsupported postgresql action: '{action}'. Supported: 'execute', 'query'")
|
|
110
|
+
except AssertionError as e:
|
|
111
|
+
return StepResult.failed(str(e))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return StepResult.failed(str(e))
|
|
114
|
+
|
|
115
|
+
def _execute(self, step: dict, statement: str) -> StepResult:
|
|
116
|
+
conn = psycopg2.connect(**self._connect_kwargs)
|
|
117
|
+
try:
|
|
118
|
+
conn.autocommit = True
|
|
119
|
+
with conn.cursor() as cur:
|
|
120
|
+
cur.execute(statement)
|
|
121
|
+
row_count = cur.rowcount
|
|
122
|
+
finally:
|
|
123
|
+
conn.close()
|
|
124
|
+
|
|
125
|
+
spec = step.get("postgresql", {})
|
|
126
|
+
if "expect_rows_affected" in spec:
|
|
127
|
+
expected = int(spec["expect_rows_affected"])
|
|
128
|
+
if row_count != expected:
|
|
129
|
+
return StepResult.failed(
|
|
130
|
+
f"Expected {expected} row(s) affected, got {row_count}. Statement: {statement}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
print(f"[postgresql] Executed ({row_count} row(s) affected): {statement}")
|
|
134
|
+
return StepResult.passed()
|
|
135
|
+
|
|
136
|
+
def _query(self, step: dict, spec: Dict[str, Any], statement: str, ctx: RunContext) -> StepResult:
|
|
137
|
+
conn = psycopg2.connect(**self._connect_kwargs)
|
|
138
|
+
try:
|
|
139
|
+
conn.autocommit = True
|
|
140
|
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
141
|
+
cur.execute(statement)
|
|
142
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
143
|
+
finally:
|
|
144
|
+
conn.close()
|
|
145
|
+
|
|
146
|
+
print(f"[postgresql] Queried ({len(rows)} row(s)): {statement}")
|
|
147
|
+
|
|
148
|
+
template_context: Dict[str, Any] = {
|
|
149
|
+
"postgresql": {
|
|
150
|
+
"rows": rows,
|
|
151
|
+
"row_count": len(rows),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if "assertions" in step:
|
|
156
|
+
for expr in step["assertions"]:
|
|
157
|
+
try:
|
|
158
|
+
rendered_expr = ctx.substitute_stored(expr)
|
|
159
|
+
rendered = ctx.render_template(rendered_expr, template_context, add_quotes=True)
|
|
160
|
+
ctx.try_eval(rendered, rendered_expr)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{e}")
|
|
163
|
+
|
|
164
|
+
if "save" in step:
|
|
165
|
+
for key, path_expr in step["save"].items():
|
|
166
|
+
rendered_path = ctx.substitute_stored(str(path_expr))
|
|
167
|
+
rendered = ctx.render_template(rendered_path, template_context)
|
|
168
|
+
ctx.store_value(key, rendered)
|
|
169
|
+
|
|
170
|
+
return StepResult.passed()
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
# Internal helpers
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _parse_npgsql_connection_string(raw: str) -> Dict[str, Any]:
|
|
178
|
+
"""
|
|
179
|
+
Parse a Npgsql-style 'Key=Value;Key=Value;' connection string (as produced by
|
|
180
|
+
compose-template/.env's SCAFFOLD_DB_CONNECTION_STRING) into psycopg2 connect() kwargs.
|
|
181
|
+
"""
|
|
182
|
+
fields: Dict[str, str] = {}
|
|
183
|
+
for part in raw.split(";"):
|
|
184
|
+
part = part.strip()
|
|
185
|
+
if not part or "=" not in part:
|
|
186
|
+
continue
|
|
187
|
+
key, _, value = part.partition("=")
|
|
188
|
+
fields[key.strip().lower()] = value.strip()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
return {
|
|
192
|
+
"host": fields["host"],
|
|
193
|
+
"port": int(fields.get("port", 5432)),
|
|
194
|
+
"dbname": fields["database"],
|
|
195
|
+
"user": fields["username"],
|
|
196
|
+
"password": fields["password"],
|
|
197
|
+
}
|
|
198
|
+
except KeyError as e:
|
|
199
|
+
raise RuntimeError(f"[postgresql] Connection string missing expected key {e}: '{raw}'")
|
|
200
|
+
|
|
201
|
+
def _wait_for_db_ready(self, timeout_seconds: int = 30) -> None:
|
|
202
|
+
deadline = time.time() + timeout_seconds
|
|
203
|
+
last_error: Optional[Exception] = None
|
|
204
|
+
while time.time() < deadline:
|
|
205
|
+
conn = None
|
|
206
|
+
try:
|
|
207
|
+
conn = psycopg2.connect(**self._connect_kwargs)
|
|
208
|
+
with conn.cursor() as cur:
|
|
209
|
+
cur.execute("SELECT 1")
|
|
210
|
+
print(f"[postgresql] Database ready at {self._connect_kwargs['host']}:{self._connect_kwargs['port']}")
|
|
211
|
+
return
|
|
212
|
+
except Exception as e:
|
|
213
|
+
last_error = e
|
|
214
|
+
time.sleep(1)
|
|
215
|
+
finally:
|
|
216
|
+
if conn is not None:
|
|
217
|
+
conn.close()
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
f"[postgresql] Database did not become ready within {timeout_seconds}s: {last_error}"
|
|
220
|
+
)
|
|
@@ -9,6 +9,8 @@ sefrone_api_e2e/runner_assets.py
|
|
|
9
9
|
sefrone_api_e2e/runner_csv.py
|
|
10
10
|
sefrone_api_e2e/runner_email_mock.py
|
|
11
11
|
sefrone_api_e2e/runner_excel.py
|
|
12
|
+
sefrone_api_e2e/runner_mysql.py
|
|
13
|
+
sefrone_api_e2e/runner_postgresql.py
|
|
12
14
|
sefrone_api_e2e/runner_rest_api.py
|
|
13
15
|
sefrone_api_e2e/runner_satim_mock.py
|
|
14
16
|
sefrone_api_e2e/runner_webhook_mock.py
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="sefrone_api_e2e",
|
|
8
|
-
version="1.
|
|
8
|
+
version="1.4.0",
|
|
9
9
|
author="Sefrone",
|
|
10
10
|
author_email="contact@sefrone.com",
|
|
11
11
|
description="A Python package to provide e2e testing helpers for sefrone API projects",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|