hap-cli 0.5.0__py3-none-any.whl
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.
- hap_cli/README.md +194 -0
- hap_cli/README_CN.md +601 -0
- hap_cli/__init__.py +3 -0
- hap_cli/commands/__init__.py +1 -0
- hap_cli/commands/ai_cmd.py +224 -0
- hap_cli/commands/app_cmd.py +308 -0
- hap_cli/commands/calendar_cmd.py +138 -0
- hap_cli/commands/chat_cmd.py +101 -0
- hap_cli/commands/config_cmd.py +169 -0
- hap_cli/commands/contact_cmd.py +125 -0
- hap_cli/commands/department_cmd.py +168 -0
- hap_cli/commands/group_cmd.py +128 -0
- hap_cli/commands/instance_cmd.py +310 -0
- hap_cli/commands/node_cmd.py +538 -0
- hap_cli/commands/optionset_cmd.py +99 -0
- hap_cli/commands/page_cmd.py +102 -0
- hap_cli/commands/plugin_cmd.py +133 -0
- hap_cli/commands/post_cmd.py +155 -0
- hap_cli/commands/record_cmd.py +228 -0
- hap_cli/commands/role_cmd.py +221 -0
- hap_cli/commands/workflow_cmd.py +284 -0
- hap_cli/commands/worksheet_cmd.py +342 -0
- hap_cli/context.py +43 -0
- hap_cli/core/__init__.py +1 -0
- hap_cli/core/ai.py +133 -0
- hap_cli/core/app.py +307 -0
- hap_cli/core/auth.py +219 -0
- hap_cli/core/calendar_mod.py +114 -0
- hap_cli/core/chat.py +73 -0
- hap_cli/core/contact.py +85 -0
- hap_cli/core/department.py +131 -0
- hap_cli/core/flow_node.py +1001 -0
- hap_cli/core/group.py +99 -0
- hap_cli/core/instance.py +572 -0
- hap_cli/core/optionset.py +112 -0
- hap_cli/core/page.py +138 -0
- hap_cli/core/plugin.py +87 -0
- hap_cli/core/post.py +118 -0
- hap_cli/core/record.py +268 -0
- hap_cli/core/role.py +227 -0
- hap_cli/core/session.py +348 -0
- hap_cli/core/workflow.py +556 -0
- hap_cli/core/worksheet.py +403 -0
- hap_cli/hap_cli.py +105 -0
- hap_cli/skills/SKILL.md +383 -0
- hap_cli/skills/__init__.py +0 -0
- hap_cli/tests/__init__.py +1 -0
- hap_cli/tests/test_core.py +1824 -0
- hap_cli/tests/test_full_e2e.py +136 -0
- hap_cli/tests/test_integration.py +805 -0
- hap_cli/utils/__init__.py +1 -0
- hap_cli/utils/formatting.py +111 -0
- hap_cli/utils/options.py +10 -0
- hap_cli-0.5.0.dist-info/METADATA +223 -0
- hap_cli-0.5.0.dist-info/RECORD +58 -0
- hap_cli-0.5.0.dist-info/WHEEL +5 -0
- hap_cli-0.5.0.dist-info/entry_points.txt +2 -0
- hap_cli-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""Real integration tests for hap-cli against live HAP API.
|
|
2
|
+
|
|
3
|
+
These tests hit the real API and require a configured session.
|
|
4
|
+
Run: hap config set --server <url> --token <token>
|
|
5
|
+
|
|
6
|
+
Design:
|
|
7
|
+
- The test WORKSHEET is persistent (created once, reused across runs).
|
|
8
|
+
- Transient resources (views, buttons, records, workflows) are created and
|
|
9
|
+
cleaned up within each run.
|
|
10
|
+
- The state file persists worksheet/section IDs between runs; transient IDs
|
|
11
|
+
are removed during cleanup.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
cd agent-harness
|
|
15
|
+
python -m pytest hap_cli/tests/test_integration.py -v -s
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
# ── Paths ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
_TESTS_DIR = Path(__file__).parent
|
|
26
|
+
_CONFIG_FILE = _TESTS_DIR / "integration_config.json"
|
|
27
|
+
_STATE_FILE = _TESTS_DIR / "integration_state.json"
|
|
28
|
+
|
|
29
|
+
# ── Config ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_cfg = json.loads(_CONFIG_FILE.read_text())
|
|
32
|
+
PROJECT_ID: str = _cfg["project_id"]
|
|
33
|
+
APP_ID: str = _cfg["app_id"]
|
|
34
|
+
|
|
35
|
+
TEST_SECTION_NAME = "Integration Tests"
|
|
36
|
+
TEST_WORKSHEET_NAME = "Integration Test Worksheet"
|
|
37
|
+
TEST_WORKFLOW_NAME = "Integration Test Workflow"
|
|
38
|
+
|
|
39
|
+
# ── Imports ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
from hap_cli.core.session import Session
|
|
42
|
+
from hap_cli.core import app as app_mod
|
|
43
|
+
from hap_cli.core import worksheet as ws_mod
|
|
44
|
+
from hap_cli.core import workflow as wf_mod
|
|
45
|
+
from hap_cli.core import flow_node as node_mod
|
|
46
|
+
from hap_cli.core import record as rec_mod
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── State helpers ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _load_state() -> dict:
|
|
52
|
+
if _STATE_FILE.exists():
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(_STATE_FILE.read_text())
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _save_state(state: dict) -> None:
|
|
61
|
+
_STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Transient keys that are cleaned up at the end of each run
|
|
65
|
+
_TRANSIENT_KEYS = {"view_id", "gallery_view_id", "button_id", "row_id",
|
|
66
|
+
"process_id", "node_id", "name_control_id"}
|
|
67
|
+
|
|
68
|
+
# Persistent keys that survive cleanup (worksheet/section)
|
|
69
|
+
_PERSISTENT_KEYS = {"worksheet_id", "section_id", "controls"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cleanup_transient(session: Session, state: dict) -> None:
|
|
73
|
+
"""Clean up resources created during this run (not the worksheet itself)."""
|
|
74
|
+
errors = []
|
|
75
|
+
|
|
76
|
+
# Delete the auto-created workflow (process linked to the button)
|
|
77
|
+
if pid := state.get("process_id"):
|
|
78
|
+
try:
|
|
79
|
+
wf_mod.delete_process(session, pid)
|
|
80
|
+
print(f" [cleanup] deleted process {pid}")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
errors.append(f"process {pid}: {e}")
|
|
83
|
+
|
|
84
|
+
# Delete the button itself (SaveWorksheetBtn with isDelete=True)
|
|
85
|
+
ws_id = state.get("worksheet_id")
|
|
86
|
+
if ws_id and (btn_id := state.get("button_id")):
|
|
87
|
+
try:
|
|
88
|
+
ws_mod.delete_button(session, ws_id, btn_id)
|
|
89
|
+
print(f" [cleanup] deleted button {btn_id}")
|
|
90
|
+
except Exception as e:
|
|
91
|
+
errors.append(f"button {btn_id}: {e}")
|
|
92
|
+
|
|
93
|
+
if errors:
|
|
94
|
+
print(f" [cleanup] non-fatal warnings: {errors}")
|
|
95
|
+
|
|
96
|
+
# Remove transient keys from state, keep persistent ones
|
|
97
|
+
for key in list(state.keys()):
|
|
98
|
+
if key in _TRANSIENT_KEYS:
|
|
99
|
+
del state[key]
|
|
100
|
+
_save_state(state)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── Section / Worksheet helpers ────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def _get_or_create_section(session: Session, state: dict) -> str:
|
|
106
|
+
"""Return a section ID for testing, creating one if needed."""
|
|
107
|
+
# Check saved state first
|
|
108
|
+
if sid := state.get("section_id"):
|
|
109
|
+
return sid
|
|
110
|
+
|
|
111
|
+
# Fetch app info to find existing sections
|
|
112
|
+
info = app_mod.get_app_info(session, APP_ID)
|
|
113
|
+
sections = info.get("sections", [])
|
|
114
|
+
for sec in sections:
|
|
115
|
+
if sec.get("name") == TEST_SECTION_NAME:
|
|
116
|
+
sid = sec.get("id") or sec.get("sectionId")
|
|
117
|
+
state["section_id"] = sid
|
|
118
|
+
_save_state(state)
|
|
119
|
+
print(f" Found existing section: {sid}")
|
|
120
|
+
return sid
|
|
121
|
+
|
|
122
|
+
# Create a new section
|
|
123
|
+
result = session.api_call("HomeApp", "AddAppSection", {
|
|
124
|
+
"appId": APP_ID,
|
|
125
|
+
"name": TEST_SECTION_NAME,
|
|
126
|
+
})
|
|
127
|
+
# Response: {'code': 1, 'data': '<section_id>'}
|
|
128
|
+
sid = (result.get("data") if isinstance(result, dict) else str(result))
|
|
129
|
+
assert sid, f"Could not create section: {result}"
|
|
130
|
+
state["section_id"] = sid
|
|
131
|
+
_save_state(state)
|
|
132
|
+
print(f" Created section: {sid}")
|
|
133
|
+
return sid
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_or_create_worksheet(session: Session, state: dict, section_id: str) -> str:
|
|
137
|
+
"""Return worksheet ID, reusing an existing one if available."""
|
|
138
|
+
# Reuse saved worksheet if it still exists
|
|
139
|
+
if ws_id := state.get("worksheet_id"):
|
|
140
|
+
try:
|
|
141
|
+
ws_mod.get_worksheet_info(session, ws_id, app_id=APP_ID)
|
|
142
|
+
print(f" Reusing existing worksheet: {ws_id}")
|
|
143
|
+
return ws_id
|
|
144
|
+
except Exception:
|
|
145
|
+
print(f" Saved worksheet {ws_id} no longer exists — will create new")
|
|
146
|
+
state.pop("worksheet_id", None)
|
|
147
|
+
state.pop("controls", None)
|
|
148
|
+
|
|
149
|
+
# Create worksheet
|
|
150
|
+
result = ws_mod.create_worksheet(
|
|
151
|
+
session,
|
|
152
|
+
app_id=APP_ID,
|
|
153
|
+
name=TEST_WORKSHEET_NAME,
|
|
154
|
+
section_id=section_id,
|
|
155
|
+
)
|
|
156
|
+
assert result, f"create_worksheet returned empty: {result}"
|
|
157
|
+
|
|
158
|
+
ws_id = None
|
|
159
|
+
if isinstance(result, dict):
|
|
160
|
+
ws_id = (result.get("workSheetId") or result.get("worksheetId")
|
|
161
|
+
or result.get("id") or result.get("data"))
|
|
162
|
+
if isinstance(ws_id, dict):
|
|
163
|
+
ws_id = ws_id.get("workSheetId") or ws_id.get("worksheetId")
|
|
164
|
+
if not ws_id:
|
|
165
|
+
ws_id = str(result)
|
|
166
|
+
|
|
167
|
+
assert ws_id, f"Could not extract worksheetId from: {result}"
|
|
168
|
+
state["worksheet_id"] = ws_id
|
|
169
|
+
_save_state(state)
|
|
170
|
+
print(f" Created worksheet: {ws_id}")
|
|
171
|
+
return ws_id
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Fixtures ───────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
@pytest.fixture(scope="module")
|
|
177
|
+
def session():
|
|
178
|
+
s = Session.load()
|
|
179
|
+
if not s.is_configured():
|
|
180
|
+
pytest.skip(
|
|
181
|
+
"HAP session not configured. Run: hap config set --server <url> --token <token>"
|
|
182
|
+
)
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.fixture(scope="module")
|
|
187
|
+
def state():
|
|
188
|
+
"""Module-wide mutable state dict, pre-loaded from state file."""
|
|
189
|
+
return _load_state()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Tests: Setup ───────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
class TestSetup:
|
|
195
|
+
|
|
196
|
+
def test_00_cleanup_previous_run(self, session, state):
|
|
197
|
+
"""Clean up only transient resources from the previous run."""
|
|
198
|
+
transient_found = any(k in state for k in _TRANSIENT_KEYS)
|
|
199
|
+
if transient_found:
|
|
200
|
+
print(f"\n Cleaning transient keys from previous run: "
|
|
201
|
+
f"{[k for k in _TRANSIENT_KEYS if k in state]}")
|
|
202
|
+
_cleanup_transient(session, state)
|
|
203
|
+
else:
|
|
204
|
+
print("\n No transient state found — clean start.")
|
|
205
|
+
|
|
206
|
+
def test_01_verify_app_access(self, session, state):
|
|
207
|
+
"""Verify connectivity to the target application."""
|
|
208
|
+
result = app_mod.get_app_info(session, APP_ID)
|
|
209
|
+
assert result, "get_app_info returned empty"
|
|
210
|
+
name = result.get("name", "(unknown)")
|
|
211
|
+
print(f"\n App: {name} (id={APP_ID})")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── Tests: Worksheet ───────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
class TestWorksheet:
|
|
217
|
+
|
|
218
|
+
def test_10_get_or_create_worksheet(self, session, state):
|
|
219
|
+
"""Ensure test worksheet exists (create if needed)."""
|
|
220
|
+
section_id = _get_or_create_section(session, state)
|
|
221
|
+
ws_id = _get_or_create_worksheet(session, state, section_id)
|
|
222
|
+
assert ws_id
|
|
223
|
+
print(f"\n Worksheet ready: {ws_id}")
|
|
224
|
+
|
|
225
|
+
def _fetch_and_store_controls(self, session, state) -> list[dict]:
|
|
226
|
+
"""Fetch controls from server and store full details in state.
|
|
227
|
+
|
|
228
|
+
Deduplicates by controlName, keeping the first occurrence of each name.
|
|
229
|
+
"""
|
|
230
|
+
ws_id = state["worksheet_id"]
|
|
231
|
+
controls = ws_mod.get_worksheet_controls(session, ws_id)
|
|
232
|
+
|
|
233
|
+
seen_names: set[str] = set()
|
|
234
|
+
deduplicated = []
|
|
235
|
+
for c in controls:
|
|
236
|
+
cid = c.get("controlId")
|
|
237
|
+
name = c.get("controlName", "")
|
|
238
|
+
if not cid:
|
|
239
|
+
continue
|
|
240
|
+
if name in seen_names:
|
|
241
|
+
continue # skip duplicate field names
|
|
242
|
+
seen_names.add(name)
|
|
243
|
+
deduplicated.append({
|
|
244
|
+
"controlId": cid,
|
|
245
|
+
"name": name,
|
|
246
|
+
"type": c.get("type", 2),
|
|
247
|
+
"dot": c.get("dot", 0),
|
|
248
|
+
"options": c.get("options", []),
|
|
249
|
+
"attribute": c.get("attribute", 0),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
state["controls"] = deduplicated
|
|
253
|
+
_save_state(state)
|
|
254
|
+
return deduplicated
|
|
255
|
+
|
|
256
|
+
def test_11_add_controls(self, session, state):
|
|
257
|
+
"""Add TEXT, NUMBER, DROP_DOWN fields if not already present."""
|
|
258
|
+
ws_id = state.get("worksheet_id")
|
|
259
|
+
assert ws_id, "worksheet_id not set"
|
|
260
|
+
|
|
261
|
+
# Check what already exists on the server
|
|
262
|
+
existing = ws_mod.get_worksheet_controls(session, ws_id)
|
|
263
|
+
existing_names = {c.get("controlName") for c in existing}
|
|
264
|
+
needed = {"Name", "Amount", "Status"}
|
|
265
|
+
if needed.issubset(existing_names):
|
|
266
|
+
print(f"\n Controls already exist — skipping creation")
|
|
267
|
+
self._fetch_and_store_controls(session, state)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
controls = [
|
|
271
|
+
{"controlName": "Name", "type": ws_mod.FIELD_TYPES["TEXT"]},
|
|
272
|
+
{"controlName": "Amount", "type": ws_mod.FIELD_TYPES["NUMBER"]},
|
|
273
|
+
{
|
|
274
|
+
"controlName": "Status",
|
|
275
|
+
"type": ws_mod.FIELD_TYPES["DROP_DOWN"],
|
|
276
|
+
"options": [
|
|
277
|
+
{"key": "opt1", "value": "Pending", "color": "#2196f3"},
|
|
278
|
+
{"key": "opt2", "value": "Done", "color": "#4caf50"},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
result = ws_mod.add_controls(session, ws_id, controls)
|
|
283
|
+
print(f"\n add_controls raw result: {result}")
|
|
284
|
+
|
|
285
|
+
# Always fetch from server after adding to get full details with types
|
|
286
|
+
self._fetch_and_store_controls(session, state)
|
|
287
|
+
print(f" Stored {len(state['controls'])} controls with full metadata")
|
|
288
|
+
|
|
289
|
+
def test_12_get_worksheet_info(self, session, state):
|
|
290
|
+
"""Retrieve worksheet info and print controls."""
|
|
291
|
+
ws_id = state.get("worksheet_id")
|
|
292
|
+
assert ws_id, "worksheet_id not set"
|
|
293
|
+
|
|
294
|
+
info = ws_mod.get_worksheet_info(session, ws_id, app_id=APP_ID, get_views=True)
|
|
295
|
+
assert info, f"get_worksheet_info returned empty"
|
|
296
|
+
|
|
297
|
+
# Use GetWorksheetControls for accurate control list
|
|
298
|
+
controls_full = self._fetch_and_store_controls(session, state)
|
|
299
|
+
print(f"\n Controls ({len(controls_full)}): "
|
|
300
|
+
f"{[c.get('name') for c in controls_full]}")
|
|
301
|
+
|
|
302
|
+
def test_13_update_worksheet_desc(self, session, state):
|
|
303
|
+
"""Update the worksheet description."""
|
|
304
|
+
ws_id = state.get("worksheet_id")
|
|
305
|
+
assert ws_id, "worksheet_id not set"
|
|
306
|
+
|
|
307
|
+
result = ws_mod.update_worksheet(
|
|
308
|
+
session, ws_id,
|
|
309
|
+
desc="Created by integration test — safe to delete",
|
|
310
|
+
app_id=APP_ID,
|
|
311
|
+
)
|
|
312
|
+
print(f"\n update_worksheet_desc: {result}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Tests: Views ───────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
class TestView:
|
|
318
|
+
|
|
319
|
+
def test_20_create_grid_view(self, session, state):
|
|
320
|
+
"""Create a grid view (viewType=0)."""
|
|
321
|
+
ws_id = state.get("worksheet_id")
|
|
322
|
+
assert ws_id, "worksheet_id not set"
|
|
323
|
+
|
|
324
|
+
result = ws_mod.save_view(
|
|
325
|
+
session, worksheet_id=ws_id,
|
|
326
|
+
name="Integration Grid View", view_type=0, app_id=APP_ID,
|
|
327
|
+
)
|
|
328
|
+
assert result, f"save_view returned empty: {result}"
|
|
329
|
+
view_id = result.get("viewId") if isinstance(result, dict) else None
|
|
330
|
+
assert view_id, f"No viewId in: {result}"
|
|
331
|
+
state["view_id"] = view_id
|
|
332
|
+
_save_state(state)
|
|
333
|
+
print(f"\n Created grid view: {view_id}")
|
|
334
|
+
|
|
335
|
+
def test_21_create_gallery_view(self, session, state):
|
|
336
|
+
"""Create a gallery view (viewType=2)."""
|
|
337
|
+
ws_id = state.get("worksheet_id")
|
|
338
|
+
assert ws_id, "worksheet_id not set"
|
|
339
|
+
|
|
340
|
+
result = ws_mod.save_view(
|
|
341
|
+
session, worksheet_id=ws_id,
|
|
342
|
+
name="Integration Gallery View", view_type=2, app_id=APP_ID,
|
|
343
|
+
)
|
|
344
|
+
assert result, f"save_view returned empty: {result}"
|
|
345
|
+
view_id = result.get("viewId") if isinstance(result, dict) else None
|
|
346
|
+
state["gallery_view_id"] = view_id
|
|
347
|
+
_save_state(state)
|
|
348
|
+
print(f"\n Created gallery view: {view_id}")
|
|
349
|
+
|
|
350
|
+
def test_22_list_views(self, session, state):
|
|
351
|
+
"""List views and verify at least 2 exist."""
|
|
352
|
+
ws_id = state.get("worksheet_id")
|
|
353
|
+
assert ws_id, "worksheet_id not set"
|
|
354
|
+
|
|
355
|
+
views = ws_mod.get_worksheet_views(session, ws_id, app_id=APP_ID)
|
|
356
|
+
assert isinstance(views, list), f"Expected list, got: {type(views)}"
|
|
357
|
+
print(f"\n Views ({len(views)}):")
|
|
358
|
+
for v in views:
|
|
359
|
+
print(f" - {v.get('name')} type={v.get('viewType')} id={v.get('viewId')}")
|
|
360
|
+
assert len(views) >= 2, f"Expected ≥ 2 views, got {len(views)}"
|
|
361
|
+
|
|
362
|
+
def test_23_delete_gallery_view(self, session, state):
|
|
363
|
+
"""Delete the gallery view."""
|
|
364
|
+
ws_id = state.get("worksheet_id")
|
|
365
|
+
gvid = state.get("gallery_view_id")
|
|
366
|
+
if not gvid:
|
|
367
|
+
pytest.skip("gallery_view_id not set")
|
|
368
|
+
|
|
369
|
+
result = ws_mod.delete_view(session, ws_id, gvid, app_id=APP_ID)
|
|
370
|
+
print(f"\n delete_view: {result}")
|
|
371
|
+
state.pop("gallery_view_id", None)
|
|
372
|
+
_save_state(state)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── Tests: Business Rules ──────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
class TestBusinessRule:
|
|
378
|
+
|
|
379
|
+
def _field_meta(self, session, state, name: str) -> dict | None:
|
|
380
|
+
"""Return full control metadata dict for field with given name."""
|
|
381
|
+
for c in state.get("controls", []):
|
|
382
|
+
if c.get("name") == name:
|
|
383
|
+
return c
|
|
384
|
+
# fallback: fetch from server
|
|
385
|
+
ws_id = state.get("worksheet_id")
|
|
386
|
+
if ws_id:
|
|
387
|
+
controls = ws_mod.get_worksheet_controls(session, ws_id)
|
|
388
|
+
for c in controls:
|
|
389
|
+
if c.get("controlName") == name:
|
|
390
|
+
return {
|
|
391
|
+
"controlId": c["controlId"],
|
|
392
|
+
"name": c.get("controlName", ""),
|
|
393
|
+
"type": c.get("type", 2),
|
|
394
|
+
"dot": c.get("dot", 0),
|
|
395
|
+
"options": c.get("options", []),
|
|
396
|
+
}
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
def test_30_get_control_rules(self, session, state):
|
|
400
|
+
"""Retrieve existing control rules."""
|
|
401
|
+
ws_id = state.get("worksheet_id")
|
|
402
|
+
assert ws_id, "worksheet_id not set"
|
|
403
|
+
|
|
404
|
+
result = ws_mod.get_control_rules(session, ws_id)
|
|
405
|
+
print(f"\n get_control_rules: {result}")
|
|
406
|
+
|
|
407
|
+
def test_31_save_control_rule(self, session, state):
|
|
408
|
+
"""Save a visibility rule: show Amount only when Status=Pending."""
|
|
409
|
+
ws_id = state.get("worksheet_id")
|
|
410
|
+
assert ws_id, "worksheet_id not set"
|
|
411
|
+
|
|
412
|
+
amount_meta = self._field_meta(session, state, "Amount")
|
|
413
|
+
status_meta = self._field_meta(session, state, "Status")
|
|
414
|
+
|
|
415
|
+
if not amount_meta or not status_meta:
|
|
416
|
+
pytest.skip("Could not resolve Amount/Status control metadata")
|
|
417
|
+
|
|
418
|
+
# Get the first option key of Status for the filter value
|
|
419
|
+
status_opts = status_meta.get("options", [])
|
|
420
|
+
pending_key = status_opts[0].get("key", "opt1") if status_opts else "opt1"
|
|
421
|
+
|
|
422
|
+
rule = {
|
|
423
|
+
"ruleId": "",
|
|
424
|
+
"ruleName": "Show Amount when Pending",
|
|
425
|
+
"disabled": False,
|
|
426
|
+
"type": 1,
|
|
427
|
+
"filters": [{
|
|
428
|
+
"controlId": status_meta["controlId"],
|
|
429
|
+
"dataType": status_meta["type"],
|
|
430
|
+
"spliceType": 1,
|
|
431
|
+
"filterType": 2,
|
|
432
|
+
"values": [pending_key],
|
|
433
|
+
}],
|
|
434
|
+
"actions": [{"type": 1, "controlIds": [amount_meta["controlId"]]}],
|
|
435
|
+
}
|
|
436
|
+
result = ws_mod.save_control_rule(session, ws_id, [rule])
|
|
437
|
+
print(f"\n save_control_rule: {result}")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ── Tests: Custom Buttons ──────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
class TestCustomButton:
|
|
443
|
+
|
|
444
|
+
def test_40_get_buttons(self, session, state):
|
|
445
|
+
"""List existing custom buttons."""
|
|
446
|
+
ws_id = state.get("worksheet_id")
|
|
447
|
+
assert ws_id, "worksheet_id not set"
|
|
448
|
+
|
|
449
|
+
result = ws_mod.get_buttons(session, ws_id)
|
|
450
|
+
print(f"\n get_buttons: {result}")
|
|
451
|
+
|
|
452
|
+
def test_41_create_button(self, session, state):
|
|
453
|
+
"""Create a custom action button (auto-creates a linked workflow)."""
|
|
454
|
+
ws_id = state.get("worksheet_id")
|
|
455
|
+
assert ws_id, "worksheet_id not set"
|
|
456
|
+
|
|
457
|
+
# Payload per SaveWorksheetBtn API spec
|
|
458
|
+
btn_data = {
|
|
459
|
+
"btnId": "",
|
|
460
|
+
"name": "Integration Test Button",
|
|
461
|
+
"filters": [],
|
|
462
|
+
"confirmMsg": "确认执行此操作吗?",
|
|
463
|
+
"sureName": "确认",
|
|
464
|
+
"cancelName": "取消",
|
|
465
|
+
"workflowId": "",
|
|
466
|
+
"desc": "",
|
|
467
|
+
"appId": APP_ID,
|
|
468
|
+
"isAllView": 1,
|
|
469
|
+
"color": "#2196f3ff",
|
|
470
|
+
"icon": "send_8",
|
|
471
|
+
"writeControls": [],
|
|
472
|
+
"addRelationControlId": "",
|
|
473
|
+
"relationControl": "",
|
|
474
|
+
"writeType": "",
|
|
475
|
+
"writeObject": "",
|
|
476
|
+
"clickType": 1,
|
|
477
|
+
"showType": 1,
|
|
478
|
+
"advancedSetting": {
|
|
479
|
+
"remarkrequired": "0",
|
|
480
|
+
"remarkname": "",
|
|
481
|
+
"tiptext": "",
|
|
482
|
+
},
|
|
483
|
+
"workflowType": 1,
|
|
484
|
+
}
|
|
485
|
+
result = ws_mod.save_button(session, ws_id, btn_data)
|
|
486
|
+
print(f"\n create button result: {result}")
|
|
487
|
+
assert result is not None, f"save_button returned None"
|
|
488
|
+
|
|
489
|
+
# Response: {"data": "<triggerId>", "state": 1}
|
|
490
|
+
# 'data' is the triggerId of the auto-created workflow
|
|
491
|
+
trigger_id = result if isinstance(result, str) else (
|
|
492
|
+
result.get("data") or result.get("btnId") or result.get("id")
|
|
493
|
+
)
|
|
494
|
+
if trigger_id:
|
|
495
|
+
state["button_id"] = trigger_id # triggerId doubles as button ID
|
|
496
|
+
_save_state(state)
|
|
497
|
+
print(f" triggerId (button_id): {trigger_id}")
|
|
498
|
+
|
|
499
|
+
# Immediately discover the linked workflow process ID
|
|
500
|
+
import requests as _req
|
|
501
|
+
_headers = {
|
|
502
|
+
"Content-Type": "application/json",
|
|
503
|
+
"Authorization": f"md_pss_id {session.auth_token}",
|
|
504
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
505
|
+
}
|
|
506
|
+
_url = (
|
|
507
|
+
f"https://api.mingdao.com/workflow/process/getProcessByTriggerId"
|
|
508
|
+
f"?appId={ws_id}&triggerId={trigger_id}"
|
|
509
|
+
)
|
|
510
|
+
_r = _req.get(_url, headers=_headers, timeout=30)
|
|
511
|
+
if _r.status_code == 200:
|
|
512
|
+
_resp = _r.json()
|
|
513
|
+
# Response: {"status": 1, "data": [{id, name, flowNodes, ...}]}
|
|
514
|
+
_inner = _resp.get("data", _resp)
|
|
515
|
+
if isinstance(_inner, list) and _inner:
|
|
516
|
+
pid = _inner[0].get("id") or _inner[0].get("processId")
|
|
517
|
+
elif isinstance(_inner, dict):
|
|
518
|
+
pid = _inner.get("id") or _inner.get("processId")
|
|
519
|
+
else:
|
|
520
|
+
pid = None
|
|
521
|
+
if pid:
|
|
522
|
+
state["process_id"] = pid
|
|
523
|
+
_save_state(state)
|
|
524
|
+
print(f" Auto-discovered process_id: {pid}")
|
|
525
|
+
else:
|
|
526
|
+
print(f" getProcessByTriggerId: could not extract process_id")
|
|
527
|
+
|
|
528
|
+
def test_42_delete_button(self, session, state):
|
|
529
|
+
"""Delete the button created above."""
|
|
530
|
+
ws_id = state.get("worksheet_id")
|
|
531
|
+
btn_id = state.get("button_id")
|
|
532
|
+
if not btn_id:
|
|
533
|
+
pytest.skip("button_id not set")
|
|
534
|
+
|
|
535
|
+
result = ws_mod.delete_button(session, ws_id, btn_id)
|
|
536
|
+
print(f"\n delete button: {result}")
|
|
537
|
+
state.pop("button_id", None)
|
|
538
|
+
_save_state(state)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ── Tests: Records ─────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
class TestRecord:
|
|
544
|
+
|
|
545
|
+
def _name_control_meta(self, session, state) -> dict | None:
|
|
546
|
+
"""Return the full control object for the 'Name' field."""
|
|
547
|
+
for c in state.get("controls", []):
|
|
548
|
+
if c.get("name") == "Name":
|
|
549
|
+
return c
|
|
550
|
+
# Fallback: fetch from server
|
|
551
|
+
ws_id = state.get("worksheet_id")
|
|
552
|
+
if ws_id:
|
|
553
|
+
controls = ws_mod.get_worksheet_controls(session, ws_id)
|
|
554
|
+
for c in controls:
|
|
555
|
+
if c.get("controlName") == "Name":
|
|
556
|
+
return {
|
|
557
|
+
"controlId": c["controlId"],
|
|
558
|
+
"name": c.get("controlName", ""),
|
|
559
|
+
"type": c.get("type", 2),
|
|
560
|
+
"dot": c.get("dot", 0),
|
|
561
|
+
}
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
def _build_receive_control(self, meta: dict, value: str) -> dict:
|
|
565
|
+
"""Build a receiveControl item with required fields for AddWorksheetRow."""
|
|
566
|
+
return {
|
|
567
|
+
"controlId": meta["controlId"],
|
|
568
|
+
"type": meta["type"],
|
|
569
|
+
"value": value,
|
|
570
|
+
"controlName": meta["name"],
|
|
571
|
+
"dot": meta.get("dot", 0),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
def test_50_create_record(self, session, state):
|
|
575
|
+
"""Create a record in the test worksheet."""
|
|
576
|
+
ws_id = state.get("worksheet_id")
|
|
577
|
+
assert ws_id, "worksheet_id not set"
|
|
578
|
+
|
|
579
|
+
name_meta = self._name_control_meta(session, state)
|
|
580
|
+
controls = (
|
|
581
|
+
[self._build_receive_control(name_meta, "Alpha Record")]
|
|
582
|
+
if name_meta else []
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
result = rec_mod.create_record(
|
|
586
|
+
session, worksheet_id=ws_id, controls=controls, trigger_workflow=False,
|
|
587
|
+
)
|
|
588
|
+
assert result, f"create_record returned empty: {result}"
|
|
589
|
+
|
|
590
|
+
# Response: {resultCode, data: {_id, rowid, ...}}
|
|
591
|
+
if isinstance(result, str):
|
|
592
|
+
row_id = result
|
|
593
|
+
else:
|
|
594
|
+
inner = result.get("data") if isinstance(result, dict) else None
|
|
595
|
+
row_id = (
|
|
596
|
+
(inner.get("rowid") or inner.get("_id") or inner.get("rowId"))
|
|
597
|
+
if isinstance(inner, dict)
|
|
598
|
+
else (result.get("rowId") or result.get("id") or result.get("rowid"))
|
|
599
|
+
)
|
|
600
|
+
assert row_id, f"Could not extract rowId from: {result}"
|
|
601
|
+
state["row_id"] = row_id
|
|
602
|
+
_save_state(state)
|
|
603
|
+
print(f"\n Created record: {row_id}")
|
|
604
|
+
|
|
605
|
+
def test_51_get_record(self, session, state):
|
|
606
|
+
"""Retrieve the created record."""
|
|
607
|
+
ws_id = state.get("worksheet_id")
|
|
608
|
+
row_id = state.get("row_id")
|
|
609
|
+
assert ws_id and row_id, "worksheet_id or row_id not set"
|
|
610
|
+
|
|
611
|
+
result = rec_mod.get_record(session, ws_id, row_id)
|
|
612
|
+
assert result, f"get_record returned empty"
|
|
613
|
+
print(f"\n Record keys: {list(result.keys()) if isinstance(result, dict) else type(result)}")
|
|
614
|
+
|
|
615
|
+
def test_52_update_record(self, session, state):
|
|
616
|
+
"""Update the record's Name field."""
|
|
617
|
+
ws_id = state.get("worksheet_id")
|
|
618
|
+
row_id = state.get("row_id")
|
|
619
|
+
assert ws_id and row_id, "worksheet_id or row_id not set"
|
|
620
|
+
|
|
621
|
+
name_meta = self._name_control_meta(session, state)
|
|
622
|
+
controls = (
|
|
623
|
+
[self._build_receive_control(name_meta, "Alpha Record (updated)")]
|
|
624
|
+
if name_meta else []
|
|
625
|
+
)
|
|
626
|
+
result = rec_mod.update_record(
|
|
627
|
+
session, worksheet_id=ws_id, row_id=row_id,
|
|
628
|
+
controls=controls, trigger_workflow=False,
|
|
629
|
+
)
|
|
630
|
+
print(f"\n update_record: {result}")
|
|
631
|
+
|
|
632
|
+
def test_53_list_records(self, session, state):
|
|
633
|
+
"""List records from the worksheet."""
|
|
634
|
+
ws_id = state.get("worksheet_id")
|
|
635
|
+
assert ws_id, "worksheet_id not set"
|
|
636
|
+
|
|
637
|
+
result = rec_mod.get_records(session, ws_id, page_size=20)
|
|
638
|
+
assert isinstance(result, dict)
|
|
639
|
+
records = result.get("data", [])
|
|
640
|
+
print(f"\n Records: count={result.get('count', '?')}, page={len(records)}")
|
|
641
|
+
assert len(records) >= 1
|
|
642
|
+
|
|
643
|
+
def test_54_delete_record(self, session, state):
|
|
644
|
+
"""Delete the created record."""
|
|
645
|
+
ws_id = state.get("worksheet_id")
|
|
646
|
+
row_id = state.get("row_id")
|
|
647
|
+
assert ws_id and row_id, "worksheet_id or row_id not set"
|
|
648
|
+
|
|
649
|
+
result = rec_mod.delete_records(session, ws_id, [row_id])
|
|
650
|
+
print(f"\n delete_records: {result}")
|
|
651
|
+
state.pop("row_id", None)
|
|
652
|
+
state.pop("name_control_id", None)
|
|
653
|
+
_save_state(state)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ── Tests: Workflow ────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class TestWorkflow:
|
|
660
|
+
"""Tests for the workflow auto-created by the custom action button.
|
|
661
|
+
|
|
662
|
+
When a button is saved via SaveWorksheetBtn, the API auto-creates a linked
|
|
663
|
+
workflow. We discover that workflow via getProcessByTriggerId and verify it.
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
def _get_process_id_from_trigger(self, session, state) -> str | None:
|
|
667
|
+
"""GET /workflow/process/getProcessByTriggerId to discover processId."""
|
|
668
|
+
trigger_id = state.get("button_id")
|
|
669
|
+
ws_id = state.get("worksheet_id")
|
|
670
|
+
if not trigger_id or not ws_id:
|
|
671
|
+
return None
|
|
672
|
+
import requests
|
|
673
|
+
headers = {
|
|
674
|
+
"Content-Type": "application/json",
|
|
675
|
+
"Authorization": f"md_pss_id {session.auth_token}",
|
|
676
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
677
|
+
}
|
|
678
|
+
url = (
|
|
679
|
+
f"https://api.mingdao.com/workflow/process/getProcessByTriggerId"
|
|
680
|
+
f"?appId={ws_id}&triggerId={trigger_id}"
|
|
681
|
+
)
|
|
682
|
+
r = requests.get(url, headers=headers, timeout=30)
|
|
683
|
+
if r.status_code == 200:
|
|
684
|
+
data = r.json()
|
|
685
|
+
return data.get("processId") or data.get("id")
|
|
686
|
+
print(f" getProcessByTriggerId: HTTP {r.status_code} {r.text[:200]}")
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
def test_60_verify_workflow_discovered(self, session, state):
|
|
690
|
+
"""Verify the workflow process_id was discovered when button was created."""
|
|
691
|
+
pid = state.get("process_id")
|
|
692
|
+
if not pid:
|
|
693
|
+
# Try to re-discover from button_id if it still exists
|
|
694
|
+
if state.get("button_id"):
|
|
695
|
+
pid = self._get_process_id_from_trigger(session, state)
|
|
696
|
+
if pid:
|
|
697
|
+
state["process_id"] = pid
|
|
698
|
+
_save_state(state)
|
|
699
|
+
|
|
700
|
+
assert pid, (
|
|
701
|
+
"process_id not set — button creation in test_41 should have "
|
|
702
|
+
"auto-discovered it via getProcessByTriggerId"
|
|
703
|
+
)
|
|
704
|
+
print(f"\n Workflow process_id: {pid}")
|
|
705
|
+
|
|
706
|
+
def test_61_get_process_info(self, session, state):
|
|
707
|
+
"""Retrieve process info via GET getProcessById."""
|
|
708
|
+
pid = state.get("process_id")
|
|
709
|
+
if not pid:
|
|
710
|
+
pytest.skip("process_id not discovered")
|
|
711
|
+
|
|
712
|
+
import requests
|
|
713
|
+
headers = {
|
|
714
|
+
"Content-Type": "application/json",
|
|
715
|
+
"Authorization": f"md_pss_id {session.auth_token}",
|
|
716
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
717
|
+
}
|
|
718
|
+
url = (
|
|
719
|
+
f"https://api.mingdao.com/workflow/process/getProcessById"
|
|
720
|
+
f"?processId={pid}"
|
|
721
|
+
)
|
|
722
|
+
r = requests.get(url, headers=headers, timeout=30)
|
|
723
|
+
print(f"\n GET getProcessById: HTTP {r.status_code}")
|
|
724
|
+
if r.status_code == 200:
|
|
725
|
+
data = r.json()
|
|
726
|
+
print(f" Process: name={data.get('name')} enabled={data.get('enabled')}")
|
|
727
|
+
else:
|
|
728
|
+
print(f" Response: {r.text[:200]}")
|
|
729
|
+
|
|
730
|
+
def test_62_get_nodes(self, session, state):
|
|
731
|
+
"""Retrieve nodes for the workflow via GET flowNode/get."""
|
|
732
|
+
pid = state.get("process_id")
|
|
733
|
+
if not pid:
|
|
734
|
+
pytest.skip("process_id not set")
|
|
735
|
+
|
|
736
|
+
import requests
|
|
737
|
+
headers = {
|
|
738
|
+
"Content-Type": "application/json",
|
|
739
|
+
"Authorization": f"md_pss_id {session.auth_token}",
|
|
740
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
741
|
+
}
|
|
742
|
+
url = f"https://api.mingdao.com/workflow/flowNode/get?processId={pid}"
|
|
743
|
+
r = requests.get(url, headers=headers, timeout=30)
|
|
744
|
+
print(f"\n GET flowNode/get: HTTP {r.status_code}")
|
|
745
|
+
if r.status_code == 200:
|
|
746
|
+
data = r.json()
|
|
747
|
+
if isinstance(data, list):
|
|
748
|
+
print(f" Node count: {len(data)}")
|
|
749
|
+
for n in data:
|
|
750
|
+
print(f" {n.get('name')} type={n.get('flowNodeType')}")
|
|
751
|
+
elif isinstance(data, dict):
|
|
752
|
+
print(f" Keys: {list(data.keys())}")
|
|
753
|
+
else:
|
|
754
|
+
print(f" Response: {r.text[:200]}")
|
|
755
|
+
|
|
756
|
+
def test_63_list_workflows(self, session, state):
|
|
757
|
+
"""List workflows in the app (informational)."""
|
|
758
|
+
try:
|
|
759
|
+
result = wf_mod.get_process_list(session, relation_id=APP_ID)
|
|
760
|
+
processes = result.get("data", [])
|
|
761
|
+
print(f"\n Workflows in app: count={result.get('count', '?')}")
|
|
762
|
+
for p in processes[:5]:
|
|
763
|
+
print(f" {p.get('name')} id={p.get('processId') or p.get('id')}")
|
|
764
|
+
except Exception as e:
|
|
765
|
+
print(f"\n get_process_list error (non-fatal): {e}")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ── Tests: Cleanup ─────────────────────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
class TestCleanup:
|
|
771
|
+
|
|
772
|
+
def test_99_cleanup_transient(self, session, state):
|
|
773
|
+
"""Delete transient resources (workflow, test views); keep worksheet."""
|
|
774
|
+
print("\n Cleaning transient resources...")
|
|
775
|
+
|
|
776
|
+
# Delete grid view if it still exists
|
|
777
|
+
ws_id = state.get("worksheet_id")
|
|
778
|
+
if ws_id and (vid := state.get("view_id")):
|
|
779
|
+
try:
|
|
780
|
+
ws_mod.delete_view(session, ws_id, vid, app_id=APP_ID)
|
|
781
|
+
print(f" Deleted view {vid}")
|
|
782
|
+
except Exception as e:
|
|
783
|
+
print(f" Could not delete view: {e}")
|
|
784
|
+
|
|
785
|
+
# Delete gallery view if still present
|
|
786
|
+
if ws_id and (gvid := state.get("gallery_view_id")):
|
|
787
|
+
try:
|
|
788
|
+
ws_mod.delete_view(session, ws_id, gvid, app_id=APP_ID)
|
|
789
|
+
print(f" Deleted gallery view {gvid}")
|
|
790
|
+
except Exception as e:
|
|
791
|
+
print(f" Could not delete gallery view: {e}")
|
|
792
|
+
|
|
793
|
+
# Delete remaining record if any
|
|
794
|
+
if ws_id and (rid := state.get("row_id")):
|
|
795
|
+
try:
|
|
796
|
+
rec_mod.delete_records(session, ws_id, [rid])
|
|
797
|
+
print(f" Deleted record {rid}")
|
|
798
|
+
except Exception as e:
|
|
799
|
+
print(f" Could not delete record: {e}")
|
|
800
|
+
|
|
801
|
+
# Delete workflow
|
|
802
|
+
_cleanup_transient(session, state)
|
|
803
|
+
|
|
804
|
+
print(f" Persistent state retained: {list(state.keys())}")
|
|
805
|
+
print(" Done. Worksheet and section are preserved for the next run.")
|