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.
424 lines
18 KiB
PHP
424 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Unit test for Umbra_Schema_Migration.
|
|
*
|
|
* The consolidation made the catalogue schema canonical for bd_books and
|
|
* bd_series. Detecting the wrong shape, or dropping a row, would corrupt real
|
|
* installations, so this exercises the migration against a real database
|
|
* rather than asserting on strings.
|
|
*
|
|
* The database is a throwaway SQLite file driven through the sqlite3 CLI: no
|
|
* credentials, no shared server, nothing to clean up but the file itself.
|
|
* A minimal wpdb shim and a stand-in Umbra_Database are defined before the
|
|
* migration is loaded, so neither the real WordPress nor the real
|
|
* dbDelta-based installer is involved.
|
|
*
|
|
* Run: php umbra-bookshelf/tests/schema_migration_test.php
|
|
*/
|
|
|
|
define('ABSPATH', '/tmp/fake-wp/');
|
|
|
|
// WordPress constants the migration relies on.
|
|
define('ARRAY_A', 'ARRAY_A');
|
|
define('OBJECT', 'OBJECT');
|
|
|
|
// ---------------------------------------------------------------- wpdb shim
|
|
class FakeWpdb {
|
|
|
|
public $prefix = 'wp_';
|
|
public $file;
|
|
public $inserted = array();
|
|
public $renamed = array();
|
|
public $meta = array();
|
|
|
|
public function __construct() {
|
|
$this->file = tempnam(sys_get_temp_dir(), 'umbra-schema-') . '.db';
|
|
// tempnam() creates the file; sqlite is happy to adopt it.
|
|
}
|
|
|
|
public function __destruct() {
|
|
if ($this->file && is_file($this->file)) {
|
|
@unlink($this->file);
|
|
}
|
|
}
|
|
|
|
/** Bare table name, as the migration sees it. */
|
|
public function bare($name) {
|
|
return $this->prefix . 'bd_' . $name;
|
|
}
|
|
|
|
// --- sqlite3 CLI plumbing --------------------------------------------
|
|
|
|
/**
|
|
* Run a statement for effect.
|
|
*/
|
|
public function exec($sql) {
|
|
$cmd = sprintf(
|
|
'sqlite3 %s %s 2>&1',
|
|
escapeshellarg($this->file),
|
|
escapeshellarg($sql)
|
|
);
|
|
$out = shell_exec($cmd);
|
|
if ($out !== null && trim($out) !== '') {
|
|
throw new RuntimeException('sqlite3: ' . trim($out) . ' :: ' . $sql);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Run a query and decode rows as associative arrays.
|
|
*/
|
|
public function rows($sql) {
|
|
$cmd = sprintf(
|
|
'sqlite3 -json %s %s 2>&1',
|
|
escapeshellarg($this->file),
|
|
escapeshellarg($sql)
|
|
);
|
|
$out = trim((string) shell_exec($cmd));
|
|
if ($out === '') {
|
|
return array();
|
|
}
|
|
if ($out[0] !== '[') {
|
|
// A missing table means an assertion is about to fail; report it as
|
|
// no rows rather than tearing down the rest of the suite.
|
|
if (stripos($out, 'no such table') !== false) {
|
|
return array();
|
|
}
|
|
throw new RuntimeException('sqlite3: ' . $out . ' :: ' . $sql);
|
|
}
|
|
return json_decode($out, true) ?: array();
|
|
}
|
|
|
|
/**
|
|
* First column of the first row.
|
|
*/
|
|
public function scalar($sql) {
|
|
$rows = $this->rows($sql);
|
|
if (!$rows) {
|
|
return null;
|
|
}
|
|
return array_values($rows[0])[0];
|
|
}
|
|
|
|
public function table_exists($table) {
|
|
return (bool) $this->scalar(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=" . $this->quote($table)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Quote a value as a SQL literal.
|
|
*/
|
|
public function quote($value) {
|
|
if ($value === null) {
|
|
return 'NULL';
|
|
}
|
|
if (is_int($value) || is_float($value)) {
|
|
return (string) $value;
|
|
}
|
|
return "'" . str_replace("'", "''", (string) $value) . "'";
|
|
}
|
|
|
|
/**
|
|
* Build "col = literal" pairs.
|
|
*/
|
|
private function assignments($data) {
|
|
$parts = array();
|
|
foreach ($data as $col => $value) {
|
|
$parts[] = $col . ' = ' . $this->quote($value);
|
|
}
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
// --- wpdb API surface used by the migration ---------------------------
|
|
|
|
public function prepare($query, ...$args) {
|
|
$i = 0;
|
|
return preg_replace_callback('/%[sdf]/', function ($m) use ($args, &$i) {
|
|
$v = $args[$i++] ?? '';
|
|
if ($m[0] === '%d') return (string) (int) $v;
|
|
if ($m[0] === '%f') return (string) (float) $v;
|
|
return "'" . str_replace("'", "''", (string) $v) . "'";
|
|
}, $query);
|
|
}
|
|
|
|
public function get_var($query) {
|
|
if (stripos($query, 'SHOW TABLES LIKE') !== false) {
|
|
preg_match("/LIKE\s+'([^']+)'/i", $query, $m);
|
|
return $this->table_exists($m[1]) ? $m[1] : null;
|
|
}
|
|
return $this->scalar($query);
|
|
}
|
|
|
|
public function get_col($query) {
|
|
if (stripos($query, 'DESCRIBE') !== false) {
|
|
preg_match('/DESCRIBE\s+(\S+)/i', $query, $m);
|
|
$cols = array();
|
|
foreach ($this->rows('PRAGMA table_info(' . $m[1] . ')') as $row) {
|
|
$cols[] = $row['name'];
|
|
}
|
|
return $cols;
|
|
}
|
|
$cols = array();
|
|
foreach ($this->rows($query) as $row) {
|
|
$cols[] = array_values($row)[0];
|
|
}
|
|
return $cols;
|
|
}
|
|
|
|
public function get_results($query, $output = null) {
|
|
return $this->rows($query);
|
|
}
|
|
|
|
public function query($query) {
|
|
// Translate MySQL DDL to its SQLite equivalent. The migration targets
|
|
// MySQL (that is what WordPress runs on); this shim only emulates it.
|
|
if (preg_match('/^\s*RENAME\s+TABLE\s+(\S+)\s+TO\s+(\S+)\s*;?\s*$/i', $query, $m)) {
|
|
$this->renamed[] = $query;
|
|
return $this->exec("ALTER TABLE {$m[1]} RENAME TO {$m[2]}");
|
|
}
|
|
return $this->exec($query);
|
|
}
|
|
|
|
public function insert($table, $data, $formats = null) {
|
|
$this->inserted[] = array($table, $data);
|
|
$cols = array_keys($data);
|
|
$vals = array_map(array($this, 'quote'), array_values($data));
|
|
$this->exec(
|
|
'INSERT INTO ' . $table . ' (' . implode(',', $cols) . ') VALUES (' .
|
|
implode(',', $vals) . ')'
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
public function update($table, $data, $where, $f = null, $wf = null) {
|
|
$this->exec(
|
|
'UPDATE ' . $table . ' SET ' . $this->assignments($data) .
|
|
' WHERE ' . $this->assignments($where)
|
|
);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
$GLOBALS['wpdb'] = new FakeWpdb();
|
|
|
|
// --- options store used by the migration ---------------------------------
|
|
$GLOBALS['options'] = array();
|
|
function get_option($k, $d = false) { return $GLOBALS['options'][$k] ?? $d; }
|
|
function update_option($k, $v, $a = null) { $GLOBALS['options'][$k] = $v; return true; }
|
|
|
|
// --- WordPress helpers the migration calls ------------------------------
|
|
function current_time($type) { return gmdate('Y-m-d H:i:s'); }
|
|
function sanitize_title($t) {
|
|
$t = strtolower(trim($t));
|
|
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
|
|
return trim($t, '-');
|
|
}
|
|
function update_post_meta($post_id, $key, $value) {
|
|
$GLOBALS['wpdb']->meta[$post_id][$key] = $value;
|
|
return true;
|
|
}
|
|
function wp_get_attachment_url($id) {
|
|
return $id ? "https://example.test/wp-content/uploads/att-{$id}.jpg" : false;
|
|
}
|
|
function get_the_post_thumbnail_url($id, $size = '') {
|
|
return "https://example.test/wp-content/uploads/thumb-{$id}.jpg";
|
|
}
|
|
|
|
// --- posts the migration resolves titles/slugs/bodies from ---------------
|
|
$GLOBALS['posts'] = array(
|
|
101 => (object) array('ID' => 101, 'post_title' => 'The Silent Harbour', 'post_name' => 'the-silent-harbour', 'post_content' => 'A novel of tide and memory.'),
|
|
102 => (object) array('ID' => 102, 'post_title' => 'Harbour Lights', 'post_name' => 'harbour-lights', 'post_content' => 'Sequel.'),
|
|
);
|
|
function get_post($id) { return $GLOBALS['posts'][$id] ?? null; }
|
|
|
|
// --- stand-in for the catalogue installer --------------------------------
|
|
// The real one shells out to dbDelta(); here it just builds the canonical
|
|
// schema so the copy step has somewhere to write.
|
|
class Umbra_Database {
|
|
public static function table($name) { return $GLOBALS['wpdb']->bare($name); }
|
|
|
|
public function create_tables() {
|
|
$w = $GLOBALS['wpdb'];
|
|
if (!$w->table_exists(self::table('books'))) {
|
|
$w->exec('CREATE TABLE ' . self::table('books') . ' (
|
|
id INTEGER PRIMARY KEY, title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
|
|
description TEXT, cover_image TEXT, isbn_ebook TEXT, asin_ebook TEXT,
|
|
amazon_asin TEXT, goodreads_id TEXT,
|
|
rating_amazon REAL DEFAULT 0, rating_goodreads REAL DEFAULT 0,
|
|
last_sync TEXT, series_id INTEGER,
|
|
created_at TEXT, updated_at TEXT)');
|
|
}
|
|
if (!$w->table_exists(self::table('series'))) {
|
|
$w->exec('CREATE TABLE ' . self::table('series') . ' (
|
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
cover_image TEXT, created_at TEXT)');
|
|
}
|
|
}
|
|
}
|
|
|
|
require dirname(__DIR__) . '/includes/class-umbra-schema-migration.php';
|
|
|
|
// ---------------------------------------------------------------- harness
|
|
$fail = 0;
|
|
$pass = 0;
|
|
function check($label, $cond, $detail = '') {
|
|
global $fail, $pass;
|
|
if ($cond) { $pass++; echo " ok $label\n"; }
|
|
else { $fail++; echo " FAIL $label" . ($detail ? " ($detail)" : '') . "\n"; }
|
|
}
|
|
function section($t) { echo "\n$t\n"; }
|
|
|
|
/** Build a library-shaped installation. */
|
|
function seed_library_shape() {
|
|
$w = $GLOBALS['wpdb'];
|
|
foreach (array('books', 'series', 'ratings', 'book_meta',
|
|
'books_legacy_library', 'series_legacy_library') as $t) {
|
|
$w->exec('DROP TABLE IF EXISTS ' . $w->bare($t));
|
|
}
|
|
|
|
$w->exec('CREATE TABLE ' . $w->bare('books') . ' (
|
|
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, pen_name_id INTEGER,
|
|
series_id INTEGER, volume_number INTEGER, asin TEXT, isbn TEXT,
|
|
publish_date TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT)');
|
|
$w->exec('CREATE TABLE ' . $w->bare('series') . ' (
|
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE,
|
|
cover_attachment_id INTEGER, header_attachment_id INTEGER,
|
|
blurb TEXT, display_order INTEGER DEFAULT 0, created_at TEXT)');
|
|
$w->exec('CREATE TABLE ' . $w->bare('ratings') . ' (
|
|
id INTEGER PRIMARY KEY, book_id INTEGER, source TEXT, rating REAL,
|
|
review_count INTEGER, last_updated TEXT)');
|
|
$w->exec('CREATE TABLE ' . $w->bare('book_meta') . ' (
|
|
id INTEGER PRIMARY KEY, book_id INTEGER, language_code TEXT, title TEXT)');
|
|
|
|
$b = $w->bare('books');
|
|
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
|
VALUES (1, 101, 1, 7, 1, 'B00TESTASIN', '9781234567897', '2024-03-01', 0, '2024-01-01 00:00:00')");
|
|
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
|
VALUES (2, 102, 1, 7, 2, NULL, '9781234567898', '2025-06-01', 1, '2025-01-01 00:00:00')");
|
|
// row 3 points at a post that no longer exists
|
|
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
|
VALUES (3, 999, NULL, NULL, NULL, NULL, NULL, NULL, 2, '2025-02-01 00:00:00')");
|
|
|
|
$s = $w->bare('series');
|
|
$w->exec("INSERT INTO {$s} (id, name, slug, cover_attachment_id, header_attachment_id, blurb, display_order, created_at)
|
|
VALUES (7, 'The Harbour Cycle', 'the-harbour-cycle', 55, 56, 'Ships, storms, and the people who stay.', 0, '2024-01-01 00:00:00')");
|
|
|
|
$r = $w->bare('ratings');
|
|
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'goodreads', 4.35, 1200, '2025-01-01 00:00:00')");
|
|
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'amazon', 4.60, 3400, '2025-01-01 00:00:00')");
|
|
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (2, 'goodreads', 4.10, 400, '2025-01-01 00:00:00')");
|
|
|
|
$m = $w->bare('book_meta');
|
|
$w->exec("INSERT INTO {$m} (book_id, language_code, title) VALUES (1, 'en', 'The Silent Harbour')");
|
|
|
|
$GLOBALS['options'] = array(); // fresh site
|
|
$GLOBALS['wpdb']->meta = array();
|
|
}
|
|
|
|
$w = $GLOBALS['wpdb'];
|
|
|
|
// ======================================================================
|
|
section('1. Shape detection on a library-shaped install');
|
|
seed_library_shape();
|
|
$status = Umbra_Schema_Migration::status();
|
|
check('bd_books detected as library shape', $status['books_shape'] === 'library', $status['books_shape']);
|
|
check('bd_series detected as library shape', $status['series_shape'] === 'library', $status['series_shape']);
|
|
check('schema version not yet advanced', $status['version'] === 0, (string) $status['version']);
|
|
|
|
section('2. Running the migration');
|
|
$report = Umbra_Schema_Migration::maybe_migrate();
|
|
check('no errors reported', empty($report['errors']), implode('; ', $report['errors']));
|
|
check('books rows copied', $report['books_migrated'] === 2, (string) $report['books_migrated']);
|
|
check('series rows copied', $report['series_migrated'] === 1, (string) $report['series_migrated']);
|
|
check('schema version advanced to target', $report['version'] === Umbra_Schema_Migration::TARGET_VERSION);
|
|
|
|
section('3. Resulting schema is the catalogue schema');
|
|
$status = Umbra_Schema_Migration::status();
|
|
check('bd_books is now catalogue shape', $status['books_shape'] === 'catalogue', $status['books_shape']);
|
|
check('bd_series is now catalogue shape', $status['series_shape'] === 'catalogue', $status['series_shape']);
|
|
|
|
section('4. Legacy tables preserved, not dropped');
|
|
check('bd_books_legacy_library exists', $w->table_exists($w->bare('books_legacy_library')));
|
|
check('bd_series_legacy_library exists', $w->table_exists($w->bare('series_legacy_library')));
|
|
check('legacy books table still holds its 3 rows',
|
|
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
|
|
|
|
section('5. Book rows carried over');
|
|
$books = $w->rows('SELECT * FROM ' . $w->bare('books') . ' ORDER BY id');
|
|
check('two books present', count($books) === 2, (string) count($books));
|
|
|
|
$b1 = $books[0];
|
|
check('id preserved (1)', (int) $b1['id'] === 1, $b1['id']);
|
|
check('title resolved from the post', $b1['title'] === 'The Silent Harbour', $b1['title']);
|
|
check('slug taken from post_name', $b1['slug'] === 'the-silent-harbour', $b1['slug']);
|
|
check('description resolved from post_content', $b1['description'] === 'A novel of tide and memory.', $b1['description']);
|
|
check('isbn mapped onto isbn_ebook', $b1['isbn_ebook'] === '9781234567897', (string) $b1['isbn_ebook']);
|
|
check('asin mapped onto asin_ebook', $b1['asin_ebook'] === 'B00TESTASIN', (string) $b1['asin_ebook']);
|
|
check('amazon_asin retained for link building', $b1['amazon_asin'] === 'B00TESTASIN', (string) $b1['amazon_asin']);
|
|
check('series_id preserved', (int) $b1['series_id'] === 7, (string) $b1['series_id']);
|
|
|
|
$ids = array_map(function ($r) { return (int) $r['id']; }, $books);
|
|
check('orphan row (missing post) skipped, not crashed', !in_array(3, $ids, true), implode(',', $ids));
|
|
check('legacy row count preserved for recovery',
|
|
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
|
|
|
|
section('6. Ratings folded into catalogue columns');
|
|
check('goodreads rating mapped', (float) $b1['rating_goodreads'] === 4.35, (string) $b1['rating_goodreads']);
|
|
check('amazon rating preserved (column retained)', (float) $b1['rating_amazon'] === 4.60, (string) $b1['rating_amazon']);
|
|
$b2 = $books[1];
|
|
check('second book goodreads rating mapped', (float) $b2['rating_goodreads'] === 4.10, (string) $b2['rating_goodreads']);
|
|
|
|
section('7. Post linkage written with the preserved meta key');
|
|
check('_bd_bookshelf_book_id set for post 101', ($w->meta[101]['_bd_bookshelf_book_id'] ?? null) == 1);
|
|
check('_bd_bookshelf_book_id set for post 102', ($w->meta[102]['_bd_bookshelf_book_id'] ?? null) == 2);
|
|
check('publish date preserved on the post',
|
|
($w->meta[101]['_bd_bookshelf_publish_date'] ?? null) === '2024-03-01',
|
|
$w->meta[101]['_bd_bookshelf_publish_date'] ?? 'unset');
|
|
|
|
section('8. Series migrated');
|
|
$series = $w->rows('SELECT * FROM ' . $w->bare('series'));
|
|
check('one series row', count($series) === 1);
|
|
check('series id preserved', (int) $series[0]['id'] === 7, $series[0]['id']);
|
|
check('series name preserved', $series[0]['name'] === 'The Harbour Cycle', $series[0]['name']);
|
|
check('blurb mapped to description',
|
|
$series[0]['description'] === 'Ships, storms, and the people who stay.',
|
|
$series[0]['description']);
|
|
check('cover attachment resolved to a URL',
|
|
strpos((string) $series[0]['cover_image'], 'att-55') !== false,
|
|
(string) $series[0]['cover_image']);
|
|
|
|
section('9. Idempotency');
|
|
$again = Umbra_Schema_Migration::maybe_migrate();
|
|
check('second run is skipped', !empty($again['skipped']));
|
|
check('no rows duplicated on re-run',
|
|
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
|
|
|
|
section('10. An already-catalogue-shaped install is left alone');
|
|
// simulate an older site that never recorded the migration, but whose tables
|
|
// are already canonical
|
|
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('books_legacy_library'));
|
|
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('series_legacy_library'));
|
|
$GLOBALS['options'] = array();
|
|
$before_renames = count($w->renamed);
|
|
$third = Umbra_Schema_Migration::maybe_migrate();
|
|
check('no rows copied from an already-correct schema', $third['books_migrated'] === 0, (string) $third['books_migrated']);
|
|
check('no rename performed', count($w->renamed) === $before_renames);
|
|
check('books untouched', (int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
|
|
|
|
section('11. Duplicate slugs are resolved (catalogue slug is UNIQUE)');
|
|
seed_library_shape();
|
|
$GLOBALS['posts'][101]->post_name = 'harbour-lights';
|
|
$GLOBALS['posts'][102]->post_name = 'harbour-lights';
|
|
$collide = Umbra_Schema_Migration::maybe_migrate();
|
|
check('collision run reported no errors', empty($collide['errors']), implode('; ', $collide['errors']));
|
|
$slugs = $w->rows('SELECT slug FROM ' . $w->bare('books') . ' ORDER BY id');
|
|
$slugs = array_map(function ($r) { return $r['slug']; }, $slugs);
|
|
check('both books still present', count($slugs) === 2, implode(',', $slugs));
|
|
check('slugs are unique', count($slugs) === count(array_unique($slugs)), implode(',', $slugs));
|
|
|
|
section('RESULT');
|
|
echo "\n";
|
|
if ($fail) { echo "FAIL - {$fail} check(s) failed, {$pass} passed\n"; exit(1); }
|
|
echo "PASS - {$pass} checks, schema migration verified against a real database\n";
|