#!/usr/bin/env python3 """API smoke tests for the admin Configuration section. Covers the three pages in `AdminLmsLayout.tsx` under "Configuration": * /admin/faq -> /api/faq/{categories,items} * /admin/notification-rules -> /api/notification-rules * /admin/approval-config -> /api/approval-workflows Usage: python3 test_config_flows.py """ import json import sys import time from urllib import request as urlreq from urllib.error import HTTPError BASE = "http://localhost:8069/api" TOKEN = None GREEN = "\033[32m"; RED = "\033[31m"; RESET = "\033[0m"; BOLD = "\033[1m" results = [] def log(page, action, ok_, note=""): results.append((page, action, ok_, note)) tag = f"{GREEN}PASS{RESET}" if ok_ else f"{RED}FAIL{RESET}" print(f" [{tag}] {page:22s} {action:42s} {note}") def _req(method, path, body=None, params=None): url = BASE + path if params: qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) url += ("?" + qs) if qs else "" data = None headers = {"Accept": "application/json"} if TOKEN: headers["Authorization"] = f"Bearer {TOKEN}" if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" req = urlreq.Request(url, data=data, method=method, headers=headers) try: with urlreq.urlopen(req, timeout=20) as resp: raw = resp.read().decode() return resp.status, (json.loads(raw) if raw else {}) except HTTPError as e: raw = e.read().decode() if e.fp else "" try: payload = json.loads(raw) except Exception: payload = {"error": raw[:200]} return e.code, payload except Exception as e: return 0, {"error": str(e)} def GET(path, **params): return _req("GET", path, params=params) def POST(path, body=None): return _req("POST", path, body=body or {}) def PATCH(path, body=None): return _req("PATCH", path, body=body or {}) def DELETE(path): return _req("DELETE", path) def ok(s): return 200 <= s < 300 def login(): global TOKEN s, b = POST("/login", {"email": "admin", "password": "admin"}) TOKEN = (b or {}).get("token") return bool(TOKEN) # ── 1. FAQ ─────────────────────────────────────────────────────────────── def test_faq(): print(f"{BOLD}== /admin/faq =={RESET}") ts = int(time.time()) # LIST categories s, b = GET("/faq/categories") log("faq", "LIST categories", ok(s) and isinstance(b, list), f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}") # CREATE category with audience='both' (frontend-only value) cat_name = f"Cfg Test Cat {ts}" s, b = POST("/faq/categories", {"name": cat_name, "audience": "both"}) cat_id = (b or {}).get("id") log("faq", "CREATE category audience=both", ok(s) and cat_id and b.get("audience") == "both", f"status={s} id={cat_id} audience={(b or {}).get('audience')}") # CREATE category with audience='entity' s2, b2 = POST("/faq/categories", {"name": f"Cfg Entity {ts}", "audience": "entity"}) entity_cat_id = (b2 or {}).get("id") log("faq", "CREATE category audience=entity", ok(s2) and entity_cat_id and b2.get("audience") == "entity", f"status={s2} id={entity_cat_id} audience={(b2 or {}).get('audience')}") # EDIT category if cat_id: s, b = PATCH(f"/faq/categories/{cat_id}", {"name": cat_name + " (edited)"}) log("faq", "EDIT category name", ok(s) and "edited" in (b or {}).get("name", ""), f"status={s} name={(b or {}).get('name')}") # CREATE item with video_url (regression: previously dropped) if cat_id: s, b = POST("/faq/items", { "question": f"Q? {ts}", "answer": "Because reasons.", "category_id": cat_id, "audience": "both", "video_url": "https://example.com/walkthrough.mp4", }) item_id = (b or {}).get("id") log("faq", "CREATE item audience=both +video", ok(s) and item_id and b.get("video_url") == "https://example.com/walkthrough.mp4", f"status={s} id={item_id} video_url={(b or {}).get('video_url')}") else: item_id = None # LIST items filtered by category if cat_id: s, b = GET("/faq/items", category_id=cat_id) log("faq", "LIST items filter category_id", ok(s) and isinstance(b, list) and any(it["id"] == item_id for it in b), f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}") # EDIT item: change audience 'both' -> 'student' if item_id: s, b = PATCH(f"/faq/items/{item_id}", {"audience": "student"}) log("faq", "EDIT item audience both->student", ok(s) and b.get("audience") == "student", f"status={s} audience={(b or {}).get('audience')}") # DELETE item if item_id: s, _ = DELETE(f"/faq/items/{item_id}") log("faq", "DELETE item", ok(s), f"status={s}") # DELETE categories (cleanup) for cid, label in [(cat_id, "cat"), (entity_cat_id, "entity cat")]: if cid: s, _ = DELETE(f"/faq/categories/{cid}") log("faq", f"DELETE {label}", ok(s), f"status={s} id={cid}") # ── 2. Notification Rules ─────────────────────────────────────────────── def test_notification_rules(): print(f"{BOLD}== /admin/notification-rules =={RESET}") ts = int(time.time()) # LIST s, b = GET("/notification-rules") log("notif-rules", "LIST", ok(s) and isinstance(b, list), f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}") # CREATE rule with new fields (days_before, frequency, channel, active) payload = { "name": f"Deadline reminder {ts}", "event_type": "deadline", "days_before": 3, "frequency": "daily", "channel": "email", } s, b = POST("/notification-rules", payload) rid = (b or {}).get("id") correct = ( ok(s) and rid and b.get("days_before") == 3 and b.get("frequency") == "daily" and b.get("channel") == "email" and b.get("active") is True # default from is_active ) log("notif-rules", "CREATE with new fields", correct, f"status={s} id={rid} days={(b or {}).get('days_before')} " f"freq={(b or {}).get('frequency')} ch={(b or {}).get('channel')} " f"active={(b or {}).get('active')}") # EDIT: send `active: False` (frontend-speak) and expect `is_active` to flip if rid: s, b = PATCH(f"/notification-rules/{rid}", {"active": False, "days_before": 7}) log("notif-rules", "EDIT active=false + days_before=7", ok(s) and b.get("active") is False and b.get("is_active") is False and b.get("days_before") == 7, f"status={s} active={(b or {}).get('active')} " f"is_active={(b or {}).get('is_active')} days={(b or {}).get('days_before')}") # EDIT: change frequency + channel if rid: s, b = PATCH(f"/notification-rules/{rid}", {"frequency": "once", "channel": "both"}) log("notif-rules", "EDIT frequency=once channel=both", ok(s) and b.get("frequency") == "once" and b.get("channel") == "both", f"status={s} freq={(b or {}).get('frequency')} ch={(b or {}).get('channel')}") # CREATE without required field -> 400 s, _ = POST("/notification-rules", {"event_type": "exam"}) log("notif-rules", "CREATE rejects missing name", s == 400, f"status={s}") # DELETE if rid: s, _ = DELETE(f"/notification-rules/{rid}") log("notif-rules", "DELETE", ok(s), f"status={s} id={rid}") # ── 3. Approval Workflows ─────────────────────────────────────────────── def test_approval_workflows(): print(f"{BOLD}== /admin/approval-config =={RESET}") ts = int(time.time()) # LIST (previously would have emitted {items:[], total:0} silently; now # confirm it also emits `results` for the frontend PaginatedResponse shape) s, b = GET("/approval-workflows") has_envelope = ( isinstance(b, dict) and "items" in b and "results" in b and "total" in b ) log("approvals", "LIST envelope items+results+total", ok(s) and has_envelope, f"status={s} keys={sorted((b or {}).keys()) if isinstance(b, dict) else 'n/a'}") # Need an approver (any admin user). Ask the approval-users endpoint. s_u, b_u = GET("/approval-users") users = (b_u or {}).get("items") or [] if not users: log("approvals", "LIST approval-users", False, f"status={s_u} (no users)") return approver = users[0] log("approvals", "LIST approval-users", True, f"status={s_u} first={approver.get('name')} id={approver.get('id')}") # CREATE workflow with full stage fields payload = { "name": f"Leave Approval {ts}", "type": "leave", "status": "draft", "allow_bypass": True, "bypass_enabled": True, "steps": [ { "order": 1, "approver_id": approver["id"], "approver_name": approver["name"], "max_days": 2, "auto_escalate": True, "notification_email": "esc@example.com", }, { "order": 2, "approver_id": approver["id"], "approver_name": approver["name"], "max_days": 5, "auto_escalate": False, }, ], } s, b = POST("/approval-workflows", payload) wf_id = (b or {}).get("id") stages = (b or {}).get("steps") or [] correct = ( ok(s) and wf_id and b.get("allow_bypass") is True and len(stages) == 2 and stages[0].get("max_days") == 2 and stages[0].get("auto_escalate") is True and stages[0].get("notification_email") == "esc@example.com" ) log("approvals", "CREATE with 2 stages + bypass", correct, f"status={s} id={wf_id} stages={len(stages)} " f"bypass={(b or {}).get('allow_bypass')}") # GET single if wf_id: s, b = GET(f"/approval-workflows/{wf_id}") log("approvals", "GET detail", ok(s) and b.get("id") == wf_id, f"status={s}") # PATCH status draft -> active (used by the toggle switch) if wf_id: s, b = PATCH(f"/approval-workflows/{wf_id}", {"status": "active"}) log("approvals", "PATCH status draft->active", ok(s) and b.get("status") == "active", f"status={s} new_status={(b or {}).get('status')}") # PATCH stages replacement if wf_id: s, b = PATCH(f"/approval-workflows/{wf_id}", { "steps": [{ "order": 1, "approver_id": approver["id"], "approver_name": approver["name"], "max_days": 10, "auto_escalate": False, }], }) new_stages = (b or {}).get("steps") or [] log("approvals", "PATCH replace stages", ok(s) and len(new_stages) == 1 and new_stages[0].get("max_days") == 10, f"status={s} stages={len(new_stages)}") # CREATE rejects missing name s, _ = POST("/approval-workflows", {"type": "custom"}) log("approvals", "CREATE rejects missing name", s == 400, f"status={s}") # DELETE if wf_id: s, _ = DELETE(f"/approval-workflows/{wf_id}") log("approvals", "DELETE", ok(s), f"status={s} id={wf_id}") # ── Main ──────────────────────────────────────────────────────────────── if __name__ == "__main__": print(f"{BOLD}Configuration Section API Smoke Tests{RESET}") if not login(): print(f"{RED}Login failed. Is Odoo running on :8069 with admin/admin?{RESET}") sys.exit(1) print(f" logged in, token={TOKEN[:12] if TOKEN else 'none'}...\n") test_faq() print() test_notification_rules() print() test_approval_workflows() total = len(results) passed = sum(1 for r in results if r[2]) failed = total - passed print("\n" + "=" * 70) print(f"{BOLD}Summary: {GREEN}{passed} passed{RESET}, " f"{RED}{failed} failed{RESET}, {total} total") if failed: print(f"\n{RED}Failing:{RESET}") for page, action, ok_, note in results: if not ok_: print(f" - {page:22s} {action:42s} {note}") sys.exit(2)