Closes the three problems that blocked the consolidation and records the rest
as tasks.
Licence: GPLv2 or later (WordPress.org compatible)
LICENSE is now the full GPLv2 text and NOTICE is gone (it was Apache
specific). All 101 SPDX headers read GPL-2.0-or-later and every shipped
header says "GPLv2 or later".
This also fixes an inconsistency that predated the consolidation: the theme's
style.css declared GPLv2 while its PHP files carried Apache headers.
Old/ and archive/pre-umbra-releases/ keep their original Apache headers on
purpose -- they are frozen snapshots, not shipped code.
Schema: the catalogue schema is canonical for bd_books and bd_series
Adds Umbra_Schema_Migration. The catalogue and library modules previously
both created those two tables with different columns, so whichever plugin
activated first won and the other read and wrote against the wrong shape.
The migration detects a library-shaped table, RENAMES it aside (never drops
it), creates the canonical table and copies rows across:
- book IDs preserved, keeping bd_book_meta, bd_reviews, bd_ratings and
bd_review_shortlinks valid
- title/slug/description resolved from the linked post
- the library's single isbn/asin land on the ebook columns; amazon_asin is
also set so existing product links keep working
- series migrated first so books.series_id still resolves
- post linkage rewritten as _bd_bookshelf_book_id (preserved meta key)
- publish_date kept as _bd_bookshelf_publish_date (no catalogue column)
- bd_ratings goodreads/amazon folded into rating_goodreads/rating_amazon
- rows whose post is gone are skipped with a log line, not turned into
unusable books
- duplicate slugs de-duplicated, since catalogue slug is UNIQUE
It runs before dbDelta, and on admin_init as well as activation, because
plugin updates do not re-run activation hooks. Ordering matters: dbDelta adds
columns but never drops them, so pointed at a library-shaped table it would
graft on catalogue columns and leave a table satisfying neither schema.
Umbra_Bookshelf_Helper_Install no longer creates bd_books or bd_series.
Scraping: Goodreads only, one implementation
There were three Goodreads implementations and two Amazon ones; both library
"scrapers" were empty stubs attached to cron events that fired on schedule to
do nothing, and both real scraper classes were never called at all.
Umbra_Amazon_Scraper is deleted and the duplicate HTML parsing is gone from
Umbra_Rating_Sync, which now delegates to Umbra_Goodreads_Scraper. The two
library stubs and their cron registrations are removed. One schedule
survives: bd_bookshelf_sync_ratings, every 12 hours.
The rating_amazon column is retained (it holds data and is populated during
migration) and Amazon retail links are unaffected.
A new rating fetch failure keeps the last known value instead of writing zero.
The admin ratings notice no longer warns about Amazon scraping.
Umbra_SEO is now instantiated
It registers its hooks from a constructor and the bootstrap never constructed
it, so the class was dead code and no SEO output was produced. Its meta, Open
Graph, Twitter card and sitemap hooks now attach. Completing SEO for generated
book detail pages is docs/TASKS.md #2.
Assets that 404'd
- assets/images/default-cover.png now holds a real 512x815 cover
(WorkingTitleCover_512x815.png). The helper returned this path but the
repository contained no images at all.
- email/assets/js/admin.js is committed as an empty placeholder. It was
enqueued on every email admin page but had never been committed.
Tests
- tests/schema_migration_test.php: 42 checks against a real database, driven
through the sqlite3 CLI on a throwaway file. Covers shape detection, row
mapping, ID preservation, orphan handling, rating folding, idempotency and
slug collisions.
- tests/run_all.sh runs every suite.
- The declared version is now checked against the plugin header rather than a
literal, so a version bump cannot silently break the suite.
Docs
- KNOWN-ISSUES.md rewritten: three resolved, three remaining.
- TASKS.md added: email admin behaviour, book detail page SEO, batching the
rating sync, reclaiming legacy tables, reconciling the two book models,
retiring the Old/ archive.
- SPEC and ARCHITECTURE updated for schema ownership, the migration ordering
constraint, and the single scraper.
Version 1.1.0.
313 lines
14 KiB
PHP
313 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Runtime smoke test for the merged umbra-bookshelf plugin.
|
|
*
|
|
* Stubs just enough of WordPress to load umbra-bookshelf.php, fire the hooks
|
|
* the plugin registers on, and assert that the merged plugin boots without a
|
|
* fatal error and registers the consolidated admin menu exactly once.
|
|
*
|
|
* Run: php /tmp/opencode/smoke_umbra.php
|
|
*/
|
|
|
|
define('ABSPATH', '/tmp/fake-wp/');
|
|
define('WP_UNINSTALL_PLUGIN', 'umbra-bookshelf/umbra-bookshelf.php');
|
|
|
|
$GLOBALS['__hooks'] = array();
|
|
$GLOBALS['__menus'] = array();
|
|
$GLOBALS['__submenus'] = array();
|
|
$GLOBALS['__shortcodes'] = array();
|
|
$GLOBALS['__pt'] = array();
|
|
$GLOBALS['__tax'] = array();
|
|
$GLOBALS['__enqueued'] = array();
|
|
$GLOBALS['__options'] = array();
|
|
$GLOBALS['__cron'] = array();
|
|
$GLOBALS['__is_admin'] = (($argv[1] ?? 'front') === 'admin');
|
|
$MODE = ($argv[1] ?? 'front');
|
|
|
|
function add_action($h, $cb, $p = 10, $a = 1) { $GLOBALS['__hooks'][$h][] = $cb; }
|
|
function add_filter($h, $cb, $p = 10, $a = 1) { return true; }
|
|
function register_activation_hook($f, $cb) {}
|
|
function register_deactivation_hook($f, $cb) {}
|
|
function load_plugin_textdomain($d, $x = false, $p = '') { return true; }
|
|
function plugin_dir_path($f) { return rtrim(dirname($f), '/') . '/'; }
|
|
function plugin_dir_url($f) { return 'https://example.test/wp-content/plugins/'
|
|
. basename(dirname($f)) . '/'; }
|
|
function plugin_basename($f) { return basename(dirname($f)) . '/' . basename($f); }
|
|
function is_admin() { return $GLOBALS['__is_admin']; }
|
|
function current_user_can($c) { return true; }
|
|
function wp_die($m = '') { throw new Exception('wp_die: ' . $m); }
|
|
function __($t, $d = null) { return $t; }
|
|
function esc_html__($t, $d = null) { return $t; }
|
|
function esc_html_e($t, $d = null) { echo $t; }
|
|
function esc_attr__($t, $d = null) { return $t; }
|
|
function esc_attr_e($t, $d = null) { echo $t; }
|
|
function esc_html($t) { return $t; }
|
|
function esc_url($t) { return $t; }
|
|
function esc_textarea($t) { return $t; }
|
|
function number_format_i18n($n) { return number_format($n); }
|
|
function absint($n) { return abs((int) $n); }
|
|
function sanitize_text_field($t) { return $t; }
|
|
function sanitize_key($t) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($t)); }
|
|
function wp_create_nonce($a) { return 'nonce'; }
|
|
function wp_verify_nonce($n, $a) { return 1; }
|
|
function admin_url($p = '') { return 'https://example.test/wp-admin/' . $p; }
|
|
function flush_rewrite_rules($h = true) {}
|
|
function update_option($k, $v, $a = null) { $GLOBALS['__options'][$k] = $v; return true; }
|
|
function get_option($k, $d = false) { return $GLOBALS['__options'][$k] ?? $d; }
|
|
function delete_option($k) { unset($GLOBALS['__options'][$k]); return true; }
|
|
function wp_count_posts($t) { return (object) array('publish' => 7); }
|
|
function get_terms($a = array()) { return 4; }
|
|
function is_wp_error($t) { return false; }
|
|
function get_post_meta($p, $k, $s = true) { return ''; }
|
|
function update_post_meta($p, $k, $v) { return true; }
|
|
function get_post($p) { return null; }
|
|
function get_the_post_thumbnail_url($p, $s = '') { return ''; }
|
|
function wp_schedule_event($t, $r, $h) { $GLOBALS['__cron'][$h] = true; }
|
|
function wp_next_scheduled($h) { return false; }
|
|
function wp_clear_scheduled_hook($h) { unset($GLOBALS['__cron'][$h]); }
|
|
function wp_unschedule_hook($h) {}
|
|
function add_shortcode($tag, $cb) { $GLOBALS['__shortcodes'][] = $tag; }
|
|
function register_post_type($t, $a = array()) { $GLOBALS['__pt'][$t] = $a; return $t; }
|
|
function register_taxonomy($t, $o, $a = array()) { $GLOBALS['__tax'][$t] = $a; return $t; }
|
|
function add_menu_page($pt, $mt, $cap, $slug, $cb = '', $icon = '', $pos = null) {
|
|
$GLOBALS['__menus'][$slug] = array('page_title' => $pt, 'capability' => $cap,
|
|
'callback' => $cb, 'icon' => $icon, 'position' => $pos);
|
|
return $slug;
|
|
}
|
|
function add_submenu_page($parent, $pt, $mt, $cap, $slug, $cb = '') {
|
|
$GLOBALS['__submenus'][$slug] = array('parent' => $parent, 'page_title' => $pt,
|
|
'menu_title' => $mt, 'capability' => $cap,
|
|
'callback' => $cb);
|
|
return $slug;
|
|
}
|
|
function get_current_screen() { return null; }
|
|
function wp_enqueue_media() {}
|
|
function wp_enqueue_style($h, $src = '', $d = array(), $v = false, $m = 'all') {
|
|
$GLOBALS['__enqueued']['style'][$h] = $src;
|
|
}
|
|
function wp_enqueue_script($h, $src = '', $d = array(), $v = false, $f = false) {
|
|
$GLOBALS['__enqueued']['script'][$h] = $src;
|
|
}
|
|
function wp_localize_script($h, $name, $data) {
|
|
$GLOBALS['__enqueued']['localize'][$h] = $name;
|
|
}
|
|
function wp_register_post_type_args($a, $t) { return $a; }
|
|
function register_post_meta_args($a, $t) { return $a; }
|
|
function wp_get_current_user() { return null; }
|
|
function get_userdata($i) { return false; }
|
|
function wp_roles() { return null; }
|
|
function add_role($r, $n, $c) {}
|
|
function remove_role($r) {}
|
|
function wp_send_json_success($d) {}
|
|
function wp_send_json_error($d) {}
|
|
function check_ajax_referer($a, $q = false) { return 1; }
|
|
function wp_die_ajax($m) {}
|
|
function sanitize_email($e) { return $e; }
|
|
function sanitize_key_($k) { return $k; }
|
|
function get_home_url($p = '') { return 'https://example.test'; }
|
|
function home_url($p = '') { return 'https://example.test'; }
|
|
function wp_mail($to, $subj, $msg) { return true; }
|
|
function wp_rand($a = 0, $b = 0) { return 12345; }
|
|
function get_bloginfo($s = '') { return 'Example'; }
|
|
function get_user_meta($i, $k, $s = false) { return ''; }
|
|
function update_user_meta($i, $k, $v) { return true; }
|
|
function add_rewrite_rule($r, $q, $after = 'bottom') { return true; }
|
|
function add_rewrite_tag($r, $q, $a = '') { return true; }
|
|
function add_query_var($v) { return $v; }
|
|
function get_query_var($v, $d = '') { return $d; }
|
|
function get_option_blogname($d = '') { return 'Example'; }
|
|
function do_action($h) {
|
|
foreach ($GLOBALS['__hooks'][$h] ?? array() as $cb) {
|
|
if (is_callable($cb)) { call_user_func($cb); }
|
|
}
|
|
}
|
|
|
|
$fail = 0;
|
|
function check($label, $cond, $detail = '') {
|
|
global $fail;
|
|
if ($cond) {
|
|
echo " ok $label\n";
|
|
} else {
|
|
echo " FAIL $label" . ($detail ? " ($detail)" : '') . "\n";
|
|
$GLOBALS['fail']++;
|
|
}
|
|
}
|
|
function section($t) { echo "\n$t\n"; }
|
|
|
|
// ---------------------------------------------------------------- load it
|
|
section('1. Load plugin bootstrap (front-end context)');
|
|
try {
|
|
require dirname(__DIR__) . '/umbra-bookshelf.php';
|
|
echo " ok bootstrap included without fatal error\n";
|
|
} catch (Throwable $e) {
|
|
echo " FAIL fatal: " . $e->getMessage() . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
section('2. Core class graph');
|
|
check('Umbra_Bookshelf class exists', class_exists('Umbra_Bookshelf'));
|
|
check('singleton instance() returns object',
|
|
umbra_bookshelf() instanceof Umbra_Bookshelf);
|
|
check('instance() is idempotent',
|
|
umbra_bookshelf() === umbra_bookshelf());
|
|
|
|
section('3. Module classes loaded');
|
|
foreach (array('Umbra_Database', 'Umbra_Book_Manager', 'Umbra_Series',
|
|
'Umbra_Authors', 'Umbra_Retail_Sites', 'Umbra_Rating_Sync',
|
|
'Umbra_Shortcodes', 'Umbra_SEO', 'Umbra_GDPR',
|
|
'Umbra_Goodreads_Scraper', 'Umbra_Schema_Migration',
|
|
'Umbra_Admin', 'Umbra_Migration', 'Umbra_Theme_Compatibility',
|
|
'Umbra_Bookshelf_Helper', 'Umbra_Bookshelf_Helper_Admin',
|
|
'Umbra_Bookshelf_Helper_Install', 'Umbra_Simple_Email',
|
|
'Umbra_Simple_Email_Admin', 'Umbra_Simple_Email_Install',
|
|
'Umbra_Email_Sender') as $c) {
|
|
check("class $c", class_exists($c));
|
|
}
|
|
|
|
section('4. Legacy theme alias');
|
|
check('class_alias exposes BD_BookShelfHelper for the theme',
|
|
class_exists('BD_BookShelfHelper', false));
|
|
check('alias resolves to the compatibility shim',
|
|
is_a('BD_BookShelfHelper', 'Umbra_Theme_Compatibility', true));
|
|
check('shim exposes get_cover_url (theme API)',
|
|
method_exists('BD_BookShelfHelper', 'get_cover_url'));
|
|
check('real library class still distinct',
|
|
!is_a('Umbra_Bookshelf_Helper', 'Umbra_Theme_Compatibility', true));
|
|
|
|
section('5. Constants');
|
|
// Compare against the plugin header rather than a hard-coded literal, so a
|
|
// version bump cannot silently break the suite.
|
|
$boot_src = file_get_contents(dirname(__DIR__) . '/umbra-bookshelf.php');
|
|
$header_version = preg_match('/^\s*\*\s*Version:\s*(\S+)/m', $boot_src, $vm) ? $vm[1] : null;
|
|
check('UMBRA_BOOKSHELF_VERSION matches the plugin header',
|
|
defined('UMBRA_BOOKSHELF_VERSION') && UMBRA_BOOKSHELF_VERSION === $header_version,
|
|
(UMBRA_BOOKSHELF_VERSION ?? 'undefined') . ' vs header ' . ($header_version ?? 'none'));
|
|
check('UMBRA_BOOKSHELF_DIR', defined('UMBRA_BOOKSHELF_DIR'));
|
|
check('UMBRA_BOOKSHELF_URL', defined('UMBRA_BOOKSHELF_URL'));
|
|
check('UMBRA_BOOKSHELF_BASENAME', defined('UMBRA_BOOKSHELF_BASENAME'));
|
|
check('library module version', defined('UMBRA_BOOKSHELF_LIBRARY_VERSION'));
|
|
check('email module version', defined('UMBRA_BOOKSHELF_EMAIL_VERSION'));
|
|
check('old BD_BOOKSHELF_VERSION removed', !defined('BD_BOOKSHELF_VERSION'));
|
|
check('old BD_BHS_DIR removed', !defined('BD_BHS_DIR'));
|
|
check('old BD_EMAIL_URI removed', !defined('BD_EMAIL_URI'));
|
|
|
|
section('6. Boot the modules (' . $MODE . ' request)');
|
|
try {
|
|
do_action('plugins_loaded');
|
|
do_action('init');
|
|
echo " ok plugins_loaded + init fired without fatal error\n";
|
|
} catch (Throwable $e) {
|
|
echo " FAIL fatal on init: " . $e->getMessage() . "\n";
|
|
$GLOBALS['fail']++;
|
|
}
|
|
check('book post type registered by library module',
|
|
isset($GLOBALS['__pt']['book']));
|
|
check('series taxonomy registered by library module',
|
|
isset($GLOBALS['__tax']['series']));
|
|
check('[bd-book] shortcode preserved',
|
|
in_array('bd-book', $GLOBALS['__shortcodes'], true));
|
|
check('[bd-books] shortcode preserved',
|
|
in_array('bd-books', $GLOBALS['__shortcodes'], true));
|
|
check('[bdm_signup] shortcode preserved',
|
|
in_array('bdm_signup', $GLOBALS['__shortcodes'], true));
|
|
|
|
section('7. Admin menu consolidation (admin context)');
|
|
try {
|
|
do_action('admin_menu');
|
|
echo " ok admin_menu hooks fired without fatal error\n";
|
|
} catch (Throwable $e) {
|
|
echo " FAIL fatal on admin_menu: " . $e->getMessage() . "\n";
|
|
$GLOBALS['fail']++;
|
|
}
|
|
|
|
$menus = $GLOBALS['__menus'];
|
|
if ($MODE !== 'admin') {
|
|
echo " .. skipped menu assertions (front-end request: no admin_menu)\n";
|
|
section('RESULT');
|
|
if (!empty($GLOBALS['fail'])) {
|
|
echo "FAIL - {$GLOBALS['fail']} check(s) failed\n";
|
|
exit(1);
|
|
}
|
|
echo "PASS - runtime smoke test clean\n";
|
|
exit(0);
|
|
}
|
|
check('exactly ONE top-level menu registered', count($menus) === 1,
|
|
count($menus) . ': ' . implode(', ', array_keys($menus)));
|
|
check('top-level slug is umbra-bookshelf',
|
|
isset($menus['umbra-bookshelf']), implode(', ', array_keys($menus)));
|
|
check('top-level titled "Umbra Bookshelf"',
|
|
($menus['umbra-bookshelf']['page_title'] ?? '') === 'Umbra Bookshelf',
|
|
$menus['umbra-bookshelf']['page_title'] ?? '');
|
|
|
|
$subs = $GLOBALS['__submenus'];
|
|
$badParent = array();
|
|
foreach ($subs as $slug => $s) {
|
|
if ($s['parent'] !== 'umbra-bookshelf') { $badParent[$slug] = $s['parent']; }
|
|
}
|
|
check('every submenu hangs off umbra-bookshelf', count($badParent) === 0,
|
|
json_encode($badParent));
|
|
|
|
$expected = array(
|
|
'umbra-bookshelf', 'umbra-bookshelf-all', 'umbra-bookshelf-add',
|
|
'umbra-bookshelf-authors', 'umbra-bookshelf-series', 'umbra-bookshelf-sites',
|
|
'umbra-bookshelf-shortcodes', 'umbra-bookshelf-sync', 'umbra-bookshelf-settings',
|
|
'umbra-bookshelf-import',
|
|
'edit.php?post_type=book', 'post-new.php?post_type=book',
|
|
'umbra-bookshelf-data-entry', 'edit-tags.php?taxonomy=series&post_type=book',
|
|
'umbra-bookshelf-pen-names', 'umbra-bookshelf-library-settings',
|
|
'umbra-bookshelf-migrate',
|
|
'umbra-bookshelf-subscribers', 'umbra-bookshelf-newsletter',
|
|
'umbra-bookshelf-email-settings',
|
|
);
|
|
$missing = array_values(array_diff($expected, array_keys($subs)));
|
|
check('all ' . count($expected) . ' expected submenus present', count($missing) === 0,
|
|
'missing: ' . implode(', ', $missing));
|
|
|
|
check('no duplicate submenu slug (settings screens distinct)',
|
|
!isset($subs['bd-bookshelf-settings']),
|
|
'legacy duplicate slug still registered');
|
|
check('catalogue settings present', isset($subs['umbra-bookshelf-settings']));
|
|
check('library settings present', isset($subs['umbra-bookshelf-library-settings']));
|
|
check('email settings present', isset($subs['umbra-bookshelf-email-settings']));
|
|
|
|
section('8. Duplicate hook / action registrations');
|
|
$dupes = array();
|
|
foreach ($GLOBALS['__hooks'] as $hook => $cbs) {
|
|
$seen = array();
|
|
foreach ($cbs as $cb) {
|
|
$k = is_string($cb) ? $cb : (is_array($cb) ? (is_object($cb[0]) ? get_class($cb[0]) . '::' . $cb[1] : $cb[0] . '::' . $cb[1]) : 'closure');
|
|
$seen[$k] = ($seen[$k] ?? 0) + 1;
|
|
}
|
|
foreach ($seen as $k => $n) {
|
|
if ($n > 1) { $dupes[] = "$hook => $k ($n)"; }
|
|
}
|
|
}
|
|
check('no callback registered twice on one hook', count($dupes) === 0,
|
|
implode('; ', $dupes));
|
|
|
|
section('9. Asset handles are unique per module');
|
|
$styles = array_keys($GLOBALS['__enqueued']['style'] ?? array());
|
|
$scripts = array_keys($GLOBALS['__enqueued']['script'] ?? array());
|
|
check('no duplicated style handle', count($styles) === count(array_unique($styles)),
|
|
implode(', ', $styles));
|
|
check('no duplicated script handle', count($scripts) === count(array_unique($scripts)),
|
|
implode(', ', $scripts));
|
|
|
|
section('10. Uninstall routine parses and targets real tables');
|
|
$src = file_get_contents(dirname(__DIR__) . '/uninstall.php');
|
|
check('uninstall references catalogue drop_tables', strpos($src, 'drop_tables()') !== false);
|
|
foreach (array('bd_book_meta', 'bd_pen_names', 'bd_reviews', 'bd_ratings',
|
|
'bd_review_shortlinks', 'bd_subscribers', 'bd_newsletters') as $t) {
|
|
check("uninstall drops $t", strpos($src, $t) !== false);
|
|
}
|
|
check('uninstall clears all three cron hooks',
|
|
substr_count($src, 'wp_clear_scheduled_hook') === 3,
|
|
substr_count($src, 'wp_clear_scheduled_hook') . ' calls');
|
|
|
|
section('RESULT');
|
|
if (!empty($GLOBALS['fail'])) {
|
|
echo "FAIL - {$GLOBALS['fail']} check(s) failed\n";
|
|
exit(1);
|
|
}
|
|
echo "PASS - runtime smoke test clean\n";
|