Developers

Building change-listener integrations: the back-in-stock notifiers

Published

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.

Note

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:

NotifierAdapterIntegration idOption key
Back In Stock Notifier for WooCommerce (CodeWooGeek), tested with 7.3.3functions/compatibility/back-in-stock-notifier.phpback-in-stock-notifier-for-woocommerceback_in_stock_notify
WooCommerce Back In Stock Notifications (the official extension), tested with 3.3.5functions/compatibility/woocommerce-back-in-stock-notifications.phpwoocommerce-back-in-stock-notificationswoocommerce_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:

  1. Capture, inside the update-postmeta stage, immediately before the _stock_status write: one INSERT IGNORE ... SELECT copies every genuine move (old value from wp_postmeta, new value from the batch table) into wp_ssi_change_events.
  2. 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.

Common mistake

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.

KeyWhat it is
idStable integration id, stored on every event row.
labelName shown in panels and CLI output.
event_typeThe kind of event, for example product_back_in_stock. Coalescing is per event type and object.
watched_keyThe postmeta key whose update triggers capture, _stock_status for both notifiers. Also the key the supersede check reads.
activeCallable: is the notifier installed and initialised?
optionname, label, description, tooltip and off_note for the per-import switch.
enabledCallable ( $template ): active and switched on for this import. Both capture and dispatch ask it.
captureCallable ( $template ): runs the capture statement and returns the row count, or false.
deliverCallable ( $event ): hands one event to the notifier. Returns true, or a reason string on failure.
declineOptional callable returning a reason to hold delivery, or null.
supersedetrue 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_labelOperator wording for the completion screen, such as “products came back in stock”.
linksAdmin 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_status falls 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 and wp_postmeta can 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_status rows.
  • INSERT IGNORE with 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:

  1. Asks decline. A reason means a deliberate skip: the events stay pending and the reason is shown on the completion screen.
  2. Reads superspeedyimports_change_event_deliver_inline (default true). This is read before anything is spawned, because a spawned worker is a separate process that never sees a filter added in this one.
  3. 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.
  4. Drains: uncapped on WP-CLI, within SUPERSPEEDYIMPORTS_CE_WEB_BUDGET_SECS (10 seconds) on the browser and scheduled runners.
  5. Schedules superspeedyimports_change_events_sweep for 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:

  1. Decline, enforced here as well as at dispatch, so no other entry point can bypass it.
  2. Recover expired claims. dispatching rows claimed more than 15 minutes ago (SUPERSPEEDYIMPORTS_CE_CLAIM_EXPIRY_SECS, 900) go back to pending. delivering rows past the same expiry go back to queued with a back-dated claimed_at, so step 7 of this same drain picks them up.
  3. Age cap. pending rows older than 7 days (SUPERSPEEDYIMPORTS_CE_AGE_CAP_DAYS) become cancelled with fail_reason = aged-out. An abandoned run’s captures never mail anyone weeks later.
  4. Supersede. pending and queued rows whose product has no watched_key row equal to new_value become cancelled / superseded. A later import or a manual edit has reversed the change.
  5. Claim up to 500 pending rows (SUPERSPEEDYIMPORTS_CE_CLAIM_CHUNK) with one conditional UPDATE ... SET status = 'dispatching', claim_token = .... InnoDB row locks make concurrent runners take disjoint sets with no other coordination.
  6. Coalesce within the claimed set: a second event for the same event type and product becomes coalesced, so one product gets one hand-over.
  7. Deliver in chunks of 25 (SUPERSPEEDYIMPORTS_CE_CHUNK_SIZE). Inline, each chunk is marked queued and handed straight to the worker. When the web budget runs out, or inline delivery is off, the chunk is marked queued and scheduled as a superspeedyimports_deliver_change_events action (Action Scheduler where present, WP-Cron otherwise).
  8. Re-kick queued rows whose action has not run: rows older than five minutes (SUPERSPEEDYIMPORTS_CE_STALLED_SECS) on an asynchronous drain, every queued row on a synchronous one.

4. The worker hands it to the notifier

superspeedyimports_deliver_change_events_worker() takes one event at a time:

  1. A conditional queued to delivering flip. Losing it means another worker owns the event.
  2. An event whose listener is not registered is cancelled as listener-unavailable.
  3. 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 to queued.
  4. clean_post_cache(), then the live supersede check again, under the lock.
  5. The adapter’s deliver. true marks the event delivered; a string marks it failed with that reason.
  6. 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

StateMeaning
pendingCaptured, waiting for a drain.
dispatchingClaimed by one drain.
queuedHanded to the worker, inline or through a scheduled action.
deliveringInside the worker, under the product lock.
deliveredThe adapter returned true.
coalescedA duplicate of another event for the same product in the same claim.
cancelledfail_reason says why: superseded, aged-out or listener-unavailable.
failedThe 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:

  1. Which hooks does it listen to? The extension: woocommerce_product_set_stock_status and woocommerce_variation_set_stock_status, handled by WC_BIS_Sync::product_stock_status_changed().
  2. 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 in WC_BIS_Sync, and loses our per-product lock and supersede check.
  3. 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 onbackorder counts as in stock, and sends out of stock to a handler that re-arms spent sign-ups. CodeWooGeek acts only when the new status is instock.
  4. 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.
  5. Can any of its own settings process the same products at the same time? If so, that is a decline condition. 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.

Best practice

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.

  1. Fixtures. For each of the nine (old, new) pairs of instock, onbackorder and outofstock, 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.
  2. Hand save. Drive wp-admin with Playwright: Products > Edit, Inventory tab, set the new stock status, Update, and read the saved value back.
  3. Import. Map the new status onto the “I” products in one import matched by SKU, and run it through the runner under test.
  4. 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.
  5. 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:

MoveWooCommerce fires a status changeWooCommerce Back In Stock NotificationsBack In Stock Notifier (CodeWooGeek)
instock to onbackorderyesin-stock handlernothing
instock to outofstockyesout-of-stock handler (re-arms)nothing
onbackorder to instockyesin-stock handlerqueues mail
onbackorder to outofstockyesout-of-stock handlernothing
outofstock to instockyesin-stock handler, subscriber mailedsubscriber mailed
outofstock to onbackorderyesin-stock handler, subscriber mailednothing, subscriber stays subscribed
any status to itselfnonothingnothing

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.

Did you know?

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.

CaseHow to drive itWhat it proves
cliwp ssi <import id>Uncapped inline drain, extra deliver-events workers
browserPlaywright: Imports > Run > Start ImportREST runner, 10-second web budget, Action Scheduler fallback
scheduledsuperspeedyimports_scheduled_tick() in fresh non-WP-CLI processes, one stage per tickScheduled runner across several requests
option-offSave the option as false, run by CLINothing 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_inline returning false (from an mu-plugin, so spawned processes see it too). The CLI import must leave events queued with no mail, and the queue runner must then deliver exactly one mail.
  • Fault points. With SSI_TEST_FAULTS defined, superspeedyimports_test_fault() fires the superspeedyimports_test_fault action at after_capture_commit, before_write:_stock_status, after_completion_before_dispatch, after_claim_before_schedule, after_schedule_before_queued_mark, delivering_before_vendor and delivering_after_vendor. Make a point sleep, kill the process with kill -9 while 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 / superseded with 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

Which plugins need an integration, After Import tasks and the public options for third parties.
What store owners see, the per-import switch and checking notifications went out.
The public completion actions and the run report.
wp ssi changes, deliver-events and retry-events in full.

Written for Super Speedy Imports 3.65.0.

Comments

▲
▼
1/1