DEV Community

ACS Developer
ACS Developer

Posted on Originally published at zenn.dev

wc_get_products() Silently Ignores meta_query: When Your Test Double Is Better Behaved Than the Implementation

868 unit tests passed and the purchase flow was still broken in production: wc_get_products() silently drops meta_query, and my test stub was politely honoring an argument the real function ignores.

I built a WooCommerce integration plugin, got all 868 unit tests passing, and took it to a real environment (WordPress 7.1 + WooCommerce 11.0.1) — where the purchase flow was broken at its root. The cause was not weak tests. It was that the test double was better behaved than the implementation.

This article records that single incident on a measured basis: symptoms, cause, fix, and a regression test that provably fails against the pre-fix code.

Symptom: they paid, and access was never granted

The plugin sells individual articles and handles tips (donations) by mapping them to WooCommerce products. A product is created per amount, and the article side looks up the matching product by amount and hands it to checkout.

Three behaviors showed up in the real environment:

  • Tip products all resolve to the same product, regardless of amount
  • A first-time purchase of a paid article resolves to a tip product instead of the paid-article product
  • As a result, readers who pay are never granted access

Payment itself succeeds. The order is created. But the product ID the granting logic looks at is wrong, so buyers cannot read the content they bought. For a paid product this is the worst possible failure mode, and it does not surface until someone actually buys.

Cause: wc_get_products() does not look at meta_query

The product resolution code looked like this — just fetch one product by the price meta _acscw_price.

// before
$products = wc_get_products( [
    'status'     => 'publish',
    'limit'      => 1,
    'meta_query' => [
        [
            'key'   => '_acscw_price',
            'value' => (string) $price,
        ],
    ],
] );

$product = $products ? $products[0] : null;
Enter fullscreen mode Exit fullscreen mode

wc_get_products() goes through WC_Product_Query, and WC_Product_Data_Store_CPT::query() builds WP_Query arguments only from the query vars it knows about. meta_query is not in that mapping table, so it is dropped silently — no error, no warning.

The query that actually executes is therefore just "one published product". So any amount returns the same single product, and all three symptoms appear at once. Nearby there was also code that permanently cached an unvalidated fallback result, which locked the misresolution in place and made it worse.

The essence is that an argument gets discarded silently, and this is easy to hit if you touch the wc_get_*() family with a WP_Query mindset.

Fix: use WP_Query, or register it on the data store filter

The shortest fix is switching to WP_Query. meta_query reliably applies, and since IDs are all you need, specify fields and no_found_rows too.

// after
$query = new WP_Query( [
    'post_type'      => 'product',
    'post_status'    => 'publish',
    'posts_per_page' => 1,
    'fields'         => 'ids',
    'no_found_rows'  => true,
    'meta_query'     => [
        [
            'key'   => '_acscw_price',
            'value' => (string) $price,
        ],
    ],
] );

$product_id = $query->posts ? (int) $query->posts[0] : 0;
$product    = $product_id ? wc_get_product( $product_id ) : null;
Enter fullscreen mode Exit fullscreen mode

If you want to keep the wc_get_products() interface, translate a custom query var into WP_Query arguments through the data store filter.

add_filter(
    'woocommerce_product_data_store_cpt_get_products_query',
    function ( $wp_query_args, $query_vars ) {
        if ( isset( $query_vars['acscw_price'] ) && '' !== $query_vars['acscw_price'] ) {
            $wp_query_args['meta_query'][] = [
                'key'   => '_acscw_price',
                'value' => (string) $query_vars['acscw_price'],
            ];
        }
        return $wp_query_args;
    },
    10,
    2
);

// the caller uses the custom argument name (never passes meta_query)
$products = wc_get_products( [
    'status'      => 'publish',
    'limit'       => 1,
    'acscw_price' => $price,
] );
Enter fullscreen mode Exit fullscreen mode

Either is fine, but practically speaking it is worth memorizing that the one thing that does not work is passing meta_query straight into wc_get_products().

Why 868 tests sailed right past it

This is the real subject. My tests run on a lightweight shim plus a real SQLite database harness, without booting a real WordPress, and at that point 5 suites (phases 2–6) totaling 868 assertions were all passing. None of them caught the bug above.

The reason is in the stub implementation.

// the stub, before (excerpt)
function wc_get_products( array $args ) {
    $products = TestStore::products();

    if ( ! empty( $args['meta_query'] ) ) {
        // the real wc_get_products() never looks at this
        $products = TestStore::filter_by_meta( $products, $args['meta_query'] );
    }

    return array_slice( $products, 0, $args['limit'] ?? count( $products ) );
}
Enter fullscreen mode Exit fullscreen mode

When I wrote the stub, I assumed meta_query was "an argument that is obviously supported everywhere in WordPress" and implemented it faithfully. The real function ignores the argument; the stub dutifully interpreted it. When a test double is kinder than the real thing, code that only breaks in production sails through green.

As the countermeasure I moved the stub closer to real behavior and made it refuse to silently accept unsupported arguments.

// the stub, after (excerpt)
function wc_get_products( array $args ) {
    // allow only the query vars the real function supports
    $supported = [ 'status', 'type', 'limit', 'offset', 'orderby', 'order', 'return', 'sku', 'category' ];
    $unknown   = array_diff( array_keys( $args ), $supported );

    if ( $unknown ) {
        throw new RuntimeException(
            'wc_get_products() silently drops: ' . implode( ', ', $unknown )
        );
    }

    return array_slice( TestStore::products( $args ), 0, $args['limit'] ?? -1 );
}
Enter fullscreen mode Exit fullscreen mode

What the real thing drops silently, the test drops loudly. Making the stub stricter exactly where behavior is ambiguous lets you catch this class of accident before it reaches a real environment.

Confirming the test fails against the old code

I added regression tests alongside the fix, but adding a test does not tell you whether that test actually catches this bug. So I mechanically verified that the new tests definitely FAIL against the pre-fix code.

// added regression test (excerpt)
$a = acscw_resolve_product_for_price( 300 );
$b = acscw_resolve_product_for_price( 500 );

assert_true( $a > 0 && $b > 0, 'no product resolves for the price' );
assert_not_equals( $a, $b, 'different prices resolve to the same product ID' );
Enter fullscreen mode Exit fullscreen mode

The procedure is simple: a script reverts the fix commit back to the old implementation, then re-runs the harness. The measurements:

# with the fix reverted
harness-v3-phase3 : PASS 91  FAIL 65

# with the fix restored
harness-v3-phase3 : PASS 156 FAIL 0
(868 assertions PASS / 0 FAIL across all phases)
Enter fullscreen mode Exit fullscreen mode

65 tests fail. So this regression suite really does catch the bug. Conversely, if reverting produces 0 failures, the test is verifying nothing. I think a new test's validity can only be established by that round trip.

Summary

  • Passing meta_query to wc_get_products() is ignored without an error. Use WP_Query, or translate a custom query var through woocommerce_product_data_store_cpt_get_products_query
  • When a test double is better behaved than the implementation, code that only breaks in production passes everything. Keep stubs within "what the real thing supports", and make unsupported arguments throw
  • A regression test's value is only settled once you confirm it fails when you revert the fix (here: 65 failures → 0)

Putting off verification in a real environment turns green tests from reassurance into merely not having checked which of the stub and the implementation is right. I caught this before shipping, but given that it is the kind of defect that stays invisible until someone buys something, it was worth reordering the process.


I publish verification records and related plugins on ACS Developer.

Originally published in Japanese on Zenn: https://zenn.dev/acs_developer/articles/wc-get-products-meta-query-ignored

Top comments (0)