No language pack ever loaded under the old 'comiceasel' domain, but a site
that compiled its own comiceasel-{locale}.mo did load it, and the rename
would have dropped that translation without a word. A load_textdomain_mofile
filter answers with the legacy filename when nothing exists under the new
one, looking both in wp-content/languages/plugins and in the directory
WordPress asked about, since 6.7 names the registered domain directory rather
than the one the file is in.
The explicit load_plugin_textdomain() call is kept and commented for the same
reason: on WordPress before 6.7 nothing else requests a translation file by
name, so it is the only place the filter can intervene.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The domain has been 'comiceasel' since 2012 while the wordpress.org slug is
'comic-easel'. Language packs from translate.wordpress.org are keyed to the
slug, so they could never load; no working translation ever shipped against
the old domain. Renames the domain in every gettext call, in
load_plugin_textdomain(), in both Text Domain headers, and on the lang/
template files, which already carried comic-easel domain markers internally.
Option keys, admin page slugs, nonce actions and style handles that share the
comiceasel prefix are deliberately left alone; only the $domain argument moved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Widget form() methods printed get_field_id()/get_field_name() straight into
id and name attributes, and the labels used _e(). Wrap the field helpers in
esc_attr(), switch the labels to esc_html_e(), and swap strip_tags() for
wp_strip_all_tags() on the stored titles. Adds the matching test stubs.
Assembled markup ($before_widget, filtered titles, $output) is left alone;
escaping it would change what existing sites render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cast hovercard widget built its CSS and JS URLs from a literal
comic-easel/ path, so the widget lost its assets whenever the plugin
was installed under any other directory name. Use the plugin URL the
rest of the plugin already resolves at runtime, and version the style
to match the script it pairs with.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The manual-testing notes said the endpoint could not be reached without a
PayPal sandbox account, which was true when they were written. This branch
adds the filters that make a local stand-in possible, so document them.
The recipe and the expectation table are what was actually used to verify this
branch against a real WordPress install: a mu-plugin answering the handshake,
a filter capturing the notification email, and a request per case.
The reject paths are listed alongside the accept path deliberately. Each is
meant to state its reason in the email to the owner rather than fail silently,
and that is only true if someone checks it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the trust matrix for the comic HTML fields -- author capability, last
editor capability, missing post -- and asserts that decoding happens before
the trust check.
Also pins the meta box round trip: a value stored by the meta box renders
identically to before, while a value stored raw is inert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The price lookup treated a stored zero as a real price while the form and the
meta box both treated it as unset, so a comic with a zero recorded price
showed the default on the form but expected zero from the notification --
which disabled the amount check for that cart.
Use the same emptiness test as the other two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The label is translated when the notification arrives, but the item name was
built when the form was rendered. If the site language changed between those
two moments, originals were read as prints.
Match the translated label or the untranslated one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The validation command was merged first but the notification merged over it,
so a submitted cmd field replaced it. This fails closed today, since PayPal
will not confirm a request that is not a validation request, but it is one
refactor away from mattering.
Build the body so the validation command is both first and authoritative.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Passing ENT_QUOTES alone to htmlspecialchars() replaces PHP 8.1's default flag
set rather than adding to it, dropping ENT_SUBSTITUTE -- so a single invalid
UTF-8 byte made the call return an empty string and the whole field rendered
blank.
Pass ENT_SUBSTITUTE explicitly, and fall back to UTF-8 when blog_charset is
empty or unrecognised, which produces the same empty result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asserts the emitted SQL for the archive and character-list queries: the
chapter is always an integer and the sort direction is always the literal ASC
or DESC, whatever the shortcode attribute contained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit suite covers the plugin's logic. It cannot cover the settings forms, the comic meta
boxes, the widget forms or the admin list columns, because those are templates with no
function boundary to call -- and they are exactly where a broken escaping change shows up as
something a user notices.
Testing those needs a real WordPress, and getting this plugin into a state where its
interesting code paths actually run turns out to be full of traps that make working features
look broken: the plugin directory has to be named comic-easel or one widget loses its
assets; it has to be activated through the Plugins screen because activation adds a column
that several queries depend on; chapter Order defaults to 0 and 0 makes one archive mode
render empty; a comic with no featured image renders as nothing but an HTML comment; any
value in refer-only hides the comic from everybody; enabling transcripts in posts makes the
transcript shortcode return nothing by design.
None of that was written down anywhere. It is now, along with how to reach the PayPal IPN
endpoint locally -- which otherwise cannot be exercised at all, since it exits unless PayPal
returns VERIFIED.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Security fixes to output escaping carry a specific risk: the fix silently changes what
users see. These tests pin the behaviour that must NOT change, so that any such drift shows
up as a failing assertion rather than as a bug report.
Chosen deliberately to be stable across the fixes that follow, so they stay green
throughout rather than needing to be rewritten by each one. The security assertions -- that
a shortcode attribute cannot reach the SQL string, that a javascript: URI cannot be stored
as a URL -- ship with the commits that make them true, so each fix arrives with the test
that would have caught its absence.
The two most valuable assertions here encode properties rather than values:
- Rendering a transcript must be stable across saves: a value stored the way the save
handler stores it, through esc_textarea(), must render back as exactly those bytes. The
case that discriminates is an author who typed a literal entity -- esc_textarea() stores
"<b>" as "&lt;b&gt;", and an escape that declines to re-encode existing
entities hands back one level short, so the author's literal text decays into a live tag a
save at a time.
- The same round-trip property for widget link labels, fed through update() twice.
ArchiveQueryTest uses the $wpdb spy rather than a database, and asserts that table names
come from $wpdb rather than a hardcoded prefix -- a query with the prefix baked in silently
returns nothing on any site that did not accept the installer default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin has never had a test of any kind. The obstacle has always been that testing a
WordPress plugin appears to require WordPress, and therefore a database.
It does not, for the part that matters. The plugin's logic -- escaping decisions, SQL
construction, payment validation -- depends only on its arguments plus a small set of
WordPress helpers. Stubbing those gives a suite that runs in milliseconds with no database,
no Docker and no WordPress checkout.
Three of the stubs have to be faithful or the tests they support quietly stop meaning
anything, and this is documented at the top of tests/stubs.php:
- esc_html()/esc_attr() call _wp_specialchars() with $double_encode = false, so they leave
existing entities alone, while esc_textarea() double-encodes. Much of this plugin's
escaping behaviour turns on that difference, so a naive htmlspecialchars() stub would
give the wrong answer.
- wp_kses_post() is a recorder returning a sentinel rather than a reimplementation. The
question worth asking is "was filtering applied", not "what did it strip".
- apply_filters() passes through by default but is overridable, because the plugin's
filters are the seams the tests need.
Also included is a $wpdb spy that records the SQL it is handed. That is what allows the
query construction to be tested without a database at all -- which matters here, because
the plugin's queries use MySQL-only date functions that a SQLite test database would
misreport.
HarnessTest.php asserts these properties of the harness itself. If it fails, nothing else
in the suite should be trusted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>