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.
Files changed (38) hide show
  1. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/PKG-INFO +1 -1
  2. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/__init__.py +4 -0
  3. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/4_workspace_endpoints.yaml +2 -23
  4. sefrone_api_e2e-1.4.0/sefrone_api_e2e/runner_mysql.py +247 -0
  5. sefrone_api_e2e-1.4.0/sefrone_api_e2e/runner_postgresql.py +220 -0
  6. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/PKG-INFO +1 -1
  7. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/SOURCES.txt +2 -0
  8. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/setup.py +1 -1
  9. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/README.md +0 -0
  10. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/api_e2e_manager.py +0 -0
  11. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/0_check_features.yaml +0 -0
  12. {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
  13. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/2_account_endpoints.yaml +0 -0
  14. {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
  15. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/5_apikey_endpoints.yaml +0 -0
  16. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e/Scenarios/Auth/6_project_endpoints.yaml +0 -0
  17. {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
  18. {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
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/e2e_scenarios_manager.py +0 -0
  25. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/env_patch.py +0 -0
  26. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/json_mock_server.py +0 -0
  27. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_assets.py +0 -0
  28. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_csv.py +0 -0
  29. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_email_mock.py +0 -0
  30. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_excel.py +0 -0
  31. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_rest_api.py +0 -0
  32. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_satim_mock.py +0 -0
  33. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/runner_webhook_mock.py +0 -0
  34. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e/step_runner.py +0 -0
  35. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/dependency_links.txt +0 -0
  36. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/requires.txt +0 -0
  37. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/sefrone_api_e2e.egg-info/top_level.txt +0 -0
  38. {sefrone_api_e2e-1.3.8 → sefrone_api_e2e-1.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sefrone_api_e2e
3
- Version: 1.3.8
3
+ Version: 1.4.0
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sefrone_api_e2e
3
- Version: 1.3.8
3
+ Version: 1.4.0
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -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.3.8",
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",