Building change-listener integrations: the back-in-stock notifiers
This guide explains how the two back-in-stock notifier integrations in Super Speedy Imports are built on its change listener: why they capture changes during the import and deliver them afterwards, what happens to one stock change between the import and the customer’s email, how to add another notifier adapter, how to prove an adapter behaves exactly like a hand save in wp-admin, and how to test it on all three runners.
Available since 2.82.0.
The listener registry is internal. There is no public filter for registering a listener, and superspeedyimports_register_internal_change_listener() is documented in the code as not being a public API. This guide is for Super Speedy Imports contributors and for add-ons built in-house. If you maintain a third-party plugin, Adding compatibility for other plugins covers the public options.
The two integrations are:
| Notifier | Adapter | Integration id | Option key |
|---|---|---|---|
| Back In Stock Notifier for WooCommerce (CodeWooGeek), tested with 7.3.3 | functions/compatibility/back-in-stock-notifier.php | back-in-stock-notifier-for-woocommerce | back_in_stock_notify |
| WooCommerce Back In Stock Notifications (the official extension), tested with 3.3.5 | functions/compatibility/woocommerce-back-in-stock-notifications.php | woocommerce-back-in-stock-notifications | woocommerce_back_in_stock_notify |
Both sit on two shared files: functions/change-events.php (the events table, the registry, the dispatcher, the drain and the delivery worker) and functions/compatibility/restock-notifiers.php (the shared stock-status capture, the per-import option rule and the After Import switches).
How a store owner uses them is in Using Back In Stock Notifier with Super Speedy Imports.
Why capture first and deliver later
Super Speedy Imports writes _stock_status with one bulk UPDATE per meta key, joined to the batch table. No product object is loaded or saved, so WooCommerce never fires woocommerce_product_set_stock_status or woocommerce_variation_set_stock_status. Both notifiers hang everything off those two actions. Without an integration, an import that restocks a thousand products tells neither notifier anything, and no subscriber is mailed.
Two obvious fixes are both wrong:
- Fire the stock-status actions after the import, for every imported product. The notifier is then told about changes that did not happen. For a notifier, that is mail for products that were in stock all along.
- Save every product after the import. By then the old value is gone, so a save cannot tell “became in stock” from “was in stock”. It is also slow on a large catalogue.
A notifier acts on a transition, and the transition only exists for the instant before the bulk write. So the integration splits the work in two:
- Capture, inside the
update-postmetastage, immediately before the_stock_statuswrite: oneINSERT IGNORE ... SELECTcopies every genuine move (old value fromwp_postmeta, new value from the batch table) intowp_ssi_change_events. - Deliver, after the import succeeds: each captured event is handed to the notifier through the entry point its own save-time handler uses, one product at a time.
The events table is the queue of record. Action Scheduler and WP-Cron are only triggers, so a missed trigger delays delivery and a duplicate trigger delivers nothing twice.
Do not “fix” a missing notification by calling do_action( 'woocommerce_product_set_stock_status', ... ) for the import’s products. It reports moves that never happened, and the notifier mails every subscriber of every in-stock product in the file.
The parts of a listener
Each adapter registers one listener on plugins_loaded at priority 20, after the notifier has initialised. The registry rejects a definition missing id, watched_key, capture or deliver.
| Key | What it is |
|---|---|
id | Stable integration id, stored on every event row. |
label | Name shown in panels and CLI output. |
event_type | The kind of event, for example product_back_in_stock. Coalescing is per event type and object. |
watched_key | The postmeta key whose update triggers capture, _stock_status for both notifiers. Also the key the supersede check reads. |
active | Callable: is the notifier installed and initialised? |
option | name, label, description, tooltip and off_note for the per-import switch. |
enabled | Callable ( $template ): active and switched on for this import. Both capture and dispatch ask it. |
capture | Callable ( $template ): runs the capture statement and returns the row count, or false. |
deliver | Callable ( $event ): hands one event to the notifier. Returns true, or a reason string on failure. |
decline | Optional callable returning a reason to hold delivery, or null. |
supersede | true by default: cancel an event when the live value differs from the captured new value. Set false only for a listener that must see every step of a sequence. |
summary_label, handover_label | Operator wording for the completion screen, such as “products came back in stock”. |
links | Admin links shown with the counts, such as the notifier’s own queue screen. |
The per-import switch
superspeedyimports_change_listener_after_import_tasks() turns every registered listener that has an option and an active vendor into an After Import task under Notify other plugins, so it appears in the import’s After Import section and in the Ready to import? box. superspeedyimports_get_after_import_tasks() adds these before its superspeedyimports_after_import_tasks filter runs. The task has no run: it is the control, and the dispatcher is the worker.
The saved value is read at run time by superspeedyimports_change_listener_option_enabled():
function superspeedyimports_change_listener_option_enabled($template, $option_name) {
if (!is_object($template) || !isset($template->post_type) || $template->post_type !== 'product') {
return false;
}
$opts = isset($template->additional_options) && is_array($template->additional_options)
? $template->additional_options : array();
if (array_key_exists($option_name, $opts)) {
return !empty($opts[$option_name]);
}
return true;
}
Product imports only, on when never saved, and array_key_exists rather than empty() so an explicitly saved false stays off. Switched off, nothing is captured and nothing is delivered for that notifier.
The lifecycle of one event
Follow one product: out of stock, one subscriber, and the supplier’s file says 5 in stock.
1. Capture, before the write
stages/core/update-postmeta.php calls superspeedyimports_capture_postmeta_changes() before each per-key UPDATE, while wp_postmeta still holds the old value. For every listener whose watched_key matches and whose enabled returns true, it ensures the events table exists and calls the listener’s capture. A capture failure is logged as a warning and never fails the import; the key’s write goes ahead.
Both notifier adapters use the shared capture, superspeedyimports_capture_stock_status_events( $template, $integration_id, $event_type, $moves ). $moves maps each new status to the old statuses the notifier acts on. The statement it builds:
$sql = $wpdb->prepare(
"INSERT IGNORE INTO %i
(history_id, import_id, integration_id, event_type, object_id, old_value, new_value, status, created_at)
SELECT %d, %d, %s, %s, b.post_id, pm.meta_value, b._stock_status, 'pending', UTC_TIMESTAMP()
FROM %i b
INNER JOIN %i pm
ON pm.meta_id = (
SELECT MIN(pm2.meta_id) FROM %i pm2
WHERE pm2.post_id = b.post_id AND pm2.meta_key = '_stock_status'
)
WHERE b.is_update = 1
AND b.stock_status_intent = 1
AND (" . implode("\n OR ", $pairs) . ")", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $pairs holds only %s placeholders; every status is bound.
array_merge(
array($events, $history_id, $import_id, $integration_id, $event_type, $batch, $wpdb->postmeta, $wpdb->postmeta),
$pair_args
)
);
Each guard is there for a reason:
is_update = 1: a product the import creates cannot have subscribers.stock_status_intent = 1: the mass-mail guard. The WooCommerce product template sets it to 1 only when the file really set a stock status or quantity. A price-only file, where_stock_statusfalls back to a default, is never a stock move.- Literal statuses on both sides: each move is written as
(b._stock_status = %s AND pm.meta_value IN (%s, ...)). The batch table andwp_postmetacan differ in collation, so the two columns are never compared with each other. MIN(meta_id): the old value is deterministic even when a product carries duplicate_stock_statusrows.INSERT IGNOREwith a unique key on history, integration, event type and object: a recovery rerun of the stage captures nothing twice and keeps the first old value it saw.
The function first checks it has a real history ID, a real batch table, and the columns post_id, is_update, _stock_status and stock_status_intent; a batch table retained from an older version lacks the last one, and capture returns false rather than guessing.
At this point the product has one event row: old_value = outofstock, new_value = instock, status = pending. The UPDATE then writes instock.
2. Dispatch, after the import succeeds
superspeedyimports_run_post_import_tasks() runs “Dispatch change events” after cache invalidation, so vendor callbacks load fresh data. It is skipped on a targeted re-run of one After Import job: these effects leave the site, and a rebuild button must not send them twice. A failed or stopped run never reaches it.
superspeedyimports_dispatch_change_events() then, for each enabled listener:
- Asks
decline. A reason means a deliberate skip: the events staypendingand the reason is shown on the completion screen. - Reads
superspeedyimports_change_event_deliver_inline(defaulttrue). This is read before anything is spawned, because a spawned worker is a separate process that never sees a filter added in this one. - On WP-CLI with inline delivery on and a host that can start background processes, starts extra
wp ssi deliver-events --import=<id> <listener>workers:superspeedyimports_change_event_workers(default 4) minus one. - Drains: uncapped on WP-CLI, within
SUPERSPEEDYIMPORTS_CE_WEB_BUDGET_SECS(10 seconds) on the browser and scheduled runners. - Schedules
superspeedyimports_change_events_sweepfor five minutes later.
Any exception is caught and reported: an integration never fails an import that has succeeded.
3. The drain
superspeedyimports_change_events_drain( $import_id, $listener_id, $sync, $budget_secs ) is the one function every path calls: the dispatcher, the extra CLI workers, the sweep, the completion screen’s Run now button and wp ssi retry-events. It is idempotent and safe to run concurrently. In order:
- Decline, enforced here as well as at dispatch, so no other entry point can bypass it.
- Recover expired claims.
dispatchingrows claimed more than 15 minutes ago (SUPERSPEEDYIMPORTS_CE_CLAIM_EXPIRY_SECS, 900) go back topending.deliveringrows past the same expiry go back toqueuedwith a back-datedclaimed_at, so step 7 of this same drain picks them up. - Age cap.
pendingrows older than 7 days (SUPERSPEEDYIMPORTS_CE_AGE_CAP_DAYS) becomecancelledwithfail_reason = aged-out. An abandoned run’s captures never mail anyone weeks later. - Supersede.
pendingandqueuedrows whose product has nowatched_keyrow equal tonew_valuebecomecancelled/superseded. A later import or a manual edit has reversed the change. - Claim up to 500
pendingrows (SUPERSPEEDYIMPORTS_CE_CLAIM_CHUNK) with one conditionalUPDATE ... SET status = 'dispatching', claim_token = .... InnoDB row locks make concurrent runners take disjoint sets with no other coordination. - Coalesce within the claimed set: a second event for the same event type and product becomes
coalesced, so one product gets one hand-over. - Deliver in chunks of 25 (
SUPERSPEEDYIMPORTS_CE_CHUNK_SIZE). Inline, each chunk is markedqueuedand handed straight to the worker. When the web budget runs out, or inline delivery is off, the chunk is markedqueuedand scheduled as asuperspeedyimports_deliver_change_eventsaction (Action Scheduler where present, WP-Cron otherwise). - Re-kick
queuedrows whose action has not run: rows older than five minutes (SUPERSPEEDYIMPORTS_CE_STALLED_SECS) on an asynchronous drain, everyqueuedrow on a synchronous one.
4. The worker hands it to the notifier
superspeedyimports_deliver_change_events_worker() takes one event at a time:
- A conditional
queuedtodeliveringflip. Losing it means another worker owns the event. - An event whose listener is not registered is cancelled as
listener-unavailable. GET_LOCK( 'ssi_ce_<product id>', 5 ). Neither notifier’s subscriber selection is safe against two concurrent calls for one product, so Super Speedy Imports never makes two. No lock within 5 seconds returns the event toqueued.clean_post_cache(), then the live supersede check again, under the lock.- The adapter’s
deliver.truemarks the eventdelivered; a string marks itfailedwith that reason. - The lock is released in a
finally.
failed events are retried only on request, by Run now or wp ssi retry-events, which reset them to pending and drain synchronously.
5. The notifier sends the email
From here the notifier runs its own code, exactly as for a hand save. For WooCommerce Back In Stock Notifications, the adapter calls the extension’s handler and then its flush:
$sync = WC_BIS()->sync;
$sync->product_stock_status_changed($product_id, (string) $event->new_value, $product);
$sync->sync();
product_stock_status_changed() applies the extension’s product-type rule and stock threshold and expands a variable parent to its parent-managed variations, then queues the product in memory. sync() is what the extension otherwise runs on shutdown; calling it here keeps its work inside our lock. It fires woocommerce_bis_sync_handle_instock_products, which marks eligible notifications as queued and schedules one wc_bis_process_notifications_batch Action Scheduler chain. When that chain runs, the extension’s email class rechecks the product, sends the mail, records last_notified_date and deactivates the notification. Deactivation is the extension’s own duplicate barrier.
For Back In Stock Notifier for WooCommerce, the adapter calls the vendor’s own bulk re-check for one product:
do_action('cwg_backward_stock_check', array(absint($event->object_id)));
The vendor reloads the product, rechecks stock, visibility and minimum quantity, moves the subscriber from cwg_subscribed to cwg_queued and mails through its own background queue.
Super Speedy Imports never sends mail and never writes either notifier’s tables.
Event states
| State | Meaning |
|---|---|
pending | Captured, waiting for a drain. |
dispatching | Claimed by one drain. |
queued | Handed to the worker, inline or through a scheduled action. |
delivering | Inside the worker, under the product lock. |
delivered | The adapter returned true. |
coalesced | A duplicate of another event for the same product in the same claim. |
cancelled | fail_reason says why: superseded, aged-out or listener-unavailable. |
failed | The adapter returned a reason. Retried only on request. |
At the end of each sweep, delivered, coalesced and cancelled rows older than 7 days are deleted in chunks of 500.
To watch an import’s events: wp ssi changes --import=<id> (or --history=<id> for one run) prints the counts per state and flags a stall; wp ssi deliver-events --import=<id> [listener] drains by hand; wp ssi retry-events --import=<id> [listener] retries failures. See the CLI reference.
Adding another notifier adapter
The WooCommerce Back In Stock Notifications adapter is the worked example throughout.
Step 1: read the notifier
Answer five questions from the notifier’s code before writing anything:
- Which hooks does it listen to? The extension:
woocommerce_product_set_stock_statusandwoocommerce_variation_set_stock_status, handled byWC_BIS_Sync::product_stock_status_changed(). - What does its handler call? That is your delivery entry point. Prefer the handler itself to a lower-level method, so the notifier applies its own rules. The extension’s tasks class has a bulk
handle_instock_products( $ids ), but calling it directly skips the type, threshold and variation rules that live inWC_BIS_Sync, and loses our per-product lock and supersede check. - Which stock moves does it act on? Read it, then prove it (step 6). The extension sends everything except out of stock to its in-stock handler, so
onbackordercounts as in stock, and sends out of stock to a handler that re-arms spent sign-ups. CodeWooGeek acts only when the new status isinstock. - What stops it mailing twice? The extension deactivates a notification once sent; CodeWooGeek moves the subscriber out of
cwg_subscribed. Your adapter relies on that barrier, so find it. - Can any of its own settings process the same products at the same time? If so, that is a
declinecondition. CodeWooGeek has two: its periodic third-party stock scanner (update_stock_third_party), which does not take our lock, and “keep subscription entry subscribed after mail” (keep_status_subscribed), which removes its duplicate barrier. The extension has none.
Step 2: reproduce the gap
Before writing the adapter, prove the notifier misses an import on unmodified code: subscribe through the notifier’s real front-end form, restock the product with a real import, and show that no notification is queued and no mail is attempted. Then move the same product out of and back into stock through the WooCommerce product API and show one mail. That control proves your mail capture sees the notifier’s real output.
Step 3: write the adapter
Create functions/compatibility/<notifier-slug>.php. The WooCommerce adapter in full order:
Constants and detection. Detect the vendor by what you are going to call, not by a class name alone:
const SUPERSPEEDYIMPORTS_WCBIS_ID = 'woocommerce-back-in-stock-notifications';
const SUPERSPEEDYIMPORTS_WCBIS_OPTION = 'woocommerce_back_in_stock_notify';
/**
* Vendor present and initialised (it refuses to initialise on an unsupported
* WooCommerce, leaving WC_BIS()->sync unset).
*/
function superspeedyimports_wcbis_active() {
return function_exists('WC_BIS')
&& class_exists('WC_BIS_Sync')
&& isset(WC_BIS()->sync)
&& WC_BIS()->sync instanceof WC_BIS_Sync;
}
function superspeedyimports_wcbis_enabled($template) {
return superspeedyimports_wcbis_active()
&& superspeedyimports_change_listener_option_enabled($template, SUPERSPEEDYIMPORTS_WCBIS_OPTION);
}
Capture. Declare exactly the moves a hand save makes this notifier act on:
function superspeedyimports_wcbis_capture($template) {
return superspeedyimports_capture_stock_status_events(
$template,
SUPERSPEEDYIMPORTS_WCBIS_ID,
'product_stock_status_changed',
array(
'instock' => array('outofstock', 'onbackorder'),
'onbackorder' => array('outofstock', 'instock'),
'outofstock' => array('instock', 'onbackorder'),
)
);
}
The CodeWooGeek adapter passes array('instock' => array('outofstock', 'onbackorder')) with event type product_back_in_stock.
Delivery. Return a reason string, not true, when the vendor or the product has gone:
function superspeedyimports_wcbis_deliver($event) {
if (!superspeedyimports_wcbis_active()) {
return 'vendor plugin no longer active';
}
$product_id = absint($event->object_id);
$product = wc_get_product($product_id);
if (!$product) {
return 'product no longer exists';
}
$sync = WC_BIS()->sync;
$sync->product_stock_status_changed($product_id, (string) $event->new_value, $product);
$sync->sync();
return true;
}
Registration, after plugins_loaded so the vendor has initialised:
function superspeedyimports_wcbis_register() {
if (!function_exists('superspeedyimports_register_internal_change_listener')) {
return;
}
superspeedyimports_register_internal_change_listener(array(
'id' => SUPERSPEEDYIMPORTS_WCBIS_ID,
'label' => 'WooCommerce Back In Stock Notifications',
'event_type' => 'product_stock_status_changed',
'watched_key' => '_stock_status',
'active' => 'superspeedyimports_wcbis_active',
'option' => array(
'name' => SUPERSPEEDYIMPORTS_WCBIS_OPTION,
'label' => 'WooCommerce Back In Stock Notifications',
'description' => 'Tell WooCommerce Back In Stock Notifications about imported stock status changes, as saving the product by hand does',
'off_note' => 'Off: stock status changes from this import are not handed to WooCommerce Back In Stock Notifications, so its subscribers are not told.',
'tooltip' => 'When products in this import change stock status (back in stock, onto backorder, or out of stock), Super Speedy Imports reports each one to WooCommerce Back In Stock Notifications after the import completes, exactly as saving the product in wp-admin would. That plugin notifies subscribers when a product comes back in stock or goes on backorder, applies its own rules and sends its own emails - Super Speedy Imports never emails customers directly.<br><br><a href="https://www.superspeedyplugins.com/kb/super-speedy-imports/getting-started/back-in-stock-notifier/" target="_blank">Read more</a>',
),
'enabled' => 'superspeedyimports_wcbis_enabled',
'capture' => 'superspeedyimports_wcbis_capture',
'deliver' => 'superspeedyimports_wcbis_deliver',
'supersede' => true,
'summary_label' => 'product stock status changes',
'handover_label' => 'stock status changes handed to WooCommerce Back In Stock Notifications',
'links' => array(
array(
'label' => 'View notification queue',
'url' => admin_url('admin.php?page=bis_notifications&status=queued_bis_notifications'),
),
),
));
}
// After plugins_loaded so WC_BIS() has initialised.
add_action('plugins_loaded', 'superspeedyimports_wcbis_register', 20);
A decline callable, where the notifier needs one, returns a sentence the operator can act on. The CodeWooGeek one:
if (!empty($settings['update_stock_third_party'])) {
return 'Back In Stock Notifier\'s own periodic third-party inventory scanner is enabled and will pick these up on its next run. Disable it to let Super Speedy Imports queue precise checks immediately.';
}
Step 4: load it
Add a require_once in super-speedy-imports.php after the shared module:
require_once SUPERSPEEDYIMPORTS_PLUGIN_DIR . 'functions/change-events.php'; // pre-write delta capture + claim-based delivery (outbox) for named integrations
require_once SUPERSPEEDYIMPORTS_PLUGIN_DIR . 'functions/compatibility/restock-notifiers.php'; // shared restock capture + option rule for the back-in-stock notifier adapters
require_once SUPERSPEEDYIMPORTS_PLUGIN_DIR . 'functions/compatibility/back-in-stock-notifier.php'; // named integration: Back In Stock Notifier for WooCommerce
require_once SUPERSPEEDYIMPORTS_PLUGIN_DIR . 'functions/compatibility/woocommerce-back-in-stock-notifications.php'; // named integration: WooCommerce Back In Stock Notifications
Nothing else needs wiring. The After Import switch, the completion-screen counts, the CLI commands, the sweep and the Run now button all come from the registration.
Step 5: if the notifier watches something other than stock status
superspeedyimports_capture_stock_status_events() covers _stock_status only. A notifier that acts on another key, such as a price-drop alert on _price, needs its own capture function, called from the listener’s capture. Keep the same shape: one INSERT IGNORE ... SELECT into the events table, update rows only, an intent guard for any value the template can fill in by default, literal values rather than a cross-table column comparison, MIN(meta_id) for the old value, and a column check before running. A new capture statement is handwritten SQL, so it goes through the plugin’s SQL review before it merges.
Capture only the moves the notifier acts on when a product is saved by hand. Capturing more costs a hand-over per product for nothing; capturing less silently drops notifications. Step 6 is how you know you have it exactly right.
Proving parity with a hand save
The goal of an adapter is that an import makes the notifier do exactly what saving each product in wp-admin does. Prove it move by move.
- Fixtures. For each of the nine (old, new) pairs of
instock,onbackorderandoutofstock, create two published simple products with unmanaged stock: one to save by hand (“H”) and one to import (“I”). Give every out-of-stock product a real subscriber through the notifier’s own sign-up path. - Hand save. Drive wp-admin with Playwright: Products > Edit, Inventory tab, set the new stock status, Update, and read the saved value back.
- Import. Map the new status onto the “I” products in one import matched by SKU, and run it through the runner under test.
- Drain Action Scheduler in fresh processes with
wp action-scheduler run --force, as production does. An in-process drain is not equivalent: Action Scheduler stops claiming once its process is 30 seconds old, which misses batches under load. - Compare, per pair, what the notifier did for H and for I, from its own hooks, its subscriber rows and the captured mail.
A test mu-plugin records the notifiers’ own hooks without changing what they do, and captures mail instead of sending it:
add_action('woocommerce_bis_sync_handle_instock_products', function ($ids) {
rp_probe_log(array('event' => 'wcbis_instock', 'ids' => array_map('intval', (array) $ids)));
}, 1);
add_action('woocommerce_bis_sync_handle_outofstock_products', function ($ids) {
rp_probe_log(array('event' => 'wcbis_outofstock', 'ids' => array_map('intval', (array) $ids)));
}, 1);
add_action('cwginstock_trigger_status', function ($id, $status) {
rp_probe_log(array('event' => 'cwg_trigger', 'ids' => array((int) $id), 'status' => (string) $status));
}, 1, 2);
The hand-save results for the two supported notifiers, which the adapters’ $moves arrays reproduce:
| Move | WooCommerce fires a status change | WooCommerce Back In Stock Notifications | Back In Stock Notifier (CodeWooGeek) |
|---|---|---|---|
| instock to onbackorder | yes | in-stock handler | nothing |
| instock to outofstock | yes | out-of-stock handler (re-arms) | nothing |
| onbackorder to instock | yes | in-stock handler | queues mail |
| onbackorder to outofstock | yes | out-of-stock handler | nothing |
| outofstock to instock | yes | in-stock handler, subscriber mailed | subscriber mailed |
| outofstock to onbackorder | yes | in-stock handler, subscriber mailed | nothing, subscriber stays subscribed |
| any status to itself | no | nothing | nothing |
Run the comparison red first, on code without your adapter or with a narrower $moves, and confirm every pair you intend to fix differs. A parity test that never failed proves nothing.
The one deliberate difference from a hand save is stock_status_intent. A hand save always means the administrator chose the status. An import row may only have a defaulted instock, so a move to instock is captured only when the file set the stock status or quantity.
Testing on all three runners
An import runs from WP-CLI, from the browser (Run Now in wp-admin, through the REST runner) or from the scheduler. Delivery behaves differently on each: CLI drains with no limit and spare workers, the browser and scheduled runners drain within 10 seconds and leave the rest to the queue. Test the adapter on every one.
| Case | How to drive it | What it proves |
|---|---|---|
cli | wp ssi <import id> | Uncapped inline drain, extra deliver-events workers |
browser | Playwright: Imports > Run > Start Import | REST runner, 10-second web budget, Action Scheduler fallback |
scheduled | superspeedyimports_scheduled_tick() in fresh non-WP-CLI processes, one stage per tick | Scheduled runner across several requests |
option-off | Save the option as false, run by CLI | Nothing captured, nothing delivered, sign-ups untouched |
The scheduled case refuses to run under WP-CLI, so it really exercises the web path:
define('WP_USE_THEMES', false);
require $site . '/wp-load.php';
if (defined('WP_CLI') && WP_CLI) throw new RuntimeException('Web context required');
$id = (int) $argv[2];
add_filter('superspeedyimports_scheduled_tick_budget', function () { return 0; });
For each case assert on the notifier’s own rows and on captured mail, never on our events table alone: an event marked delivered is our side of the hand-over, and the notifier can still decide to send nothing. Useful fixtures beyond the plain restock: a product restocked to 0 (no event), a product in stock both before and after (no event), a variation, a product moved onto backorder, and a run with both notifiers active, where each must capture and deliver only its own events.
Also cover the deferred path and the crash windows:
- Inline off. Add
superspeedyimports_change_event_deliver_inlinereturningfalse(from an mu-plugin, so spawned processes see it too). The CLI import must leave eventsqueuedwith no mail, and the queue runner must then deliver exactly one mail. - Fault points. With
SSI_TEST_FAULTSdefined,superspeedyimports_test_fault()fires thesuperspeedyimports_test_faultaction atafter_capture_commit,before_write:_stock_status,after_completion_before_dispatch,after_claim_before_schedule,after_schedule_before_queued_mark,delivering_before_vendoranddelivering_after_vendor. Make a point sleep, kill the process withkill -9while it sleeps, then rerun and assert exactly one mail. It is inert in production. - Supersede. Reverse the stock status by hand between the import and delivery; the event must end
cancelled/supersededwith no mail. - Repeat. Run the same import twice. The second run captures nothing, because nothing moved.
Run the tests on a dedicated site with WooCommerce HPOS on, WooCommerce’s coming-soon mode off (it hides the shop from the logged-out sign-up form), and WP-Cron disabled so only the test advances a scheduled run.
Further reading
wp ssi changes, deliver-events and retry-events in full.Written for Super Speedy Imports 3.65.0.

Comments