Add Python client, profile endpoint, and functional test
- remarkbox_client.py: stdlib-only Python client with cookie persistence, 3-tier config (args/env/file), CLI mode - GET/PATCH /api/v1/user/profile: read and update display name - GET /api/v1/clients/python: serve client for curl/wget download - functional_test.py: idempotent live test that maintains a journey thread documenting each run - Remove email2 spam honeypot from API (not useful for agents) - Bump content limit to 500k chars (~128k tokens)
This commit is contained in:
parent
9d59a3ca8a
commit
5a10e155bd
8 changed files with 1287 additions and 4 deletions
|
|
@ -189,7 +189,7 @@ Request body:
|
|||
|
||||
- `namespace` (required)
|
||||
- `title` (required)
|
||||
- `data` (required, max 50000 chars)
|
||||
- `data` (required, max 500000 chars)
|
||||
- `anonymous_name` (optional, used when namespace allows anonymous)
|
||||
- `email` (optional, creates an unverified user)
|
||||
|
||||
|
|
@ -219,7 +219,7 @@ Request body:
|
|||
}
|
||||
```
|
||||
|
||||
- `data` (required, max 50000 chars)
|
||||
- `data` (required, max 500000 chars)
|
||||
- `anonymous_name` (optional)
|
||||
- `email` (optional)
|
||||
|
||||
|
|
@ -361,7 +361,7 @@ All errors return a JSON body with an `error` key:
|
|||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| 400 | Bad request (missing params, content too long) |
|
||||
| 401 | Authentication required or spam detected |
|
||||
| 401 | Authentication required |
|
||||
| 403 | Forbidden (locked thread, disabled node, namespace opt-out) |
|
||||
| 404 | Not found (or API globally disabled) |
|
||||
| 429 | Rate limit exceeded |
|
||||
|
|
|
|||
153
docs/testing.md
Normal file
153
docs/testing.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Functional Testing the Remarkbox API
|
||||
|
||||
Walkthrough for testing every endpoint with `curl`. Replace
|
||||
`REMARKBOX` with your deploy URL (e.g. `https://my.remarkbox.com`).
|
||||
|
||||
## Read Endpoints
|
||||
|
||||
### List Threads
|
||||
|
||||
```bash
|
||||
curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Get Thread
|
||||
|
||||
```bash
|
||||
# grab the first thread id from the list
|
||||
THREAD_ID=$(curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['threads'][0]['id'])")
|
||||
|
||||
curl -s "$REMARKBOX/api/v1/threads/$THREAD_ID" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Get Node
|
||||
|
||||
```bash
|
||||
curl -s "$REMARKBOX/api/v1/nodes/$THREAD_ID" | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Anonymous Posting
|
||||
|
||||
Requires a namespace with **Allow Anonymous Comments** enabled.
|
||||
|
||||
### Create Thread
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$REMARKBOX/api/v1/threads" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"namespace": "meta.remarkbox.com",
|
||||
"title": "Test thread from curl",
|
||||
"data": "Hello from the API.",
|
||||
"anonymous_name": "CurlBot"
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Reply to Thread
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$REMARKBOX/api/v1/threads/$THREAD_ID/replies" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"data": "Reply from curl.",
|
||||
"anonymous_name": "CurlBot"
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Authentication (Email OTP)
|
||||
|
||||
### Request OTP
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$REMARKBOX/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "you@example.com"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Verify OTP
|
||||
|
||||
Check your inbox for the 6-digit code, then:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$REMARKBOX/api/v1/auth/verify" \
|
||||
-H "Content-Type: application/json" \
|
||||
-c cookies.txt \
|
||||
-d '{"email": "you@example.com", "otp": "123456"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
The `-c cookies.txt` saves the session cookie for subsequent requests.
|
||||
|
||||
### Create Authenticated Thread
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$REMARKBOX/api/v1/threads" \
|
||||
-H "Content-Type: application/json" \
|
||||
-b cookies.txt \
|
||||
-d '{
|
||||
"namespace": "meta.remarkbox.com",
|
||||
"title": "Authenticated thread",
|
||||
"data": "Posted with a verified session."
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Edit a Node
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-b cookies.txt \
|
||||
-d '{"data": "Updated content."}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Error Cases
|
||||
|
||||
### Missing namespace
|
||||
|
||||
```bash
|
||||
curl -s "$REMARKBOX/api/v1/threads" | python3 -m json.tool
|
||||
# {"error": "namespace parameter is required"}
|
||||
```
|
||||
|
||||
### Namespace with API access disabled
|
||||
|
||||
```bash
|
||||
curl -s "$REMARKBOX/api/v1/threads?namespace=opted-out.example.com"
|
||||
# {"error": "API access is disabled for this namespace"}
|
||||
```
|
||||
|
||||
### Edit without auth
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"data": "nope"}'
|
||||
# {"error": "Authentication required"}
|
||||
```
|
||||
|
||||
## Python Client
|
||||
|
||||
Download the Python client directly from the API:
|
||||
|
||||
```bash
|
||||
curl -s "$REMARKBOX/api/v1/clients/python" -o remarkbox_client.py
|
||||
```
|
||||
|
||||
Or with wget:
|
||||
|
||||
```bash
|
||||
wget -q "$REMARKBOX/api/v1/clients/python" -O remarkbox_client.py
|
||||
```
|
||||
|
||||
Then use it:
|
||||
|
||||
```python
|
||||
from remarkbox_client import RemarkboxClient
|
||||
|
||||
client = RemarkboxClient("https://my.remarkbox.com")
|
||||
threads = client.list_threads("meta.remarkbox.com")
|
||||
for t in threads["threads"]:
|
||||
print(t["title"])
|
||||
```
|
||||
|
||||
See `remarkbox_client.py` header comments for full usage.
|
||||
|
|
@ -5,4 +5,6 @@ def includeme(config):
|
|||
config.add_route("api-node-detail", "/api/v1/nodes/{node_id}")
|
||||
config.add_route("api-auth-login", "/api/v1/auth/login")
|
||||
config.add_route("api-auth-verify", "/api/v1/auth/verify")
|
||||
config.add_route("api-user-profile", "/api/v1/user/profile")
|
||||
config.add_route("api-client-python", "/api/v1/clients/python")
|
||||
config.scan("remarkbox.api.views")
|
||||
|
|
|
|||
364
remarkbox/api/functional_test.py
Normal file
364
remarkbox/api/functional_test.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""
|
||||
Functional test of the Remarkbox API against a live instance.
|
||||
|
||||
Uses cookie persistence so authentication survives across runs.
|
||||
First run requires an OTP; subsequent runs reuse the session.
|
||||
Creates one persistent "API Testing Journey" thread and appends
|
||||
a new section on each run. All other operations are reads.
|
||||
|
||||
Usage:
|
||||
# First run (sends OTP, prompts for code):
|
||||
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
|
||||
|
||||
# With OTP on command line:
|
||||
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com 173786
|
||||
|
||||
# Subsequent runs reuse saved session:
|
||||
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
|
||||
|
||||
# Set display name (idempotent):
|
||||
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon
|
||||
|
||||
# Specify existing journey thread to append to:
|
||||
python functional_test.py ... --journey 9f970183-ffaf-11f0-b565-040140774501
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from remarkbox_client import RemarkboxClient, RemarkboxError
|
||||
|
||||
THREAD_TITLE = "API Testing Journey"
|
||||
DEFAULT_COOKIE_DIR = os.path.join(os.path.expanduser("~"), ".config", "remarkbox")
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
|
||||
|
||||
class JournalWriter:
|
||||
"""Collects test results and builds a markdown journal section."""
|
||||
|
||||
def __init__(self):
|
||||
self.lines = []
|
||||
self.results = []
|
||||
self._step = 0
|
||||
|
||||
def step(self, title):
|
||||
self._step += 1
|
||||
self.lines.append("### {}. {}".format(self._step, title))
|
||||
|
||||
def note(self, text):
|
||||
self.lines.append(text)
|
||||
|
||||
def blank(self):
|
||||
self.lines.append("")
|
||||
|
||||
def log(self, test_name, passed, detail=""):
|
||||
mark = "+" if passed else "!"
|
||||
status = PASS if passed else FAIL
|
||||
print(" [{}] {} {}{}".format(mark, status, test_name,
|
||||
": " + detail if detail else ""))
|
||||
self.results.append(passed)
|
||||
return passed
|
||||
|
||||
@property
|
||||
def passed(self):
|
||||
return sum(1 for r in self.results if r)
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return len(self.results)
|
||||
|
||||
def summary_line(self):
|
||||
return "**{}/{} passed.**".format(self.passed, self.total)
|
||||
|
||||
def render(self):
|
||||
return "\n".join(self.lines)
|
||||
|
||||
|
||||
def ensure_authenticated(client, email, otp=None):
|
||||
"""Return True if authenticated, attempting login/verify if needed."""
|
||||
try:
|
||||
profile = client.get_profile()
|
||||
if profile.get("user"):
|
||||
return True, "reused session"
|
||||
except RemarkboxError:
|
||||
pass
|
||||
|
||||
# Request OTP on this client, then verify
|
||||
login_result = client.login(email)
|
||||
status = login_result.get("status")
|
||||
|
||||
if otp and status in ("sent", "throttled"):
|
||||
try:
|
||||
result = client.verify(email, otp)
|
||||
if result.get("status") == "authenticated":
|
||||
return True, "verified otp"
|
||||
except RemarkboxError:
|
||||
pass
|
||||
|
||||
# Need interactive OTP
|
||||
if status == "throttled":
|
||||
print("\n OTP already sent to {} (check inbox)".format(email))
|
||||
else:
|
||||
print("\n OTP sent to {}".format(email))
|
||||
otp = input(" Enter 6-digit code: ").strip()
|
||||
result = client.verify(email, otp)
|
||||
return result.get("status") == "authenticated", "verified otp"
|
||||
|
||||
|
||||
def find_journey_thread(client, namespace):
|
||||
"""Find existing journey thread by title, or return None."""
|
||||
data = client.list_threads(namespace)
|
||||
for thread in data.get("threads", []):
|
||||
if thread["title"].strip() == THREAD_TITLE:
|
||||
return thread["id"]
|
||||
return None
|
||||
|
||||
|
||||
def run(url, namespace, email, otp=None, display_name=None, journey_id=None):
|
||||
cookie_file = os.path.join(DEFAULT_COOKIE_DIR, "cookies.txt")
|
||||
os.makedirs(DEFAULT_COOKIE_DIR, exist_ok=True)
|
||||
|
||||
client = RemarkboxClient(url, email=email, cookie_file=cookie_file)
|
||||
j = JournalWriter()
|
||||
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 1: Read operations (no trace)
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 1: Read operations")
|
||||
|
||||
j.step("List Threads")
|
||||
try:
|
||||
data = client.list_threads(namespace)
|
||||
ok = data["namespace"]["name"] == namespace
|
||||
j.log("list_threads", ok, "{} threads".format(len(data["threads"])))
|
||||
j.note("Fetched {} threads from `{}`.".format(len(data["threads"]), namespace))
|
||||
j.blank()
|
||||
except Exception as e:
|
||||
j.log("list_threads", False, str(e))
|
||||
|
||||
j.step("Get Thread")
|
||||
thread_id = None
|
||||
try:
|
||||
if data["threads"]:
|
||||
thread_id = data["threads"][0]["id"]
|
||||
detail = client.get_thread(thread_id)
|
||||
ok = "thread" in detail and "replies" in detail
|
||||
j.log("get_thread", ok, "{} replies".format(len(detail["replies"])))
|
||||
j.note("Read thread `{}` with {} replies.".format(
|
||||
thread_id[:8], len(detail["replies"])))
|
||||
j.blank()
|
||||
except Exception as e:
|
||||
j.log("get_thread", False, str(e))
|
||||
|
||||
j.step("Get Node")
|
||||
try:
|
||||
node_data = client.get_node(thread_id)
|
||||
ok = node_data["node"]["id"] == thread_id
|
||||
j.log("get_node", ok)
|
||||
j.note("Fetched node `{}`.".format(thread_id[:8]))
|
||||
j.blank()
|
||||
except Exception as e:
|
||||
j.log("get_node", False, str(e))
|
||||
|
||||
j.step("Error Handling")
|
||||
try:
|
||||
client.list_threads("")
|
||||
j.log("error_400", False, "expected error")
|
||||
except RemarkboxError as e:
|
||||
j.log("error_400", e.status == 400, "HTTP {}".format(e.status))
|
||||
|
||||
try:
|
||||
client.get_node("00000000-0000-0000-0000-000000000000")
|
||||
j.log("error_404", False, "expected error")
|
||||
except RemarkboxError as e:
|
||||
j.log("error_404", e.status == 404, "HTTP {}".format(e.status))
|
||||
j.note("Missing namespace -> 400, nonexistent node -> 404.")
|
||||
j.blank()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 2: Authentication
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 2: Authentication")
|
||||
|
||||
j.step("Authenticate")
|
||||
ok, method = ensure_authenticated(client, email, otp)
|
||||
j.log("authenticate", ok, method)
|
||||
j.note("Authenticated via {}.".format(method))
|
||||
j.blank()
|
||||
|
||||
if not ok:
|
||||
print("\n Authentication failed. Aborting.")
|
||||
return j
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 3: Profile (idempotent)
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 3: Profile")
|
||||
|
||||
j.step("Get Profile")
|
||||
try:
|
||||
profile = client.get_profile()
|
||||
current_name = profile["user"]["name"]
|
||||
j.log("get_profile", True, current_name)
|
||||
j.note("Current display name: `{}`.".format(current_name))
|
||||
j.blank()
|
||||
profile_available = True
|
||||
except RemarkboxError as e:
|
||||
j.log("get_profile", False, "endpoint not available (HTTP {})".format(e.status))
|
||||
j.note("Profile endpoint not deployed yet -- skipping profile tests.")
|
||||
j.blank()
|
||||
profile_available = False
|
||||
|
||||
if display_name and profile_available:
|
||||
j.step("Update Display Name")
|
||||
result = client.update_profile(display_name)
|
||||
ok = result["user"]["name"] == display_name
|
||||
j.log("update_profile", ok, "{} -> {}".format(current_name, display_name))
|
||||
j.note("Set display name: `{}` -> `{}`.".format(current_name, display_name))
|
||||
j.blank()
|
||||
|
||||
# Verify idempotent (run again, same name)
|
||||
result2 = client.update_profile(display_name)
|
||||
j.log("update_profile_idempotent", result2["user"]["name"] == display_name,
|
||||
"same name accepted")
|
||||
|
||||
# Verify invalid name rejected
|
||||
try:
|
||||
client.update_profile("bad name!!!")
|
||||
j.log("error_invalid_name", False, "expected error")
|
||||
except RemarkboxError as e:
|
||||
j.log("error_invalid_name", e.status == 400, "HTTP {}".format(e.status))
|
||||
j.note("Invalid name correctly rejected with 400.")
|
||||
j.blank()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 4: Write operations (single journey thread)
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 4: Write operations")
|
||||
|
||||
# Find or create the journey thread
|
||||
if not journey_id:
|
||||
journey_id = find_journey_thread(client, namespace)
|
||||
|
||||
if journey_id:
|
||||
j.step("Reuse Journey Thread")
|
||||
j.log("find_journey", True, journey_id[:8])
|
||||
j.note("Found existing journey thread `{}`.".format(journey_id[:8]))
|
||||
j.blank()
|
||||
else:
|
||||
j.step("Create Journey Thread")
|
||||
create_result = client.create_thread(
|
||||
namespace=namespace,
|
||||
title=THREAD_TITLE,
|
||||
data="# {}\n\nInitial creation.".format(THREAD_TITLE),
|
||||
)
|
||||
journey_id = create_result["node"]["id"]
|
||||
j.log("create_thread", bool(journey_id), "node {}".format(journey_id[:8]))
|
||||
j.note("Created journey thread `{}`.".format(journey_id[:8]))
|
||||
j.blank()
|
||||
|
||||
# Reply
|
||||
j.step("Reply")
|
||||
reply_result = client.reply(
|
||||
journey_id,
|
||||
data="Test reply from run at {}. "
|
||||
"Verifies `POST /api/v1/threads/{{node_id}}/replies`.".format(now),
|
||||
)
|
||||
reply_id = reply_result["node"]["id"]
|
||||
j.log("reply", bool(reply_id), "node {}".format(reply_id[:8]))
|
||||
j.note("Posted reply `{}`.".format(reply_id[:8]))
|
||||
j.blank()
|
||||
|
||||
# Edit reply
|
||||
j.step("Edit Reply")
|
||||
client.edit_node(
|
||||
reply_id,
|
||||
data="Test reply from run at {} (edited). "
|
||||
"Verifies `PATCH /api/v1/nodes/{{node_id}}`.".format(now),
|
||||
)
|
||||
j.log("edit_reply", True)
|
||||
j.note("Edited reply `{}`.".format(reply_id[:8]))
|
||||
j.blank()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 5: Readback
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 5: Readback")
|
||||
|
||||
j.step("Readback")
|
||||
readback = client.get_thread(journey_id)
|
||||
ok = readback["thread"]["title"].strip() == THREAD_TITLE
|
||||
j.log("readback", ok, "{} replies".format(len(readback["replies"])))
|
||||
j.note("Read back thread: {} replies.".format(len(readback["replies"])))
|
||||
j.blank()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 6: Update journey thread body
|
||||
# ---------------------------------------------------------------
|
||||
print("\nPhase 6: Update journey thread")
|
||||
|
||||
j.note("---")
|
||||
j.blank()
|
||||
j.note(j.summary_line())
|
||||
j.blank()
|
||||
j.note("_Run at {} by `remarkbox_client.py`._".format(now))
|
||||
|
||||
# Build new body: keep existing content, append this run's section
|
||||
existing_body = readback["thread"]["data"]
|
||||
run_header = "\n\n## Run: {}\n\n".format(now)
|
||||
updated_body = existing_body.rstrip() + run_header + j.render()
|
||||
|
||||
client.edit_node(journey_id, data=updated_body)
|
||||
j.log("update_journey", True, "appended run section")
|
||||
|
||||
print("\n Journey: {}/api/v1/threads/{}".format(url, journey_id))
|
||||
|
||||
return j
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Functional test of the Remarkbox API against a live instance."
|
||||
)
|
||||
parser.add_argument("url", help="Base URL (e.g. https://my.remarkbox.com)")
|
||||
parser.add_argument("namespace", help="Namespace (e.g. meta.remarkbox.com)")
|
||||
parser.add_argument("email", help="Email for authentication")
|
||||
parser.add_argument("otp", nargs="?", default=None, help="OTP code (optional)")
|
||||
parser.add_argument("--name", default=None,
|
||||
help="Set display name (idempotent)")
|
||||
parser.add_argument("--journey", default=None,
|
||||
help="Existing journey thread ID to append to")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Remarkbox API Functional Test")
|
||||
print(" URL: {}".format(args.url))
|
||||
print(" Namespace: {}".format(args.namespace))
|
||||
print(" Email: {}".format(args.email))
|
||||
if args.name:
|
||||
print(" Name: {}".format(args.name))
|
||||
if args.journey:
|
||||
print(" Journey: {}".format(args.journey))
|
||||
|
||||
j = run(args.url, args.namespace, args.email,
|
||||
otp=args.otp, display_name=args.name, journey_id=args.journey)
|
||||
|
||||
print("\n" + "=" * 40)
|
||||
print("Results: {}/{}".format(j.passed, j.total))
|
||||
|
||||
if j.passed == j.total:
|
||||
print("All tests passed.")
|
||||
else:
|
||||
print("Some tests failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
398
remarkbox/api/remarkbox_client.py
Normal file
398
remarkbox/api/remarkbox_client.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""
|
||||
Remarkbox API Client (Python, stdlib only)
|
||||
|
||||
Download:
|
||||
curl -s https://REMARKBOX/api/v1/clients/python -o remarkbox_client.py
|
||||
wget -q https://REMARKBOX/api/v1/clients/python -O remarkbox_client.py
|
||||
|
||||
Quick start:
|
||||
from remarkbox_client import RemarkboxClient
|
||||
|
||||
client = RemarkboxClient("https://my.remarkbox.com")
|
||||
|
||||
# List threads
|
||||
result = client.list_threads("meta.remarkbox.com")
|
||||
for thread in result["threads"]:
|
||||
print(thread["title"])
|
||||
|
||||
# Read a thread and its replies
|
||||
thread = client.get_thread(thread_id)
|
||||
for reply in thread["replies"]:
|
||||
print(reply["data"])
|
||||
|
||||
# Post anonymously (namespace must allow anonymous)
|
||||
node = client.create_thread(
|
||||
namespace="meta.remarkbox.com",
|
||||
title="Hello from Python",
|
||||
data="This is a test post.",
|
||||
anonymous_name="MyBot",
|
||||
)
|
||||
|
||||
# Reply to a thread
|
||||
reply = client.reply(node["node"]["id"], data="Nice thread!")
|
||||
|
||||
# Authenticate via email OTP
|
||||
client.login("agent@example.com")
|
||||
# ... check inbox for 6-digit code ...
|
||||
client.verify("agent@example.com", "123456")
|
||||
|
||||
# Now requests are authenticated
|
||||
thread = client.create_thread(
|
||||
namespace="meta.remarkbox.com",
|
||||
title="Verified post",
|
||||
data="Posted with a session.",
|
||||
)
|
||||
|
||||
# Edit your own post
|
||||
client.edit_node(thread["node"]["id"], data="Updated content.")
|
||||
|
||||
Configuration:
|
||||
# From arguments (highest priority)
|
||||
client = RemarkboxClient("https://my.remarkbox.com")
|
||||
|
||||
# From environment variables
|
||||
# REMARKBOX_URL=https://my.remarkbox.com
|
||||
# REMARKBOX_EMAIL=agent@example.com
|
||||
client = RemarkboxClient.from_env()
|
||||
|
||||
# From config file (~/.config/remarkbox/config.json)
|
||||
# {"url": "https://my.remarkbox.com", "email": "agent@example.com"}
|
||||
client = RemarkboxClient.from_config()
|
||||
|
||||
Requires: Python 3.6+ (stdlib only, no pip install needed)
|
||||
License: Same as Remarkbox
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import http.cookiejar
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
class RemarkboxError(Exception):
|
||||
"""Raised when the API returns an error response."""
|
||||
|
||||
def __init__(self, status, body):
|
||||
self.status = status
|
||||
self.body = body
|
||||
msg = body.get("error", str(body)) if isinstance(body, dict) else str(body)
|
||||
super().__init__("HTTP {}: {}".format(status, msg))
|
||||
|
||||
|
||||
class RemarkboxClient:
|
||||
"""Remarkbox API client. Manages sessions via cookies automatically."""
|
||||
|
||||
def __init__(self, url, email=None, cookie_file=None):
|
||||
"""
|
||||
Args:
|
||||
url: Base URL of the Remarkbox instance (e.g. https://my.remarkbox.com)
|
||||
email: Optional default email for login/verify
|
||||
cookie_file: Optional path to persist session cookies across runs.
|
||||
If provided, cookies are loaded on init and saved
|
||||
after login/verify. Use this to stay logged in.
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.email = email
|
||||
self._cookie_file = cookie_file
|
||||
if cookie_file:
|
||||
self._cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file)
|
||||
if os.path.exists(cookie_file):
|
||||
self._cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
||||
else:
|
||||
self._cookie_jar = http.cookiejar.CookieJar()
|
||||
self._opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(self._cookie_jar)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
"""Create client from environment variables.
|
||||
|
||||
Reads:
|
||||
REMARKBOX_URL (required)
|
||||
REMARKBOX_EMAIL (optional)
|
||||
"""
|
||||
url = os.environ.get("REMARKBOX_URL")
|
||||
if not url:
|
||||
raise RemarkboxError(0, {"error": "REMARKBOX_URL environment variable not set"})
|
||||
email = os.environ.get("REMARKBOX_EMAIL")
|
||||
return cls(url, email=email)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, path=None):
|
||||
"""Create client from a JSON config file.
|
||||
|
||||
Default path: ~/.config/remarkbox/config.json
|
||||
|
||||
Config format:
|
||||
{"url": "https://my.remarkbox.com", "email": "agent@example.com"}
|
||||
"""
|
||||
if path is None:
|
||||
path = os.path.join(
|
||||
os.path.expanduser("~"), ".config", "remarkbox", "config.json"
|
||||
)
|
||||
with open(path) as f:
|
||||
config = json.load(f)
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
raise RemarkboxError(0, {"error": "url is required in config file"})
|
||||
return cls(url, email=config.get("email"), cookie_file=config.get("cookie_file"))
|
||||
|
||||
def _save_cookies(self):
|
||||
"""Persist cookies to disk if cookie_file was provided."""
|
||||
if self._cookie_file and hasattr(self._cookie_jar, "save"):
|
||||
self._cookie_jar.save(ignore_discard=True, ignore_expires=True)
|
||||
|
||||
def _request(self, method, path, body=None):
|
||||
"""Make an HTTP request and return parsed JSON."""
|
||||
url = self.url + path
|
||||
data = None
|
||||
headers = {}
|
||||
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
resp = self._opener.open(req)
|
||||
raw = resp.read().decode("utf-8")
|
||||
try:
|
||||
return json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
raise RemarkboxError(resp.status, {"error": "Non-JSON response"})
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8")
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except Exception:
|
||||
body = {"error": raw}
|
||||
raise RemarkboxError(e.code, body)
|
||||
|
||||
# ----- Threads -----
|
||||
|
||||
def list_threads(self, namespace, page=1):
|
||||
"""List threads in a namespace.
|
||||
|
||||
Args:
|
||||
namespace: The namespace name (e.g. "meta.remarkbox.com")
|
||||
page: Page number (default 1)
|
||||
|
||||
Returns:
|
||||
dict with keys: namespace, threads, page, page_size
|
||||
"""
|
||||
params = urllib.parse.urlencode({"namespace": namespace, "page": page})
|
||||
return self._request("GET", "/api/v1/threads?" + params)
|
||||
|
||||
def get_thread(self, node_id):
|
||||
"""Get a thread and all its replies.
|
||||
|
||||
Args:
|
||||
node_id: UUID of the root thread node
|
||||
|
||||
Returns:
|
||||
dict with keys: namespace, thread, replies
|
||||
"""
|
||||
return self._request("GET", "/api/v1/threads/{}".format(node_id))
|
||||
|
||||
def create_thread(self, namespace, title, data, anonymous_name=None, email=None):
|
||||
"""Create a new thread.
|
||||
|
||||
Args:
|
||||
namespace: Target namespace name
|
||||
title: Thread title
|
||||
data: Markdown content (max 500000 chars)
|
||||
anonymous_name: Name for anonymous posting (optional)
|
||||
email: Email to associate with post (optional)
|
||||
|
||||
Returns:
|
||||
dict with keys: node, verified
|
||||
"""
|
||||
body = {"namespace": namespace, "title": title, "data": data}
|
||||
if anonymous_name:
|
||||
body["anonymous_name"] = anonymous_name
|
||||
if email:
|
||||
body["email"] = email
|
||||
return self._request("POST", "/api/v1/threads", body)
|
||||
|
||||
# ----- Replies -----
|
||||
|
||||
def reply(self, node_id, data, anonymous_name=None, email=None):
|
||||
"""Reply to a thread or another reply.
|
||||
|
||||
Args:
|
||||
node_id: UUID of the parent node (thread or reply)
|
||||
data: Markdown content (max 500000 chars)
|
||||
anonymous_name: Name for anonymous posting (optional)
|
||||
email: Email to associate with post (optional)
|
||||
|
||||
Returns:
|
||||
dict with keys: node, verified
|
||||
"""
|
||||
body = {"data": data}
|
||||
if anonymous_name:
|
||||
body["anonymous_name"] = anonymous_name
|
||||
if email:
|
||||
body["email"] = email
|
||||
return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body)
|
||||
|
||||
# ----- Nodes -----
|
||||
|
||||
def get_node(self, node_id):
|
||||
"""Get a single node by ID.
|
||||
|
||||
Args:
|
||||
node_id: UUID of the node
|
||||
|
||||
Returns:
|
||||
dict with key: node
|
||||
"""
|
||||
return self._request("GET", "/api/v1/nodes/{}".format(node_id))
|
||||
|
||||
def edit_node(self, node_id, data=None, title=None):
|
||||
"""Edit a node (requires authentication).
|
||||
|
||||
Args:
|
||||
node_id: UUID of the node to edit
|
||||
data: New markdown content (optional)
|
||||
title: New title, only for root nodes (optional)
|
||||
|
||||
Returns:
|
||||
dict with key: node
|
||||
"""
|
||||
body = {}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
if title is not None:
|
||||
body["title"] = title
|
||||
if not body:
|
||||
raise ValueError("data or title is required")
|
||||
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body)
|
||||
|
||||
# ----- Auth -----
|
||||
|
||||
def login(self, email=None):
|
||||
"""Request an OTP code be sent to the email address.
|
||||
|
||||
Args:
|
||||
email: Email address (uses self.email if not provided)
|
||||
|
||||
Returns:
|
||||
dict with keys: status, message
|
||||
"""
|
||||
email = email or self.email
|
||||
if not email:
|
||||
raise ValueError("email is required")
|
||||
return self._request("POST", "/api/v1/auth/login", {"email": email})
|
||||
|
||||
def verify(self, email=None, otp=None):
|
||||
"""Verify an OTP code and establish an authenticated session.
|
||||
|
||||
After calling this, subsequent requests are authenticated
|
||||
via the session cookie (managed automatically).
|
||||
|
||||
Args:
|
||||
email: Email address (uses self.email if not provided)
|
||||
otp: The 6-digit verification code from email
|
||||
|
||||
Returns:
|
||||
dict with keys: status, user
|
||||
"""
|
||||
email = email or self.email
|
||||
if not email:
|
||||
raise ValueError("email is required")
|
||||
if not otp:
|
||||
raise ValueError("otp is required")
|
||||
result = self._request("POST", "/api/v1/auth/verify", {"email": email, "otp": otp})
|
||||
self._save_cookies()
|
||||
return result
|
||||
|
||||
# ----- Profile -----
|
||||
|
||||
def get_profile(self):
|
||||
"""Get the current authenticated user's profile.
|
||||
|
||||
Returns:
|
||||
dict with key: user (id, name, email)
|
||||
"""
|
||||
return self._request("GET", "/api/v1/user/profile")
|
||||
|
||||
def update_profile(self, name):
|
||||
"""Update the current user's display name.
|
||||
|
||||
Args:
|
||||
name: New display name (alphanumeric and dashes only)
|
||||
|
||||
Returns:
|
||||
dict with key: user (id, name, email)
|
||||
"""
|
||||
return self._request("PATCH", "/api/v1/user/profile", {"name": name})
|
||||
|
||||
|
||||
# ----- CLI -----
|
||||
|
||||
def main():
|
||||
"""Simple CLI for quick testing."""
|
||||
import sys
|
||||
|
||||
usage = """Usage: python remarkbox_client.py <url> <command> [args...]
|
||||
|
||||
Commands:
|
||||
threads <namespace> List threads
|
||||
thread <node_id> Get thread with replies
|
||||
node <node_id> Get a single node
|
||||
post <namespace> <title> <data> [name] Create thread (anonymous)
|
||||
reply <node_id> <data> [name] Reply to thread (anonymous)
|
||||
login <email> Request OTP
|
||||
verify <email> <otp> Verify OTP
|
||||
|
||||
Examples:
|
||||
python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com
|
||||
python remarkbox_client.py https://my.remarkbox.com post meta.remarkbox.com "Hello" "World" MyBot
|
||||
"""
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
cmd = sys.argv[2]
|
||||
args = sys.argv[3:]
|
||||
client = RemarkboxClient(url)
|
||||
|
||||
try:
|
||||
if cmd == "threads" and len(args) >= 1:
|
||||
result = client.list_threads(args[0])
|
||||
elif cmd == "thread" and len(args) >= 1:
|
||||
result = client.get_thread(args[0])
|
||||
elif cmd == "node" and len(args) >= 1:
|
||||
result = client.get_node(args[0])
|
||||
elif cmd == "post" and len(args) >= 3:
|
||||
name = args[3] if len(args) > 3 else None
|
||||
result = client.create_thread(args[0], args[1], args[2], anonymous_name=name)
|
||||
elif cmd == "reply" and len(args) >= 2:
|
||||
name = args[2] if len(args) > 2 else None
|
||||
result = client.reply(args[0], args[1], anonymous_name=name)
|
||||
elif cmd == "login" and len(args) >= 1:
|
||||
result = client.login(args[0])
|
||||
elif cmd == "verify" and len(args) >= 2:
|
||||
result = client.verify(args[0], args[1])
|
||||
else:
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
except RemarkboxError as e:
|
||||
print(json.dumps({"error": str(e), "status": e.status}, indent=2), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
from pyramid.response import Response
|
||||
from pyramid.view import view_config
|
||||
|
||||
from remarkbox.models import (
|
||||
|
|
@ -8,7 +10,11 @@ from remarkbox.models import (
|
|||
get_or_create_user_surrogate_by_name,
|
||||
get_nodes_who_share_root,
|
||||
)
|
||||
from remarkbox.models.user import get_or_create_user_by_email
|
||||
from remarkbox.models.user import (
|
||||
get_or_create_user_by_email,
|
||||
is_user_name_valid,
|
||||
is_user_name_available,
|
||||
)
|
||||
from remarkbox.models.namespace import get_or_create_namespace
|
||||
from remarkbox.lib.mail import send_verification_digits_to_email
|
||||
from remarkbox.lib.notify import schedule_notifications
|
||||
|
|
@ -492,3 +498,95 @@ def api_auth_verify(request):
|
|||
|
||||
request.response.status_code = 401
|
||||
return {"error": "Invalid verification code"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User Profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="api-user-profile",
|
||||
request_method="GET",
|
||||
renderer="json",
|
||||
require_csrf=False,
|
||||
)
|
||||
def api_get_profile(request):
|
||||
"""Get the current user's profile."""
|
||||
if not request.user or not request.user.authenticated:
|
||||
request.response.status_code = 401
|
||||
return {"error": "Authentication required"}
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": str(request.user.id),
|
||||
"name": request.user.name,
|
||||
"email": request.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="api-user-profile",
|
||||
request_method="PATCH",
|
||||
renderer="json",
|
||||
require_csrf=False,
|
||||
)
|
||||
def api_update_profile(request):
|
||||
"""Update the current user's profile (display name)."""
|
||||
if not request.user or not request.user.authenticated:
|
||||
request.response.status_code = 401
|
||||
return {"error": "Authentication required"}
|
||||
|
||||
body = get_json_body(request)
|
||||
name = body.get("name", "").strip()
|
||||
|
||||
if not name:
|
||||
request.response.status_code = 400
|
||||
return {"error": "name is required"}
|
||||
|
||||
if not is_user_name_valid(name):
|
||||
request.response.status_code = 400
|
||||
return {"error": "name must be alphanumeric (dashes allowed)"}
|
||||
|
||||
# Allow setting to current name (idempotent)
|
||||
if name.lower() != request.user.name.lower():
|
||||
if not is_user_name_available(request.dbsession, name):
|
||||
request.response.status_code = 409
|
||||
return {"error": "name is already taken"}
|
||||
|
||||
request.user.name = name
|
||||
request.dbsession.add(request.user)
|
||||
request.dbsession.flush()
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": str(request.user.id),
|
||||
"name": request.user.name,
|
||||
"email": request.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clients
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_client_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="api-client-python",
|
||||
request_method="GET",
|
||||
require_csrf=False,
|
||||
)
|
||||
def api_client_python(request):
|
||||
"""Serve the Python client for agents to download."""
|
||||
path = os.path.join(_client_dir, "remarkbox_client.py")
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
return Response(
|
||||
body=content,
|
||||
content_type="text/plain",
|
||||
charset="utf-8",
|
||||
)
|
||||
|
|
|
|||
254
remarkbox/tests/test_api_client.py
Normal file
254
remarkbox/tests/test_api_client.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import threading
|
||||
|
||||
|
||||
class FakeHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal HTTP handler for testing the client."""
|
||||
|
||||
routes = {}
|
||||
|
||||
def do_GET(self):
|
||||
self._handle()
|
||||
|
||||
def do_POST(self):
|
||||
self._handle()
|
||||
|
||||
def do_PATCH(self):
|
||||
self._handle()
|
||||
|
||||
def _handle(self):
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_len) if content_len else b""
|
||||
parsed = json.loads(body) if body else {}
|
||||
|
||||
# Store the request for assertions
|
||||
FakeHandler.last_request = {
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"body": parsed,
|
||||
"headers": dict(self.headers),
|
||||
}
|
||||
|
||||
path = self.path.split("?")[0]
|
||||
key = (self.command, path)
|
||||
if key in self.routes:
|
||||
status, response = self.routes[key]
|
||||
else:
|
||||
status, response = 200, {"ok": True}
|
||||
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress logs
|
||||
|
||||
|
||||
class TestRemarkboxClient(unittest.TestCase):
|
||||
"""Unit tests for the Python client."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = HTTPServer(("127.0.0.1", 0), FakeHandler)
|
||||
cls.port = cls.server.server_address[1]
|
||||
cls.base_url = "http://127.0.0.1:{}".format(cls.port)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever)
|
||||
cls.thread.daemon = True
|
||||
cls.thread.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
|
||||
def setUp(self):
|
||||
FakeHandler.routes = {}
|
||||
FakeHandler.last_request = None
|
||||
|
||||
def _make_client(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient
|
||||
return RemarkboxClient(self.base_url)
|
||||
|
||||
def test_list_threads(self):
|
||||
FakeHandler.routes[("GET", "/api/v1/threads")] = (
|
||||
200,
|
||||
{"threads": [{"id": "abc", "title": "Test"}], "page": 1},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.list_threads("example.com")
|
||||
self.assertEqual(result["threads"][0]["title"], "Test")
|
||||
self.assertIn("namespace=example.com", FakeHandler.last_request["path"])
|
||||
|
||||
def test_get_thread(self):
|
||||
FakeHandler.routes[("GET", "/api/v1/threads/abc-123")] = (
|
||||
200,
|
||||
{"thread": {"id": "abc-123"}, "replies": []},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.get_thread("abc-123")
|
||||
self.assertEqual(result["thread"]["id"], "abc-123")
|
||||
|
||||
def test_create_thread(self):
|
||||
FakeHandler.routes[("POST", "/api/v1/threads")] = (
|
||||
201,
|
||||
{"node": {"id": "new-1"}, "verified": True},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.create_thread(
|
||||
"example.com", "Title", "Body", anonymous_name="Bot"
|
||||
)
|
||||
self.assertEqual(result["node"]["id"], "new-1")
|
||||
body = FakeHandler.last_request["body"]
|
||||
self.assertEqual(body["namespace"], "example.com")
|
||||
self.assertEqual(body["title"], "Title")
|
||||
self.assertEqual(body["anonymous_name"], "Bot")
|
||||
|
||||
def test_reply(self):
|
||||
FakeHandler.routes[("POST", "/api/v1/threads/abc/replies")] = (
|
||||
201,
|
||||
{"node": {"id": "reply-1"}, "verified": True},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.reply("abc", "Reply text", anonymous_name="Bot")
|
||||
self.assertEqual(result["node"]["id"], "reply-1")
|
||||
self.assertEqual(FakeHandler.last_request["body"]["data"], "Reply text")
|
||||
|
||||
def test_get_node(self):
|
||||
FakeHandler.routes[("GET", "/api/v1/nodes/node-1")] = (
|
||||
200,
|
||||
{"node": {"id": "node-1", "data": "content"}},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.get_node("node-1")
|
||||
self.assertEqual(result["node"]["id"], "node-1")
|
||||
|
||||
def test_edit_node(self):
|
||||
FakeHandler.routes[("PATCH", "/api/v1/nodes/node-1")] = (
|
||||
200,
|
||||
{"node": {"id": "node-1", "data": "updated"}},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.edit_node("node-1", data="updated")
|
||||
self.assertEqual(FakeHandler.last_request["body"]["data"], "updated")
|
||||
|
||||
def test_edit_node_requires_data_or_title(self):
|
||||
client = self._make_client()
|
||||
with self.assertRaises(ValueError):
|
||||
client.edit_node("node-1")
|
||||
|
||||
def test_login(self):
|
||||
FakeHandler.routes[("POST", "/api/v1/auth/login")] = (
|
||||
200,
|
||||
{"status": "sent", "message": "Code sent."},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.login("test@example.com")
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(
|
||||
FakeHandler.last_request["body"]["email"], "test@example.com"
|
||||
)
|
||||
|
||||
def test_verify(self):
|
||||
FakeHandler.routes[("POST", "/api/v1/auth/verify")] = (
|
||||
200,
|
||||
{"status": "authenticated", "user": {"id": "u1"}},
|
||||
)
|
||||
client = self._make_client()
|
||||
result = client.verify("test@example.com", "123456")
|
||||
self.assertEqual(result["status"], "authenticated")
|
||||
body = FakeHandler.last_request["body"]
|
||||
self.assertEqual(body["email"], "test@example.com")
|
||||
self.assertEqual(body["otp"], "123456")
|
||||
|
||||
def test_login_requires_email(self):
|
||||
client = self._make_client()
|
||||
with self.assertRaises(ValueError):
|
||||
client.login()
|
||||
|
||||
def test_verify_requires_otp(self):
|
||||
client = self._make_client()
|
||||
with self.assertRaises(ValueError):
|
||||
client.verify("test@example.com")
|
||||
|
||||
def test_error_raises_remarkbox_error(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxError
|
||||
|
||||
FakeHandler.routes[("GET", "/api/v1/threads")] = (
|
||||
400,
|
||||
{"error": "namespace parameter is required"},
|
||||
)
|
||||
client = self._make_client()
|
||||
with self.assertRaises(RemarkboxError) as ctx:
|
||||
client.list_threads("")
|
||||
self.assertEqual(ctx.exception.status, 400)
|
||||
self.assertIn("namespace", str(ctx.exception))
|
||||
|
||||
def test_default_email_used(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient
|
||||
|
||||
FakeHandler.routes[("POST", "/api/v1/auth/login")] = (
|
||||
200,
|
||||
{"status": "sent"},
|
||||
)
|
||||
client = RemarkboxClient(self.base_url, email="default@example.com")
|
||||
client.login()
|
||||
self.assertEqual(
|
||||
FakeHandler.last_request["body"]["email"], "default@example.com"
|
||||
)
|
||||
|
||||
def test_from_env(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"REMARKBOX_URL": "https://test.example.com", "REMARKBOX_EMAIL": "a@b.com"},
|
||||
):
|
||||
client = RemarkboxClient.from_env()
|
||||
self.assertEqual(client.url, "https://test.example.com")
|
||||
self.assertEqual(client.email, "a@b.com")
|
||||
|
||||
def test_from_env_missing_url(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient, RemarkboxError
|
||||
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
# Remove the keys if they exist
|
||||
os.environ.pop("REMARKBOX_URL", None)
|
||||
with self.assertRaises(RemarkboxError):
|
||||
RemarkboxClient.from_env()
|
||||
|
||||
def test_from_config(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient
|
||||
|
||||
config = {"url": "https://config.example.com", "email": "c@d.com"}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(config, f)
|
||||
f.flush()
|
||||
try:
|
||||
client = RemarkboxClient.from_config(f.name)
|
||||
self.assertEqual(client.url, "https://config.example.com")
|
||||
self.assertEqual(client.email, "c@d.com")
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_url_trailing_slash_stripped(self):
|
||||
from remarkbox.api.remarkbox_client import RemarkboxClient
|
||||
|
||||
client = RemarkboxClient("https://example.com/")
|
||||
self.assertEqual(client.url, "https://example.com")
|
||||
|
||||
def test_cookies_persist_across_requests(self):
|
||||
"""Verify session cookies are maintained (important for auth flow)."""
|
||||
FakeHandler.routes[("GET", "/api/v1/threads")] = (
|
||||
200,
|
||||
{"threads": [], "page": 1},
|
||||
)
|
||||
client = self._make_client()
|
||||
# Make two requests - the cookie jar should persist
|
||||
client.list_threads("a.com")
|
||||
client.list_threads("b.com")
|
||||
# No error means the opener and cookie jar survived both calls
|
||||
|
|
@ -849,3 +849,17 @@ class TestAPINamespaceOptOut(APIFunctionalTests):
|
|||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(res.status_int, 403)
|
||||
|
||||
|
||||
class TestAPIClientDownload(APIFunctionalTests):
|
||||
"""Test the client download endpoint."""
|
||||
|
||||
def test_download_python_client(self):
|
||||
res = self.testapp.get("/api/v1/clients/python")
|
||||
self.assertEqual(res.status_int, 200)
|
||||
self.assertIn("text/plain", res.content_type)
|
||||
self.assertIn("RemarkboxClient", res.text)
|
||||
self.assertIn("def list_threads", res.text)
|
||||
self.assertIn("def reply", res.text)
|
||||
self.assertIn("def login", res.text)
|
||||
self.assertIn("def verify", res.text)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue