From d83ee4bd92f36e5ca570f4bc3909f5bc683c70a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:10:57 +0000 Subject: [PATCH] Add write abilities wrapping the bibliography write routes (Phase 05 M5) Six abilities on WordPress 6.9+: borges/add-citations, update-citation, delete-citation, reorder-citations, update-bibliography-settings, and reformat-bibliography. They are registered only when the write-routes filter is on. Each builds the request its route would get and calls the route's own callback, so edit_post, dry run, and the ETag check (etag input sent as If-Match) are one code path for both surfaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KbVuMPN7YTcV27vwsG3LZ2 --- .planning/ROADMAP.md | 11 +- .../05-DESIGN-MEMO.md | 4 +- CHANGELOG.md | 4 + CLAUDE.md | 1 + bibliography-builder.php | 1 + docs/current-metrics.md | 4 +- docs/rest-write-routes.md | 17 +- includes/write-abilities.php | 386 ++++++++++++++++++ tests/phpunit/WriteAbilitiesTest.php | 296 ++++++++++++++ tests/phpunit/bootstrap.php | 11 + 10 files changed, 725 insertions(+), 10 deletions(-) create mode 100644 includes/write-abilities.php create mode 100644 tests/phpunit/WriteAbilitiesTest.php diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d7507a0..b7e1ac2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -410,12 +410,11 @@ or pull requests are open. Rough payoff-per-effort order for the 1.11 line: runs on pull requests), the `main-preview` Playground build, and the live resolver checks (DOI, PMID, PMCID, arXiv, ISBN, catalog, Internet Archive, Abilities) -2. **Phase 05 M5: write abilities** — unblocked - - M0–M3 shipped in 1.7.0 and the Abilities API is in core (WordPress - 6.9), so nothing in the design memo's sequencing table holds M5 back. - Only the six read-only abilities in `includes/abilities.php` exist - today. Write abilities should wrap the M2/M3 write-route helpers and - inherit their opt-in filter, `edit_post`, dry-run, and `If-Match` rules +2. **Phase 05 M5: write abilities** — done (unreleased) + - six write abilities in `includes/write-abilities.php` wrap the M2/M3 + route callbacks and inherit their opt-in filter, `edit_post`, dry-run, + and `If-Match` (`etag` input) rules. Tier 4 (bulk) is the only Phase 05 + milestone left 3. **Reference-manager export corpus** — pending todo `2026-06-23-test-reference-manager-exports.md`; **needs the owner** - a hand-authored corpus landed in #91 diff --git a/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md b/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md index f270650..e33ec09 100644 --- a/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md +++ b/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md @@ -270,6 +270,8 @@ This decision should be revisited once Tier 2 is prototyped and the static-save | M2 | Tier 2 add/update/delete/reorder behind an opt-in filter — **done (unreleased)**; see `docs/rest-write-routes.md` | M1 + static-save spike | | M3 | Reformat, reorder, ETag (Tier 2 complete) — **done (unreleased)**: reorder and ETag shipped with M2, block settings and reformat (Tier 3) with M3 | M2 validated | | M4 | Bulk routes (Tier 4) | M3 + rate-limiting design | -| M5 | Abilities registration (Tier 5) | WP Abilities API stable | +| M5 | Abilities registration (Tier 5) — **done (unreleased)**: six write abilities wrapping the M2/M3 routes | WP Abilities API stable | **2026-09-24 update:** the Abilities API is in core as of WordPress 6.9, so M5's blocker is gone for the read side. The read-only cut shipped ahead of the other milestones, in 1.6.0: `borges/get-bibliographies`, `borges/export-bibliography`, and `borges/validate-citations` in `includes/abilities.php`, using core's `wp_register_ability()` on `wp_abilities_api_init` with `readonly` annotations. The memo's `bibliography/read` naming was replaced by core's required `namespace/action` form under the `borges/` namespace. Writable abilities stay behind M0–M3. + +**2026-10-14 update:** the write abilities shipped behind the write-routes filter in `includes/write-abilities.php`: `borges/add-citations`, `borges/update-citation`, `borges/delete-citation`, `borges/reorder-citations`, `borges/update-bibliography-settings`, and `borges/reformat-bibliography`. Rather than re-implement the routes, each ability builds the request its route would receive and calls the route's callback, so permissions, dry runs, and the ETag check cannot drift between the two surfaces. `etag` in the input is sent as `If-Match`. Tier 4 (bulk) is the only milestone left. diff --git a/CHANGELOG.md b/CHANGELOG.md index bba0a62..e3bda05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Write abilities** (Phase 05, M5), on WordPress 6.9+ and only when the write routes are enabled with the `bibliography_builder_enable_write_routes` filter: `borges/add-citations`, `borges/update-citation`, `borges/delete-citation`, `borges/reorder-citations`, `borges/update-bibliography-settings`, and `borges/reformat-bibliography`. Each calls the matching write route's own code, with the same rules: `edit_post` on the post, a preview unless `dry_run` is false, and an `etag` from a read or dry run to save. See `docs/rest-write-routes.md`. + ## [1.11.0] - 2026-10-14 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 67d7ea4..9986d6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ A Gutenberg block plugin that accepts DOI identifiers, PubMed/PMID records, BibT - `GET /posts/{post_id}/bibliographies` — list all bibliography blocks in post - `GET /posts/{post_id}/bibliographies/{index}` — single bibliography; supports `?format=json|text|csl-json` - Write routes (Phase 05 M2 and M3, `includes/write-routes.php`): POST/PATCH/DELETE/PUT under `/posts/{post_id}/bibliographies/{ref}/citations`, plus PATCH `/posts/{post_id}/bibliographies/{ref}` (block settings) and POST `…/{ref}/reformat` (style switch, mirroring the editor's `handleCitationStyleChange`). They are **off unless** the `bibliography_builder_enable_write_routes` filter returns true. They need `edit_post`, are dry runs unless `dry_run=false`, and need `If-Match` (the post's ETag, a hash of its content). A write rebuilds markup with the `save()` port and splices only that block via `includes/block-locator.php`. See `docs/rest-write-routes.md`. +- Write abilities (Phase 05 M5, `includes/write-abilities.php`): `borges/add-citations`, `update-citation`, `delete-citation`, `reorder-citations`, `update-bibliography-settings`, `reformat-bibliography`. Registered only when the write-routes filter is on; each builds the matching route's request and calls that route's callback, so `edit_post`, dry run, and `etag` (sent as `If-Match`) behave identically. - The PMID, PMCID, arXiv, ISBN, catalog, and Internet Archive route callbacks, their provider constants (fixed upstream URLs, timeout, cache TTLs), and the NCBI cache helpers live in `includes/resolvers.php`, loaded by `require_once` from the main file. Route registration and the `edit_posts` permission callbacks stay in `bibliography-builder.php`. - `includes/google-books-key.php` reads an optional Google Books API key from the `BIBLIOGRAPHY_BUILDER_GOOGLE_BOOKS_API_KEY` constant, then the `bibliography_builder_google_books_api_key` filter. It is added only to the ISBN resolver's Google Books fallback request. There is deliberately no option or settings screen: `verify-metrics.sh` enforces that the plugin persists no settings. - Read-only Abilities API integration (WordPress 6.9+) lives in `includes/abilities.php`: `borges/get-bibliographies`, `borges/export-bibliography`, `borges/validate-citations` in a `bibliography` category. They reuse the REST read/permission helpers; on older WordPress the `wp_abilities_api_*` hooks never fire. diff --git a/bibliography-builder.php b/bibliography-builder.php index 391f986..1e6ffc8 100644 --- a/bibliography-builder.php +++ b/bibliography-builder.php @@ -2052,6 +2052,7 @@ function bibliography_builder_block_init() { require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/frontend-json-ld.php'; require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/block-locator.php'; require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/write-routes.php'; +require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/write-abilities.php'; add_filter( 'rest_pre_serve_request', 'bibliography_builder_rest_pre_serve_request', 10, 4 ); /** diff --git a/docs/current-metrics.md b/docs/current-metrics.md index 7532046..47a26d8 100644 --- a/docs/current-metrics.md +++ b/docs/current-metrics.md @@ -11,8 +11,8 @@ Source and LOC figures last verified: **2026-10-06** against the heading-level b | Metric | Value | Re-derivation command | |---|---|---| -| Main plugin file (`bibliography-builder.php`) | **2,185** | `wc -l bibliography-builder.php` | -| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **9,537** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | +| Main plugin file (`bibliography-builder.php`) | **2,186** | `wc -l bibliography-builder.php` | +| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **9,924** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | | JS source (`src/`, excl. `*.test.js`) | **11,198** | `find ./src -name '*.js' -not -name '*.test.js' -print0 \| xargs -0 wc -l \| tail -1` | | Shipped frontend runtime (`build/view.js`, minified) | **1,449 bytes** | `npm run build` then `wc -c < build/view.js` | diff --git a/docs/rest-write-routes.md b/docs/rest-write-routes.md index cc6934a..3c008eb 100644 --- a/docs/rest-write-routes.md +++ b/docs/rest-write-routes.md @@ -147,6 +147,21 @@ The stored CSL-JSON is not changed. If any entry has CSL-JSON the formatter cann - **HTML filtering:** WordPress filters saved HTML for users without `unfiltered_html`, in exactly the same way as it does for the editor. - **intl:** the PHP `intl` extension is required. Without it, writes return `501`. +## Abilities + +On WordPress 6.9 and later, the same writes are also available as abilities through the Abilities API (`includes/write-abilities.php`). They are registered only when the write routes are enabled, in the `bibliography` category next to the read-only abilities: + +| Ability | Route it wraps | Input beyond the common fields | +|---|---|---| +| `borges/add-citations` | `POST …/citations` | `items` | +| `borges/update-citation` | `PATCH …/citations/` | `citation_id`, `fields` | +| `borges/delete-citation` | `DELETE …/citations/` | `citation_id` | +| `borges/reorder-citations` | `PUT …/citations/order` | `ids` | +| `borges/update-bibliography-settings` | `PATCH …/bibliographies/` | `settings` | +| `borges/reformat-bibliography` | `POST …/reformat` | `style` | + +Every ability takes `post_id` and either `index` or `bibliography_id` to name the block, plus `dry_run` (default `true`) and `etag`. Each one builds the request its route would get and calls the route's own callback, so the rules are the same: `edit_post` on the post, a preview unless `dry_run` is `false`, and to save, `etag` set to the ETag from a read or dry run. Without `etag` a save is refused with status `428`, and with a stale one `412`. The output is the route's response body, `etag` included. Only `borges/delete-citation` is annotated as destructive. + ## Still to come -Later milestones are bulk routes across posts (Tier 4) and write abilities (Tier 5). See the design memo's sequencing table. +The remaining milestone is bulk routes across posts (Tier 4). See the design memo's sequencing table. diff --git a/includes/write-abilities.php b/includes/write-abilities.php new file mode 100644 index 0000000..727f997 --- /dev/null +++ b/includes/write-abilities.php @@ -0,0 +1,386 @@ + array( + 'readonly' => false, + 'destructive' => $destructive, + 'idempotent' => $idempotent, + ), + 'show_in_rest' => true, + ); +} + +/** + * Register the write abilities when the site has enabled the write routes. + * + * @return void + */ +function bibliography_builder_register_write_abilities() { + if ( ! function_exists( 'wp_register_ability' ) || ! bibliography_builder_write_routes_enabled() ) { + return; + } + + $common = array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post with the bibliography block.', 'borges-bibliography-builder' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'default' => 0, + 'description' => __( + 'Zero-based position of the bibliography block within the post.', + 'borges-bibliography-builder' + ), + ), + 'bibliography_id' => array( + 'type' => 'string', + 'pattern' => '^' . BIBLIOGRAPHY_BUILDER_BLOCK_ID_PATTERN . '$', + 'description' => __( + "The block's stable bibliographyId. Takes precedence over index when given.", + 'borges-bibliography-builder' + ), + ), + 'dry_run' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( + 'Preview the change without saving. Defaults to true; pass false to save.', + 'borges-bibliography-builder' + ), + ), + 'etag' => array( + 'type' => 'string', + 'description' => __( + 'The ETag from a read or dry run. Required to save; a stale one is refused.', + 'borges-bibliography-builder' + ), + ), + ); + $citation_id = array( + 'type' => 'string', + 'pattern' => '^[^/\s]{1,128}$', + 'description' => __( 'ID of the citation within the bibliography.', 'borges-bibliography-builder' ), + ); + $output = array( + 'type' => 'object', + 'properties' => array( + 'postId' => array( 'type' => 'integer' ), + 'dryRun' => array( 'type' => 'boolean' ), + 'changes' => array( 'type' => 'object' ), + 'bibliography' => array( 'type' => 'object' ), + 'etag' => array( 'type' => 'string' ), + ), + ); + + $abilities = array( + 'borges/add-citations' => array( + 'label' => __( 'Add citations', 'borges-bibliography-builder' ), + 'description' => __( + 'Adds CSL-JSON citations to one Borges bibliography. Duplicates are skipped and reported.', + 'borges-bibliography-builder' + ), + 'properties' => array( + 'items' => array( + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => BIBLIOGRAPHY_BUILDER_MAX_ITEMS_PER_WRITE, + 'items' => array( 'type' => 'object' ), + 'description' => __( 'CSL-JSON citation records to add.', 'borges-bibliography-builder' ), + ), + ), + 'required' => array( 'items' ), + 'callback' => 'bibliography_builder_ability_add_citations', + 'meta' => bibliography_builder_get_write_ability_meta( false, false ), + ), + 'borges/update-citation' => array( + 'label' => __( 'Update a citation', 'borges-bibliography-builder' ), + 'description' => __( + 'Changes CSL-JSON fields of one citation and reformats it. A null value removes a field.', + 'borges-bibliography-builder' + ), + 'properties' => array( + 'citation_id' => $citation_id, + 'fields' => array( + 'type' => 'object', + 'minProperties' => 1, + 'description' => __( + 'CSL-JSON fields to set. "id" and "type" cannot be changed.', + 'borges-bibliography-builder' + ), + ), + ), + 'required' => array( 'citation_id', 'fields' ), + 'callback' => 'bibliography_builder_ability_update_citation', + 'meta' => bibliography_builder_get_write_ability_meta( false, true ), + ), + 'borges/delete-citation' => array( + 'label' => __( 'Delete a citation', 'borges-bibliography-builder' ), + 'description' => __( + 'Removes one citation from a Borges bibliography and returns the removed entry.', + 'borges-bibliography-builder' + ), + 'properties' => array( 'citation_id' => $citation_id ), + 'required' => array( 'citation_id' ), + 'callback' => 'bibliography_builder_ability_delete_citation', + 'meta' => bibliography_builder_get_write_ability_meta( true, false ), + ), + 'borges/reorder-citations' => array( + 'label' => __( 'Reorder citations', 'borges-bibliography-builder' ), + 'description' => __( + 'Reorders the citations of a numeric-style Borges bibliography. Other styles sort themselves.', + 'borges-bibliography-builder' + ), + 'properties' => array( + 'ids' => array( + 'type' => 'array', + 'minItems' => 1, + 'items' => array( 'type' => 'string' ), + 'description' => __( 'Every citation ID, in the new order.', 'borges-bibliography-builder' ), + ), + ), + 'required' => array( 'ids' ), + 'callback' => 'bibliography_builder_ability_reorder_citations', + 'meta' => bibliography_builder_get_write_ability_meta( false, true ), + ), + 'borges/update-bibliography-settings' => array( + 'label' => __( 'Update bibliography settings', 'borges-bibliography-builder' ), + 'description' => __( + 'Changes block settings of one Borges bibliography, such as its heading. Citations are not touched.', + 'borges-bibliography-builder' + ), + 'properties' => array( + 'settings' => array( + 'type' => 'object', + 'minProperties' => 1, + 'description' => __( + 'Block settings to change. Use borges/reformat-bibliography to change the citation style.', + 'borges-bibliography-builder' + ), + ), + ), + 'required' => array( 'settings' ), + 'callback' => 'bibliography_builder_ability_update_bibliography_settings', + 'meta' => bibliography_builder_get_write_ability_meta( false, true ), + ), + 'borges/reformat-bibliography' => array( + 'label' => __( 'Reformat a bibliography', 'borges-bibliography-builder' ), + 'description' => __( + 'Switches one Borges bibliography to another citation style and reformats every entry.', + 'borges-bibliography-builder' + ), + 'properties' => array( + 'style' => array( + 'type' => 'string', + 'enum' => array_keys( bibliography_builder_get_formatter_style_definitions() ), + 'description' => __( 'Citation style to switch to.', 'borges-bibliography-builder' ), + ), + ), + 'required' => array( 'style' ), + 'callback' => 'bibliography_builder_ability_reformat_bibliography', + 'meta' => bibliography_builder_get_write_ability_meta( false, true ), + ), + ); + + foreach ( $abilities as $name => $ability ) { + wp_register_ability( + $name, + array( + 'label' => $ability['label'], + 'description' => $ability['description'], + 'category' => BIBLIOGRAPHY_BUILDER_ABILITY_CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array_merge( $common, $ability['properties'] ), + 'required' => array_merge( array( 'post_id' ), $ability['required'] ), + 'additionalProperties' => false, + ), + 'output_schema' => $output, + 'execute_callback' => $ability['callback'], + 'permission_callback' => 'bibliography_builder_ability_can_write_post', + 'meta' => $ability['meta'], + ) + ); + } +} + +/** + * The route request a write ability stands for. + * + * @param array $input Validated ability input. + * @param string $method HTTP method of the matching route. + * @param array|null $body JSON body, or null for none. + * @param string|null $citation_id Citation ID for the per-citation routes. + * @return WP_REST_Request + */ +function bibliography_builder_write_ability_request( $input, $method, $body = null, $citation_id = null ) { + $input = is_array( $input ) ? $input : array(); + $request = new WP_REST_Request( $method, '/bibliography/v1/abilities' ); + $url = array( + 'post_id' => isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0, + 'ref' => (string) bibliography_builder_ability_block_ref( $input ), + ); + + if ( null !== $citation_id ) { + $url['citation_id'] = $citation_id; + } + + $request->set_url_params( $url ); + $request->set_query_params( + array( 'dry_run' => ! isset( $input['dry_run'] ) || false !== $input['dry_run'] ) + ); + + if ( isset( $input['etag'] ) && is_string( $input['etag'] ) ) { + $request->set_header( 'If-Match', $input['etag'] ); + } + + if ( null !== $body ) { + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + } + + return $request; +} + +/** + * A route's response as ability output. + * + * @param WP_REST_Response|WP_Error $response Route response. + * @return array|WP_Error + */ +function bibliography_builder_write_ability_result( $response ) { + return is_wp_error( $response ) ? $response : $response->get_data(); +} + +/** + * Permission callback for the write abilities: `edit_post` on the post, as + * for the write routes. + * + * @param mixed $input Ability input. + * @return true|WP_Error + */ +function bibliography_builder_ability_can_write_post( $input = null ) { + return bibliography_builder_rest_write_permissions_check( + bibliography_builder_write_ability_request( $input, 'GET' ) + ); +} + +/** + * Execute callback for `borges/add-citations`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_add_citations( $input ) { + return bibliography_builder_write_ability_result( + bibliography_builder_rest_add_citations( + bibliography_builder_write_ability_request( $input, 'POST', array( 'items' => $input['items'] ?? null ) ) + ) + ); +} + +/** + * Execute callback for `borges/update-citation`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_update_citation( $input ) { + $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : array(); + + return bibliography_builder_write_ability_result( + bibliography_builder_rest_update_citation( + bibliography_builder_write_ability_request( $input, 'PATCH', $fields, (string) $input['citation_id'] ) + ) + ); +} + +/** + * Execute callback for `borges/delete-citation`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_delete_citation( $input ) { + return bibliography_builder_write_ability_result( + bibliography_builder_rest_delete_citation( + bibliography_builder_write_ability_request( $input, 'DELETE', null, (string) $input['citation_id'] ) + ) + ); +} + +/** + * Execute callback for `borges/reorder-citations`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_reorder_citations( $input ) { + return bibliography_builder_write_ability_result( + bibliography_builder_rest_reorder_citations( + bibliography_builder_write_ability_request( $input, 'PUT', array( 'ids' => $input['ids'] ?? null ) ) + ) + ); +} + +/** + * Execute callback for `borges/update-bibliography-settings`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_update_bibliography_settings( $input ) { + $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); + + return bibliography_builder_write_ability_result( + bibliography_builder_rest_update_bibliography_settings( + bibliography_builder_write_ability_request( $input, 'PATCH', $settings ) + ) + ); +} + +/** + * Execute callback for `borges/reformat-bibliography`. + * + * @param array $input Validated ability input. + * @return array|WP_Error + */ +function bibliography_builder_ability_reformat_bibliography( $input ) { + return bibliography_builder_write_ability_result( + bibliography_builder_rest_reformat_bibliography( + bibliography_builder_write_ability_request( $input, 'POST', array( 'style' => $input['style'] ?? null ) ) + ) + ); +} + +add_action( 'wp_abilities_api_init', 'bibliography_builder_register_write_abilities' ); diff --git a/tests/phpunit/WriteAbilitiesTest.php b/tests/phpunit/WriteAbilitiesTest.php new file mode 100644 index 0000000..d86c55b --- /dev/null +++ b/tests/phpunit/WriteAbilitiesTest.php @@ -0,0 +1,296 @@ +markTestSkipped( 'intl and citeproc-php are required.' ); + } + + $this->content = "\n

Intro

\n\n\n" + . self::block( + array( + 'bibliographyId' => 'notes-block', + 'citationStyle' => 'chicago-notes-bibliography', + 'citations' => array( + self::citation( 'c-knuth', 'Literate Programming', 'Knuth', 'Donald E.', 1984 ), + self::citation( 'c-turing', 'Computing Machinery and Intelligence', 'Turing', 'A. M.', 1950 ), + ), + ) + ) + . "\n\n" + . self::block( + array( + 'bibliographyId' => 'ieee-block', + 'citationStyle' => 'ieee', + 'citations' => array( + self::citation( 'i-one', 'First Paper', 'Zed', 'Ann', 2001 ), + self::citation( 'i-two', 'Second Paper', 'Abel', 'Bo', 2002 ), + ), + ) + ) + . "\n"; + + bibliography_builder_test_set_post( self::POST_ID, 'publish', $this->content ); + bibliography_builder_test_set_parsed_blocks( $this->content, ( new WP_Block_Parser() )->parse( $this->content ) ); + bibliography_builder_test_set_current_user( self::USER_ID ); + bibliography_builder_test_grant_cap( self::USER_ID, 'edit_post', self::POST_ID ); + } + + private static function citation( $id, $title, $family, $given, $year ) { + return array( + 'id' => $id, + 'csl' => array( + 'type' => 'article-journal', + 'title' => $title, + 'author' => array( + array( + 'family' => $family, + 'given' => $given, + ), + ), + 'issued' => array( 'date-parts' => array( array( $year ) ) ), + ), + 'formattedText' => $family . '. ' . $title . '.', + ); + } + + private static function block( $attrs ) { + $html = bibliography_builder_render_save_markup( bibliography_builder_decode_save_attributes( wp_json_encode( $attrs ) ) ); + + return bibliography_builder_serialize_bibliography_block( $attrs, $html ); + } + + private static function etag() { + return bibliography_builder_post_etag( get_post( self::POST_ID ) ); + } + + private static function citation_ids( $ref ) { + $blocks = bibliography_builder_locate_bibliography_blocks( get_post( self::POST_ID )->post_content ); + $attrs = bibliography_builder_write_to_arrays( $blocks[ $ref ]['attrs'] ); + + return array_column( $attrs['citations'], 'id' ); + } + + private static function enable_and_register() { + add_filter( 'bibliography_builder_enable_write_routes', '__return_true' ); + bibliography_builder_register_write_abilities(); + } + + public function test_abilities_are_off_unless_write_routes_are_enabled() { + bibliography_builder_register_write_abilities(); + $this->assertSame( array(), $GLOBALS['bibliography_builder_test_abilities'] ); + + self::enable_and_register(); + + $this->assertSame( + array( + 'borges/add-citations', + 'borges/update-citation', + 'borges/delete-citation', + 'borges/reorder-citations', + 'borges/update-bibliography-settings', + 'borges/reformat-bibliography', + ), + array_keys( $GLOBALS['bibliography_builder_test_abilities'] ) + ); + + foreach ( $GLOBALS['bibliography_builder_test_abilities'] as $name => $args ) { + $this->assertSame( 'bibliography', $args['category'], $name ); + $this->assertFalse( $args['meta']['annotations']['readonly'], $name ); + $this->assertSame( 'borges/delete-citation' === $name, $args['meta']['annotations']['destructive'], $name ); + $this->assertSame( 'bibliography_builder_ability_can_write_post', $args['permission_callback'], $name ); + $this->assertTrue( is_callable( $args['execute_callback'] ), $name ); + $this->assertFalse( $args['input_schema']['additionalProperties'], $name ); + $this->assertSame( 'post_id', $args['input_schema']['required'][0], $name ); + $this->assertTrue( $args['input_schema']['properties']['dry_run']['default'], $name ); + } + } + + public function test_permission_needs_edit_post() { + $this->assertTrue( bibliography_builder_ability_can_write_post( array( 'post_id' => self::POST_ID ) ) ); + + bibliography_builder_test_set_current_user( 99 ); + $this->assertSame( + 'bibliography_builder_write_forbidden', + bibliography_builder_ability_can_write_post( array( 'post_id' => self::POST_ID ) )->get_error_code() + ); + $this->assertSame( + 'bibliography_builder_post_not_found', + bibliography_builder_ability_can_write_post( array( 'post_id' => 404 ) )->get_error_code() + ); + } + + public function test_add_previews_by_default_and_saves_with_a_current_etag() { + $input = array( + 'post_id' => self::POST_ID, + 'bibliography_id' => 'notes-block', + 'items' => array( self::citation( 'x', 'Beta Study', 'Beta', 'Bea', 2010 )['csl'] ), + ); + $preview = bibliography_builder_ability_add_citations( $input ); + + $this->assertTrue( $preview['dryRun'] ); + $this->assertSame( 3, $preview['bibliography']['entryCount'] ); + $this->assertSame( self::etag(), $preview['etag'] ); + $this->assertSame( $this->content, get_post( self::POST_ID )->post_content ); + + $input['dry_run'] = false; + $this->assertSame( 428, bibliography_builder_ability_add_citations( $input )->get_error_data()['status'] ); + + $input['etag'] = '"stale"'; + $this->assertSame( 412, bibliography_builder_ability_add_citations( $input )->get_error_data()['status'] ); + $this->assertSame( $this->content, get_post( self::POST_ID )->post_content ); + + $input['etag'] = $preview['etag']; + $saved = bibliography_builder_ability_add_citations( $input ); + + $this->assertFalse( $saved['dryRun'] ); + $this->assertSame( self::etag(), $saved['etag'] ); + $this->assertCount( 3, self::citation_ids( 0 ) ); + $this->assertSame( array( 'i-one', 'i-two' ), self::citation_ids( 1 ) ); + } + + public function test_output_matches_the_route_response() { + $items = array( self::citation( 'x', 'Beta Study', 'Beta', 'Bea', 2010 )['csl'] ); + $request = new WP_REST_Request( 'POST', '/bibliography/v1/posts/' . self::POST_ID . '/bibliographies/0/citations' ); + $request->set_query_params( + array( + 'post_id' => self::POST_ID, + 'ref' => '0', + 'dry_run' => true, + ) + ); + $request->set_body_params( array( 'items' => $items ) ); + + $route = bibliography_builder_rest_add_citations( $request )->get_data(); + $ability = bibliography_builder_ability_add_citations( + array( + 'post_id' => self::POST_ID, + 'items' => $items, + ) + ); + + // Added IDs are random; everything else must agree. + unset( $route['changes']['added'], $ability['changes']['added'], $route['bibliography'], $ability['bibliography'] ); + $this->assertSame( $route, $ability ); + } + + public function test_update_and_delete_address_a_citation() { + $updated = bibliography_builder_ability_update_citation( + array( + 'post_id' => self::POST_ID, + 'bibliography_id' => 'notes-block', + 'citation_id' => 'c-turing', + 'fields' => array( 'title' => 'On Computable Numbers' ), + 'dry_run' => false, + 'etag' => self::etag(), + ) + ); + + $this->assertSame( array( 'c-turing' ), $updated['changes']['updated'] ); + $this->assertStringContainsString( 'On Computable Numbers', get_post( self::POST_ID )->post_content ); + + $locked = bibliography_builder_ability_update_citation( + array( + 'post_id' => self::POST_ID, + 'citation_id' => 'c-turing', + 'fields' => array( 'type' => 'book' ), + ) + ); + $this->assertSame( 'bibliography_builder_locked_field', $locked->get_error_code() ); + + $deleted = bibliography_builder_ability_delete_citation( + array( + 'post_id' => self::POST_ID, + 'index' => 0, + 'citation_id' => 'c-knuth', + 'dry_run' => false, + 'etag' => self::etag(), + ) + ); + + $this->assertSame( 'c-knuth', $deleted['changes']['removed']['id'] ); + $this->assertSame( array( 'c-turing' ), self::citation_ids( 0 ) ); + + $missing = bibliography_builder_ability_delete_citation( + array( + 'post_id' => self::POST_ID, + 'citation_id' => 'c-knuth', + ) + ); + $this->assertSame( 404, $missing->get_error_data()['status'] ); + } + + public function test_reorder_settings_and_reformat() { + $reordered = bibliography_builder_ability_reorder_citations( + array( + 'post_id' => self::POST_ID, + 'bibliography_id' => 'ieee-block', + 'ids' => array( 'i-two', 'i-one' ), + 'dry_run' => false, + 'etag' => self::etag(), + ) + ); + $this->assertSame( array( 'i-two', 'i-one' ), $reordered['changes']['order'] ); + $this->assertSame( array( 'i-two', 'i-one' ), self::citation_ids( 1 ) ); + + $automatic = bibliography_builder_ability_reorder_citations( + array( + 'post_id' => self::POST_ID, + 'ids' => array( 'c-turing', 'c-knuth' ), + ) + ); + $this->assertSame( 409, $automatic->get_error_data()['status'] ); + + $settings = bibliography_builder_ability_update_bibliography_settings( + array( + 'post_id' => self::POST_ID, + 'settings' => array( 'headingText' => 'Sources' ), + ) + ); + $this->assertSame( array( 'headingText' ), $settings['changes']['updated'] ); + $this->assertTrue( $settings['dryRun'] ); + + $style = bibliography_builder_ability_update_bibliography_settings( + array( + 'post_id' => self::POST_ID, + 'settings' => array( 'citationStyle' => 'apa-7' ), + ) + ); + $this->assertSame( 'bibliography_builder_style_needs_reformat', $style->get_error_code() ); + + $reformat = bibliography_builder_ability_reformat_bibliography( + array( + 'post_id' => self::POST_ID, + 'style' => 'apa-7', + ) + ); + $this->assertSame( + array( + 'from' => 'chicago-notes-bibliography', + 'to' => 'apa-7', + ), + $reformat['changes']['citationStyle'] + ); + $this->assertSame( self::etag(), $reformat['etag'] ); + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 12fe063..7bec349 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -493,6 +493,17 @@ public function set_body_params( $params ) { $this->body = json_encode( $params ); } + public function set_url_params( $params ) { + $this->params = array_merge( $this->params, $params ); + } + + public function set_body( $body ) { + $this->body = $body; + $decoded = json_decode( $body, true ); + $this->json_params = is_array( $decoded ) ? $decoded : array(); + $this->params = array_merge( $this->params, $this->json_params ); + } + public function get_json_params() { return $this->json_params; }