Get and auto-create categories and tags

This commit is contained in:
Dejvino
2026-07-30 19:09:50 +02:00
parent ebc2de4190
commit 072f74ed2d
6 changed files with 331 additions and 57 deletions
+80 -18
View File
@@ -1,28 +1,90 @@
#!/usr/bin/env python3
# Minimal mock of the WordPress REST API /wp/v2/posts endpoint, for testing
# post.sh without a real WordPress install.
# Minimal mock of the WordPress REST API, for testing post.sh and terms.sh
# without a real WordPress install.
#
# Behavior is controlled by two files (paths given via env vars):
# Handles POST /wp-json/wp/v2/posts(/ID) same as before, plus
# GET/POST /wp-json/wp/v2/categories and /wp-json/wp/v2/tags.
#
# Behavior is controlled by files (paths given via env vars):
# MOCK_WP_CONTROL_FILE - optional JSON {"status": int, "body": {...}} that
# determines the response to the next request. If absent, responds
# 201 with the submitted fields echoed back under an "id".
# determines the response to the next POST /posts request. If absent,
# responds 201 with the submitted fields echoed back under an "id".
# MOCK_WP_REQUEST_FILE - overwritten on every request with
# {"path": ..., "payload": ..., "user_agent": ...} so a test can assert
# on what was sent.
# MOCK_WP_TERMS_FILE - JSON {"categories": [...], "tags": [...]}, used as
# the backing store for the categories/tags endpoints. Created terms
# are appended and persisted back to this file.
#
# Listens on 127.0.0.1:$MOCK_WP_PORT (default 8899).
import http.server
import json
import os
import re
from urllib.parse import urlparse, parse_qs
PORT = int(os.environ.get("MOCK_WP_PORT", "8899"))
CONTROL_FILE = os.environ["MOCK_WP_CONTROL_FILE"]
REQUEST_FILE = os.environ["MOCK_WP_REQUEST_FILE"]
TERMS_FILE = os.environ["MOCK_WP_TERMS_FILE"]
TERM_TYPES = {
"/wp-json/wp/v2/categories": "categories",
"/wp-json/wp/v2/tags": "tags",
}
def slugify(name):
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def load_terms():
if os.path.exists(TERMS_FILE):
with open(TERMS_FILE) as f:
return json.load(f)
return {"categories": [], "tags": []}
def save_terms(terms):
with open(TERMS_FILE, "w") as f:
json.dump(terms, f)
class Handler(http.server.BaseHTTPRequestHandler):
def _write_json(self, status, body):
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _record_request(self, path, data):
with open(REQUEST_FILE, "w") as f:
json.dump({
"path": path,
"payload": data,
"user_agent": self.headers.get("User-Agent", ""),
}, f)
def do_GET(self):
parsed = urlparse(self.path)
term_type = TERM_TYPES.get(parsed.path)
if term_type is None:
self._write_json(404, {"code": "not_found"})
return
qs = parse_qs(parsed.query)
per_page = int(qs.get("per_page", ["10"])[0])
page = int(qs.get("page", ["1"])[0])
terms = load_terms()[term_type]
start = (page - 1) * per_page
self._write_json(200, terms[start:start + per_page])
def do_POST(self):
parsed = urlparse(self.path)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
@@ -30,12 +92,18 @@ class Handler(http.server.BaseHTTPRequestHandler):
except json.JSONDecodeError:
data = {"_raw": body.decode(errors="replace")}
with open(REQUEST_FILE, "w") as f:
json.dump({
"path": self.path,
"payload": data,
"user_agent": self.headers.get("User-Agent", ""),
}, f)
self._record_request(self.path, data)
term_type = TERM_TYPES.get(parsed.path)
if term_type is not None:
terms = load_terms()
next_id = max([t["id"] for t in terms[term_type]], default=0) + 1
name = data.get("name", "")
term = {"id": next_id, "name": name, "slug": slugify(name), "count": 0}
terms[term_type].append(term)
save_terms(terms)
self._write_json(201, term)
return
status, resp_body = 201, {"id": 1, **data}
if os.path.exists(CONTROL_FILE):
@@ -43,13 +111,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
control = json.load(f)
status = control.get("status", status)
resp_body = control.get("body", resp_body)
payload = json.dumps(resp_body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
self._write_json(status, resp_body)
def log_message(self, format, *args):
pass
+54
View File
@@ -16,6 +16,7 @@ trap 'kill $SERVER_PID 2>/dev/null; rm -rf "$WORKDIR"' EXIT
export MOCK_WP_PORT=$PORT
export MOCK_WP_CONTROL_FILE="$WORKDIR/control.json"
export MOCK_WP_REQUEST_FILE="$WORKDIR/request.json"
export MOCK_WP_TERMS_FILE="$WORKDIR/terms.json"
python3 -u tests/mock_wp_server.py &
SERVER_PID=$!
@@ -53,6 +54,10 @@ run_post_sh() {
printf "" | "$PWD/post.sh" "$@"
}
run_terms_sh() {
"$PWD/terms.sh" "$@"
}
FAIL=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
@@ -146,6 +151,55 @@ assert_eq "error: exit code" "1" "$?"
assert_eq "error: HTTP status surfaced" "true" "$(grep -q "HTTP 401" <<<"$OUT" && echo true || echo false)"
rm -f "$MOCK_WP_CONTROL_FILE"
# --- terms.sh: list starts empty ---
rm -f "$MOCK_WP_TERMS_FILE"
OUT=$(run_terms_sh --list)
assert_eq "terms list: exit code" "0" "$?"
assert_eq "terms list: empty when no categories exist" "" "$OUT"
# --- terms.sh: create a category, then find and list it ---
OUT=$(run_terms_sh --create "Hardware")
assert_eq "terms create: exit code" "0" "$?"
assert_eq "terms create: prints the new id" "1" "$OUT"
OUT=$(run_terms_sh --find "Hardware")
assert_eq "terms find: exit code" "0" "$?"
assert_eq "terms find: resolves name to id" "1" "$OUT"
OUT=$(run_terms_sh --list)
assert_eq "terms list: shows created category" "1 Hardware hardware 0" "$OUT"
# --- terms.sh: categories and tags are separate namespaces ---
OUT=$(run_terms_sh --type tag --create "arduino")
assert_eq "terms create tag: exit code" "0" "$?"
assert_eq "terms create tag: gets its own id sequence" "1" "$OUT"
OUT=$(run_terms_sh --type tag --list)
assert_eq "terms list tags: shows only the tag, not the category" "1 arduino arduino 0" "$OUT"
# --- terms.sh: --find on a name that doesn't exist ---
OUT=$(run_terms_sh --find "Does Not Exist" 2>&1)
assert_eq "terms find missing: exits non-zero" "1" "$?"
assert_eq "terms find missing: error message" "true" "$(grep -q "No category named" <<<"$OUT" && echo true || echo false)"
# --- post.sh: --category/--tag by name resolve to an existing term, merged with config IDs ---
rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE"
run_post_sh --file "$POST_FILE" --category "Hardware" >/dev/null
assert_eq "post category resolve: exit code" "0" "$?"
REQ=$(cat "$MOCK_WP_REQUEST_FILE")
assert_eq "post category resolve: merges existing id with config CATEGORIES" "[1,3,7]" "$(jq -c '.payload.categories | sort' <<<"$REQ")"
assert_eq "post category resolve: does not create a duplicate" "1" "$(run_terms_sh --list | wc -l)"
# --- post.sh: --category/--tag by a new name creates it, then attaches it ---
rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE"
run_post_sh --file "$POST_FILE" --category "Software" --tag "midi" >/dev/null
assert_eq "post category create: exit code" "0" "$?"
NEW_CAT_ID=$(run_terms_sh --find "Software")
NEW_TAG_ID=$(run_terms_sh --type tag --find "midi")
REQ=$(cat "$MOCK_WP_REQUEST_FILE")
assert_eq "post category create: includes the newly created category" "true" "$(jq --argjson id "$NEW_CAT_ID" '.payload.categories | index($id) != null' <<<"$REQ")"
assert_eq "post tag create: includes the newly created tag" "true" "$(jq --argjson id "$NEW_TAG_ID" '.payload.tags | index($id) != null' <<<"$REQ")"
echo
if [[ $FAIL -eq 0 ]]; then
echo "All tests passed."