diff --git a/tests/discourse/functional/_discourse.py b/tests/discourse/functional/_discourse.py new file mode 100644 index 0000000..0e50c25 --- /dev/null +++ b/tests/discourse/functional/_discourse.py @@ -0,0 +1,62 @@ +"""Shared discourse test helpers — admin user + API key + JSON HTTP. + +The bitnamilegacy/discourse recipe (compose app env) sets ONLY the DB + SMTP vars — it does NOT seed +an admin user, and Discourse's Admin API requires `Api-Key` + `Api-Username` headers (there is no +auto-created API key). So the functional tests bootstrap their own admin by running Rails inside the +`app` container (Discourse ships its Rails env at /opt/bitnami/discourse): find-or-create an admin +user, then create an ApiKey and print its plaintext `.key` (Discourse returns the plaintext only at +create time; it's stored hashed). Both the admin user and the key are class-B run-scoped — they live +only in the per-run app and are destroyed at teardown. + +`mint_admin(domain)` returns (api_key, api_username). Each call creates a fresh ApiKey (cheap; +idempotent enough — the shared deployment's functional tests each mint their own), reusing the same +admin user across calls. Uses `lifecycle.exec_in_app` (hardened exec with retry). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +# Rails snippet (single line): find-or-create an admin, create an ApiKey, print key + username as the +# last two lines. SecureRandom is available in the Rails runtime. We mark the user active + approved +# so the API accepts it. created_by_id must be set (ApiKey validates it). +_BOOTSTRAP_RB = ( + "u = User.where(admin: true).order(:id).first; " + "if u.nil?; " + "u = User.create!(username: 'ccciadmin', name: 'CCCI Admin', " + "email: 'ccciadmin@ccci.example.com', password: SecureRandom.hex(20), " + "active: true, approved: true, trust_level: 1); " + "u.update!(admin: true); u.activate; " + "end; " + "k = ApiKey.create!(description: 'ccci-run', created_by_id: u.id); " + "puts 'CCCI_API_KEY=' + k.key; " + "puts 'CCCI_API_USER=' + u.username" +) + + +def mint_admin(domain: str) -> tuple[str, str]: + """Bootstrap an admin + fresh API key via Rails in the app container. Returns (api_key, username).""" + cmd = ( + "cd /opt/bitnami/discourse && " + f"RAILS_ENV=production bin/rails runner \"{_BOOTSTRAP_RB}\"" + ) + out = lifecycle.exec_in_app(domain, ["bash", "-lc", cmd], service="app", timeout=240) + key = user = None + for line in out.splitlines(): + line = line.strip() + if line.startswith("CCCI_API_KEY="): + key = line.split("=", 1)[1].strip() + elif line.startswith("CCCI_API_USER="): + user = line.split("=", 1)[1].strip() + assert key and user, ( + f"could not bootstrap discourse admin/API key; rails output tail:\n{out[-1000:]}" + ) + return key, user + + +def admin_headers(api_key: str, api_username: str) -> dict[str, str]: + return {"Api-Key": api_key, "Api-Username": api_username} diff --git a/tests/discourse/functional/test_create_topic.py b/tests/discourse/functional/test_create_topic.py new file mode 100644 index 0000000..abf7212 --- /dev/null +++ b/tests/discourse/functional/test_create_topic.py @@ -0,0 +1,65 @@ +"""discourse — Q4.6 recipe-specific functional test (plan §4.3: "create the app's primary object — a +topic — and read it back"). + +Exercises Discourse's core forum function end-to-end against the live per-run deploy, via the real +Admin API: + 1. Wait for the Admin API to answer (/site.json 200 once Rails is serving). + 2. Bootstrap an admin + mint an API key (_discourse.mint_admin — the recipe seeds no admin). + 3. POST /posts.json to create a new topic (title + body carrying a unique marker). + 4. GET /t/.json to read it back and assert the title round-tripped; GET the post's raw + and assert the unique body marker survived. + +NOT health-only: a Discourse whose DB/Rails/posting path is broken fails here even though /srv/status +returns 200. The marker is unique per run, so a stale/echoed response cannot pass. +""" + +from __future__ import annotations + +import os +import sys +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +import _discourse # noqa: E402 +from harness import http as harness_http # noqa: E402 + + +def test_create_topic_roundtrip(live_app): + base = f"https://{live_app}" + + # 1) Admin API ready (Rails serving JSON, not just /srv/status). + harness_http.retry_http_get(f"{base}/site.json", expect_status=200, max_wait=120, interval=5) + + # 2) Bootstrap admin + API key (recipe seeds no admin; mint via Rails in the app container). + api_key, api_user = _discourse.mint_admin(live_app) + hdrs = _discourse.admin_headers(api_key, api_user) + + # 3) Create a topic with a unique marker in title + body (raw must be >= ~20 chars). + uniq = uuid.uuid4().hex[:10] + title = f"ccci topic {uniq}" + marker = f"ccci-body-marker-{uniq}-roundtrip-padding-text" + status, body = harness_http.http_post( + f"{base}/posts.json", + data={"title": title, "raw": marker}, + headers=hdrs, + timeout=60, + ) + assert status in (200, 201) and isinstance(body, dict), ( + f"create topic failed: HTTP {status}, body={body!r}" + ) + topic_id = body.get("topic_id") + assert topic_id, f"create topic returned no topic_id: {body!r}" + + # 4) Read the topic back and assert title + first-post body round-trip. + status, got = harness_http.http_get(f"{base}/t/{topic_id}.json", headers=hdrs, timeout=30) + assert status == 200 and isinstance(got, dict), f"read topic failed: HTTP {status}, body={got!r}" + assert got.get("title") == title, ( + f"topic title did not round-trip: sent {title!r}, got {got.get('title')!r}" + ) + posts = (got.get("post_stream") or {}).get("posts") or [] + assert posts, f"topic has no posts on read-back: {got!r}" + first_cooked = posts[0].get("cooked", "") + assert marker in first_cooked, ( + f"topic body did not round-trip: marker {marker!r} not in first post {first_cooked!r}" + ) diff --git a/tests/discourse/functional/test_site_basic.py b/tests/discourse/functional/test_site_basic.py new file mode 100644 index 0000000..fd8793d --- /dev/null +++ b/tests/discourse/functional/test_site_basic.py @@ -0,0 +1,31 @@ +"""discourse — Q4.6 recipe-specific functional test (2nd functional, beyond create-topic + health). + +Asserts Discourse's Rails app emits its bootstrap JSON: GET /site.json returns 200 with the +site-config envelope the Ember SPA needs (categories list + default trust levels). This distinguishes +"the Discourse Rails backend is up and serving its API" from "a static/error page is served" — a +wedged backend 5xxs, a misroute 404s. Complements test_create_topic (write path) with a read of the +app's characteristic site config. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def test_site_json_has_discourse_config(live_app): + status, body = harness_http.retry_http_get( + f"https://{live_app}/site.json", expect_status=200, max_wait=120, interval=5 + ) + assert status == 200 and isinstance(body, dict), ( + f"GET /site.json failed: HTTP {status}, body type={type(body).__name__}" + ) + # /site.json carries Discourse-specific structure — `categories` (a list) and `groups` are always + # present in a booted Discourse. A non-Discourse 200 (placeholder page) would not parse to this. + assert "categories" in body, f"/site.json missing 'categories' key: keys={list(body)[:20]}" + assert isinstance(body["categories"], list), ( + f"/site.json 'categories' not a list: {type(body['categories']).__name__}" + )