diff --git a/starpunk/micropub.py b/starpunk/micropub.py new file mode 100644 index 0000000..d2ca50e --- /dev/null +++ b/starpunk/micropub.py @@ -0,0 +1,400 @@ +""" +Micropub endpoint implementation for StarPunk + +This module handles Micropub protocol requests, providing a standard IndieWeb +interface for creating posts via external clients. + +Functions: + normalize_properties: Convert form/JSON data to Micropub properties format + extract_content: Get content from Micropub properties + extract_title: Get or generate title from Micropub properties + extract_tags: Get category tags from Micropub properties + handle_create: Process Micropub create action + handle_query: Process Micropub query endpoints + extract_bearer_token: Get token from Authorization header or form + +Exceptions: + MicropubError: Base exception for Micropub operations + MicropubAuthError: Authentication/authorization errors + MicropubValidationError: Invalid request data + +References: + - W3C Micropub Specification: https://www.w3.org/TR/micropub/ + - IndieAuth Specification: https://www.w3.org/TR/indieauth/ +""" + +from datetime import datetime +from typing import Optional + +from flask import Request, current_app, jsonify + +from starpunk.notes import create_note, get_note, InvalidNoteDataError, NoteNotFoundError +from starpunk.tokens import check_scope + + +# Custom Exceptions + + +class MicropubError(Exception): + """Base exception for Micropub operations""" + + def __init__(self, error: str, error_description: str, status_code: int = 400): + self.error = error + self.error_description = error_description + self.status_code = status_code + super().__init__(error_description) + + +class MicropubAuthError(MicropubError): + """Authentication or authorization error""" + + def __init__(self, error_description: str, status_code: int = 401): + super().__init__("unauthorized", error_description, status_code) + + +class MicropubValidationError(MicropubError): + """Invalid request data""" + + def __init__(self, error_description: str): + super().__init__("invalid_request", error_description, 400) + + +# Response Helpers + + +def error_response(error: str, error_description: str, status_code: int = 400): + """ + Generate OAuth 2.0 compliant error response + + Args: + error: Error code (e.g., "invalid_request") + error_description: Human-readable error description + status_code: HTTP status code + + Returns: + Tuple of (response, status_code) + """ + return ( + jsonify({"error": error, "error_description": error_description}), + status_code, + ) + + +# Token Extraction + + +def extract_bearer_token(request: Request) -> Optional[str]: + """ + Extract bearer token from Authorization header or form parameter + + Micropub spec allows token in either location: + - Authorization: Bearer + - access_token form parameter + + Args: + request: Flask request object + + Returns: + Token string if found, None otherwise + """ + # Try Authorization header first + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:] # Remove "Bearer " prefix + + # Try form parameter + if request.method == "POST": + return request.form.get("access_token") + elif request.method == "GET": + return request.args.get("access_token") + + return None + + +# Property Normalization + + +def normalize_properties(data: dict) -> dict: + """ + Normalize Micropub properties from both form and JSON formats + + Handles two input formats: + - JSON: {"type": ["h-entry"], "properties": {"content": ["value"]}} + - Form: {content: ["value"], "category[]": ["tag1", "tag2"]} + + Args: + data: Raw request data (form dict or JSON dict) + + Returns: + Normalized properties dict with all values as lists + """ + # JSON format has properties nested + if "properties" in data: + return data["properties"] + + # Form format - convert to properties dict + properties = {} + for key, value in data.items(): + # Skip reserved Micropub parameters + if key.startswith("mp-") or key in ["action", "url", "access_token", "h"]: + continue + + # Handle array notation: property[] -> property + clean_key = key.rstrip("[]") + + # Ensure value is always a list + if not isinstance(value, list): + value = [value] + + properties[clean_key] = value + + return properties + + +# Property Extraction + + +def extract_content(properties: dict) -> str: + """ + Extract content from Micropub properties + + Args: + properties: Normalized Micropub properties dict + + Returns: + Content string + + Raises: + MicropubValidationError: If content is missing or empty + """ + content_list = properties.get("content", []) + + # Handle both plain text and HTML/text objects + if not content_list: + raise MicropubValidationError("Content is required") + + content = content_list[0] + + # Handle structured content ({"html": "...", "text": "..."}) + if isinstance(content, dict): + # Prefer text over html for markdown storage + content = content.get("text") or content.get("html", "") + + if not content or not content.strip(): + raise MicropubValidationError("Content cannot be empty") + + return content.strip() + + +def extract_title(properties: dict) -> Optional[str]: + """ + Extract or generate title from Micropub properties + + Per ADR-029 mapping rules: + 1. Use 'name' property if provided + 2. If no name, extract from content (first line, max 50 chars) + + Args: + properties: Normalized Micropub properties dict + + Returns: + Title string or None + """ + # Try explicit name property first + name = properties.get("name", [""])[0] + if name: + return name.strip() + + # Generate from content (first line, max 50 chars) + content_list = properties.get("content", []) + if content_list: + content = content_list[0] + # Handle structured content + if isinstance(content, dict): + content = content.get("text") or content.get("html", "") + + if content: + first_line = content.split("\n")[0].strip() + if len(first_line) > 50: + return first_line[:50] + "..." + return first_line + + return None + + +def extract_tags(properties: dict) -> list[str]: + """ + Extract tags from Micropub category property + + Args: + properties: Normalized Micropub properties dict + + Returns: + List of tag strings + """ + categories = properties.get("category", []) + # Filter out empty strings and strip whitespace + return [tag.strip() for tag in categories if tag and tag.strip()] + + +def extract_published_date(properties: dict) -> Optional[datetime]: + """ + Extract published date from Micropub properties + + Args: + properties: Normalized Micropub properties dict + + Returns: + Datetime object if published date provided, None otherwise + """ + published = properties.get("published", [""])[0] + if not published: + return None + + try: + # Parse ISO 8601 datetime + # datetime.fromisoformat handles most ISO formats + return datetime.fromisoformat(published.replace("Z", "+00:00")) + except (ValueError, AttributeError): + # If parsing fails, log and return None (will use current time) + current_app.logger.warning(f"Failed to parse published date: {published}") + return None + + +# Action Handlers + + +def handle_create(data: dict, token_info: dict): + """ + Handle Micropub create action + + Creates a note using StarPunk's notes.py CRUD functions after + mapping Micropub properties to StarPunk's note format. + + Args: + data: Raw request data (form or JSON) + token_info: Authenticated token information (me, client_id, scope) + + Returns: + Tuple of (response_body, status_code, headers) + + Raises: + MicropubError: If scope insufficient or creation fails + """ + # Check scope + if not check_scope("create", token_info.get("scope", "")): + raise MicropubError( + "insufficient_scope", "Token lacks create scope", status_code=403 + ) + + # Normalize and extract properties + try: + properties = normalize_properties(data) + content = extract_content(properties) + title = extract_title(properties) + tags = extract_tags(properties) + published_date = extract_published_date(properties) + except MicropubValidationError as e: + raise e + except Exception as e: + current_app.logger.error(f"Property extraction failed: {e}") + raise MicropubValidationError(f"Failed to parse request: {str(e)}") + + # Create note using existing CRUD + try: + note = create_note( + content=content, published=True, created_at=published_date # Micropub posts are published by default + ) + + # Build permalink URL + site_url = current_app.config.get("SITE_URL", "http://localhost:5000") + permalink = f"{site_url}/notes/{note.slug}" + + # Return 201 Created with Location header + return "", 201, {"Location": permalink} + + except InvalidNoteDataError as e: + raise MicropubValidationError(str(e)) + except Exception as e: + current_app.logger.error(f"Failed to create note via Micropub: {e}") + raise MicropubError( + "server_error", "Failed to create post", status_code=500 + ) + + +def handle_query(args: dict, token_info: dict): + """ + Handle Micropub query endpoints + + Supports: + - q=config: Return server configuration + - q=source: Return post source in Microformats2 JSON + - q=syndicate-to: Return syndication targets (empty for V1) + + Args: + args: Query string arguments + token_info: Authenticated token information + + Returns: + Tuple of (response, status_code) + """ + q = args.get("q") + + if q == "config": + # Return server configuration + config = { + "media-endpoint": None, # No media endpoint in V1 + "syndicate-to": [], # No syndication targets in V1 + "post-types": [{"type": "note", "name": "Note", "properties": ["content"]}], + } + return jsonify(config), 200 + + elif q == "source": + # Return source of a specific post + url = args.get("url") + if not url: + return error_response("invalid_request", "No URL provided") + + # Extract slug from URL + try: + # URL format: https://example.com/notes/{slug} + slug = url.rstrip("/").split("/")[-1] + note = get_note(slug) + + # Check if note exists + if note is None: + return error_response("invalid_request", "Post not found") + + except NoteNotFoundError: + return error_response("invalid_request", "Post not found") + except Exception as e: + current_app.logger.error(f"Failed to get note source: {e}") + return error_response("server_error", "Failed to retrieve post") + + # Convert note to Micropub Microformats2 format + site_url = current_app.config.get("SITE_URL", "http://localhost:5000") + mf2 = { + "type": ["h-entry"], + "properties": { + "content": [note.content], + "published": [note.created_at.isoformat()], + "url": [f"{site_url}/notes/{note.slug}"], + }, + } + + # Add optional properties + if note.title: + mf2["properties"]["name"] = [note.title] + + # Tags not implemented in V1, skip category property + # if hasattr(note, 'tags') and note.tags: + # mf2["properties"]["category"] = note.tags + + return jsonify(mf2), 200 + + elif q == "syndicate-to": + # Return syndication targets (none for V1) + return jsonify({"syndicate-to": []}), 200 + + else: + return error_response("invalid_request", f"Unknown query: {q}") diff --git a/starpunk/routes/__init__.py b/starpunk/routes/__init__.py index 1cd5007..e23e85f 100644 --- a/starpunk/routes/__init__.py +++ b/starpunk/routes/__init__.py @@ -7,7 +7,7 @@ admin, auth, and (conditionally) dev auth routes. from flask import Flask -from starpunk.routes import admin, auth, public +from starpunk.routes import admin, auth, micropub, public def register_routes(app: Flask) -> None: @@ -19,7 +19,8 @@ def register_routes(app: Flask) -> None: Registers: - Public routes (homepage, note permalinks) - - Auth routes (login, callback, logout) + - Auth routes (login, callback, logout, token, authorization) + - Micropub routes (Micropub API endpoint) - Admin routes (dashboard, note management) - Dev auth routes (if DEV_MODE enabled) """ @@ -29,6 +30,9 @@ def register_routes(app: Flask) -> None: # Register auth routes app.register_blueprint(auth.bp) + # Register Micropub routes + app.register_blueprint(micropub.bp) + # Register admin routes app.register_blueprint(admin.bp) diff --git a/starpunk/routes/micropub.py b/starpunk/routes/micropub.py new file mode 100644 index 0000000..30c6cc8 --- /dev/null +++ b/starpunk/routes/micropub.py @@ -0,0 +1,121 @@ +""" +Micropub endpoint routes for StarPunk + +Implements the W3C Micropub specification for creating posts via +external IndieWeb clients. + +Endpoints: + GET/POST /micropub - Main Micropub endpoint + GET: Query operations (config, source, syndicate-to) + POST: Action operations (create in V1, update/delete in future) + +Authentication: + Bearer token authentication required for all endpoints. + Token must have appropriate scope for requested operation. + +References: + - W3C Micropub Specification: https://www.w3.org/TR/micropub/ + - ADR-028: Micropub Implementation Strategy + - ADR-029: Micropub IndieAuth Integration Strategy +""" + +from flask import Blueprint, current_app, request + +from starpunk.micropub import ( + MicropubError, + extract_bearer_token, + error_response, + handle_create, + handle_query, +) +from starpunk.tokens import verify_token + +# Create blueprint +bp = Blueprint("micropub", __name__) + + +@bp.route("/micropub", methods=["GET", "POST"]) +def micropub_endpoint(): + """ + Main Micropub endpoint for all operations + + GET requests: + Handle query operations via q= parameter: + - q=config: Return server capabilities + - q=source&url={url}: Return post source + - q=syndicate-to: Return syndication targets + + POST requests: + Handle action operations (form-encoded or JSON): + - action=create (or no action): Create new post + - action=update: Update existing post (not supported in V1) + - action=delete: Delete post (not supported in V1) + + Authentication: + Requires valid bearer token in Authorization header or + access_token parameter. + + Returns: + GET: JSON response with query results + POST create: 201 Created with Location header + POST other: Error responses + + Error responses follow OAuth 2.0 format: + { + "error": "error_code", + "error_description": "Human-readable description" + } + """ + # Extract and verify token + token = extract_bearer_token(request) + if not token: + return error_response("unauthorized", "No access token provided", 401) + + token_info = verify_token(token) + if not token_info: + return error_response("unauthorized", "Invalid or expired access token", 401) + + # Handle query endpoints (GET requests) + if request.method == "GET": + try: + return handle_query(request.args.to_dict(), token_info) + except MicropubError as e: + return error_response(e.error, e.error_description, e.status_code) + except Exception as e: + current_app.logger.error(f"Micropub query error: {e}") + return error_response("server_error", "An unexpected error occurred", 500) + + # Handle action endpoints (POST requests) + content_type = request.headers.get("Content-Type", "") + + try: + # Parse request based on content type + if "application/json" in content_type: + data = request.get_json() or {} + action = data.get("action", "create") + else: + # Form-encoded or multipart (V1 only supports form-encoded) + data = request.form.to_dict(flat=False) + action = data.get("action", ["create"])[0] + + # Route to appropriate handler + if action == "create": + return handle_create(data, token_info) + elif action == "update": + # V1: Update not supported + return error_response( + "invalid_request", "Update action not supported in V1", 400 + ) + elif action == "delete": + # V1: Delete not supported + return error_response( + "invalid_request", "Delete action not supported in V1", 400 + ) + else: + return error_response("invalid_request", f"Unknown action: {action}", 400) + + except MicropubError as e: + return error_response(e.error, e.error_description, e.status_code) + except Exception as e: + current_app.logger.error(f"Micropub action error: {e}") + return error_response("server_error", "An unexpected error occurred", 500) diff --git a/tests/test_micropub.py b/tests/test_micropub.py new file mode 100644 index 0000000..aeada04 --- /dev/null +++ b/tests/test_micropub.py @@ -0,0 +1,450 @@ +""" +Tests for Micropub endpoint + +Tests the /micropub endpoint for creating posts via IndieWeb clients. +Covers both form-encoded and JSON requests, authentication, and error handling. +""" + +import pytest +from starpunk.tokens import create_access_token +from starpunk.notes import get_note + + +# Helper function to create a valid access token for testing + + +@pytest.fixture +def valid_token(app): + """Create a valid access token with create scope""" + with app.app_context(): + return create_access_token( + me="https://user.example", + client_id="https://client.example", + scope="create" + ) + + +@pytest.fixture +def read_only_token(app): + """Create a token without create scope""" + with app.app_context(): + return create_access_token( + me="https://user.example", + client_id="https://client.example", + scope="read" # Not a valid scope, but tests scope checking + ) + + +# Authentication Tests + + +def test_micropub_no_token(client): + """Test Micropub endpoint rejects requests without token""" + response = client.post('/micropub', data={ + 'h': 'entry', + 'content': 'Test post' + }) + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == 'unauthorized' + assert 'access token' in data['error_description'].lower() + + +def test_micropub_invalid_token(client): + """Test Micropub endpoint rejects invalid tokens""" + response = client.post('/micropub', + headers={'Authorization': 'Bearer invalid_token_12345'}, + data={ + 'h': 'entry', + 'content': 'Test post' + }) + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == 'unauthorized' + assert 'invalid' in data['error_description'].lower() or 'expired' in data['error_description'].lower() + + +def test_micropub_insufficient_scope(client, app, read_only_token): + """Test Micropub endpoint rejects tokens without create scope""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {read_only_token}'}, + data={ + 'h': 'entry', + 'content': 'Test post' + }) + + assert response.status_code == 403 + data = response.get_json() + assert data['error'] == 'insufficient_scope' + + +# Create Action - Form-Encoded Tests + + +def test_micropub_create_form_encoded(client, app, valid_token): + """Test creating a note with form-encoded request""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': 'This is a test post from Micropub' + }, + content_type='application/x-www-form-urlencoded') + + assert response.status_code == 201 + assert 'Location' in response.headers + location = response.headers['Location'] + assert '/notes/' in location + + # Verify note was created + with app.app_context(): + slug = location.split('/')[-1] + note = get_note(slug) + assert note is not None + assert note.content == 'This is a test post from Micropub' + assert note.published is True + + +def test_micropub_create_with_title(client, app, valid_token): + """Test creating note with explicit title (name property)""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'name': 'My Test Title', + 'content': 'Content of the post' + }) + + assert response.status_code == 201 + + with app.app_context(): + slug = response.headers['Location'].split('/')[-1] + note = get_note(slug) + # Note: Current create_note doesn't support title, this may need adjustment + assert note.content == 'Content of the post' + + +def test_micropub_create_with_categories(client, app, valid_token): + """Test creating note with categories (tags)""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': 'Post with tags', + 'category[]': ['indieweb', 'micropub', 'testing'] + }) + + assert response.status_code == 201 + + with app.app_context(): + slug = response.headers['Location'].split('/')[-1] + note = get_note(slug) + # Note: Need to verify tag storage format in notes.py + assert note.content == 'Post with tags' + + +def test_micropub_create_missing_content(client, valid_token): + """Test Micropub rejects posts without content""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry' + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + assert 'content' in data['error_description'].lower() + + +def test_micropub_create_empty_content(client, valid_token): + """Test Micropub rejects posts with empty content""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': ' ' # Only whitespace + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + + +# Create Action - JSON Tests + + +def test_micropub_create_json(client, app, valid_token): + """Test creating note with JSON request""" + response = client.post('/micropub', + headers={ + 'Authorization': f'Bearer {valid_token}', + 'Content-Type': 'application/json' + }, + json={ + 'type': ['h-entry'], + 'properties': { + 'content': ['This is a JSON test post'] + } + }) + + assert response.status_code == 201 + assert 'Location' in response.headers + + with app.app_context(): + slug = response.headers['Location'].split('/')[-1] + note = get_note(slug) + assert note.content == 'This is a JSON test post' + + +def test_micropub_create_json_with_name_and_categories(client, app, valid_token): + """Test creating note with JSON including name and categories""" + response = client.post('/micropub', + headers={ + 'Authorization': f'Bearer {valid_token}', + 'Content-Type': 'application/json' + }, + json={ + 'type': ['h-entry'], + 'properties': { + 'name': ['Test Note Title'], + 'content': ['JSON post content'], + 'category': ['test', 'json', 'micropub'] + } + }) + + assert response.status_code == 201 + + with app.app_context(): + slug = response.headers['Location'].split('/')[-1] + note = get_note(slug) + assert note.content == 'JSON post content' + + +def test_micropub_create_json_structured_content(client, app, valid_token): + """Test creating note with structured content (html/text object)""" + response = client.post('/micropub', + headers={ + 'Authorization': f'Bearer {valid_token}', + 'Content-Type': 'application/json' + }, + json={ + 'type': ['h-entry'], + 'properties': { + 'content': [{ + 'text': 'Plain text version', + 'html': '

HTML version

' + }] + } + }) + + assert response.status_code == 201 + + with app.app_context(): + slug = response.headers['Location'].split('/')[-1] + note = get_note(slug) + # Should prefer text over html + assert note.content == 'Plain text version' + + +# Token Location Tests + + +def test_micropub_token_in_form_parameter(client, app, valid_token): + """Test token can be provided as form parameter""" + response = client.post('/micropub', + data={ + 'h': 'entry', + 'content': 'Test with form token', + 'access_token': valid_token + }) + + assert response.status_code == 201 + + +def test_micropub_token_in_query_parameter(client, app, valid_token): + """Test token in query parameter for GET requests""" + response = client.get(f'/micropub?q=config&access_token={valid_token}') + + assert response.status_code == 200 + + +# V1 Limitation Tests + + +def test_micropub_update_not_supported(client, valid_token): + """Test update action returns error in V1""" + response = client.post('/micropub', + headers={ + 'Authorization': f'Bearer {valid_token}', + 'Content-Type': 'application/json' + }, + json={ + 'action': 'update', + 'url': 'https://example.com/notes/test', + 'replace': { + 'content': ['Updated content'] + } + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + assert 'not supported' in data['error_description'] + + +def test_micropub_delete_not_supported(client, valid_token): + """Test delete action returns error in V1""" + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'action': 'delete', + 'url': 'https://example.com/notes/test' + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + assert 'not supported' in data['error_description'] + + +# Query Endpoint Tests + + +def test_micropub_query_config(client, valid_token): + """Test q=config query endpoint""" + response = client.get('/micropub?q=config', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 200 + data = response.get_json() + + # Check required fields + assert 'media-endpoint' in data + assert 'syndicate-to' in data + assert data['media-endpoint'] is None # V1 has no media endpoint + assert data['syndicate-to'] == [] # V1 has no syndication + + +def test_micropub_query_syndicate_to(client, valid_token): + """Test q=syndicate-to query endpoint""" + response = client.get('/micropub?q=syndicate-to', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 200 + data = response.get_json() + assert 'syndicate-to' in data + assert data['syndicate-to'] == [] # V1 has no syndication targets + + +def test_micropub_query_source(client, app, valid_token): + """Test q=source query endpoint""" + # First create a post + with app.app_context(): + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': 'Test post for source query' + }) + + assert response.status_code == 201 + note_url = response.headers['Location'] + + # Query the source + response = client.get(f'/micropub?q=source&url={note_url}', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 200 + data = response.get_json() + + # Check Microformats2 structure + assert data['type'] == ['h-entry'] + assert 'properties' in data + assert 'content' in data['properties'] + assert data['properties']['content'][0] == 'Test post for source query' + + +def test_micropub_query_source_missing_url(client, valid_token): + """Test q=source without URL parameter returns error""" + response = client.get('/micropub?q=source', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + assert 'url' in data['error_description'].lower() + + +def test_micropub_query_source_not_found(client, valid_token): + """Test q=source with non-existent URL returns error""" + response = client.get('/micropub?q=source&url=https://example.com/notes/nonexistent', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 400 + data = response.get_json() + assert 'not found' in data['error_description'].lower() + + +def test_micropub_query_unknown(client, valid_token): + """Test unknown query parameter returns error""" + response = client.get('/micropub?q=unknown', + headers={'Authorization': f'Bearer {valid_token}'}) + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'invalid_request' + assert 'unknown' in data['error_description'].lower() + + +# Integration Tests + + +def test_micropub_end_to_end_flow(client, app, valid_token): + """Test complete flow: create post, query config, query source""" + # 1. Get config + response = client.get('/micropub?q=config', + headers={'Authorization': f'Bearer {valid_token}'}) + assert response.status_code == 200 + + # 2. Create post + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': 'End-to-end test post', + 'category[]': ['test', 'integration'] + }) + assert response.status_code == 201 + note_url = response.headers['Location'] + + # 3. Query source + response = client.get(f'/micropub?q=source&url={note_url}', + headers={'Authorization': f'Bearer {valid_token}'}) + assert response.status_code == 200 + data = response.get_json() + assert data['properties']['content'][0] == 'End-to-end test post' + + +def test_micropub_multiple_posts(client, app, valid_token): + """Test creating multiple posts in sequence""" + for i in range(3): + response = client.post('/micropub', + headers={'Authorization': f'Bearer {valid_token}'}, + data={ + 'h': 'entry', + 'content': f'Test post number {i+1}' + }) + + assert response.status_code == 201 + assert 'Location' in response.headers + + # Verify all notes were created + with app.app_context(): + from starpunk.notes import list_notes + notes = list_notes() + # Filter to published notes with our test content + test_notes = [n for n in notes if n.published and 'Test post number' in n.content] + assert len(test_notes) == 3