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.
Files changed (58) hide show
  1. hap_cli/README.md +194 -0
  2. hap_cli/README_CN.md +601 -0
  3. hap_cli/__init__.py +3 -0
  4. hap_cli/commands/__init__.py +1 -0
  5. hap_cli/commands/ai_cmd.py +224 -0
  6. hap_cli/commands/app_cmd.py +308 -0
  7. hap_cli/commands/calendar_cmd.py +138 -0
  8. hap_cli/commands/chat_cmd.py +101 -0
  9. hap_cli/commands/config_cmd.py +169 -0
  10. hap_cli/commands/contact_cmd.py +125 -0
  11. hap_cli/commands/department_cmd.py +168 -0
  12. hap_cli/commands/group_cmd.py +128 -0
  13. hap_cli/commands/instance_cmd.py +310 -0
  14. hap_cli/commands/node_cmd.py +538 -0
  15. hap_cli/commands/optionset_cmd.py +99 -0
  16. hap_cli/commands/page_cmd.py +102 -0
  17. hap_cli/commands/plugin_cmd.py +133 -0
  18. hap_cli/commands/post_cmd.py +155 -0
  19. hap_cli/commands/record_cmd.py +228 -0
  20. hap_cli/commands/role_cmd.py +221 -0
  21. hap_cli/commands/workflow_cmd.py +284 -0
  22. hap_cli/commands/worksheet_cmd.py +342 -0
  23. hap_cli/context.py +43 -0
  24. hap_cli/core/__init__.py +1 -0
  25. hap_cli/core/ai.py +133 -0
  26. hap_cli/core/app.py +307 -0
  27. hap_cli/core/auth.py +219 -0
  28. hap_cli/core/calendar_mod.py +114 -0
  29. hap_cli/core/chat.py +73 -0
  30. hap_cli/core/contact.py +85 -0
  31. hap_cli/core/department.py +131 -0
  32. hap_cli/core/flow_node.py +1001 -0
  33. hap_cli/core/group.py +99 -0
  34. hap_cli/core/instance.py +572 -0
  35. hap_cli/core/optionset.py +112 -0
  36. hap_cli/core/page.py +138 -0
  37. hap_cli/core/plugin.py +87 -0
  38. hap_cli/core/post.py +118 -0
  39. hap_cli/core/record.py +268 -0
  40. hap_cli/core/role.py +227 -0
  41. hap_cli/core/session.py +348 -0
  42. hap_cli/core/workflow.py +556 -0
  43. hap_cli/core/worksheet.py +403 -0
  44. hap_cli/hap_cli.py +105 -0
  45. hap_cli/skills/SKILL.md +383 -0
  46. hap_cli/skills/__init__.py +0 -0
  47. hap_cli/tests/__init__.py +1 -0
  48. hap_cli/tests/test_core.py +1824 -0
  49. hap_cli/tests/test_full_e2e.py +136 -0
  50. hap_cli/tests/test_integration.py +805 -0
  51. hap_cli/utils/__init__.py +1 -0
  52. hap_cli/utils/formatting.py +111 -0
  53. hap_cli/utils/options.py +10 -0
  54. hap_cli-0.5.0.dist-info/METADATA +223 -0
  55. hap_cli-0.5.0.dist-info/RECORD +58 -0
  56. hap_cli-0.5.0.dist-info/WHEEL +5 -0
  57. hap_cli-0.5.0.dist-info/entry_points.txt +2 -0
  58. 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.")