0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

wc_get_products()にmeta_queryを渡しても効かない — テストダブルが実装より行儀が良かった話

0
Posted at

この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/wc-get-products-meta-query-ignored )。

WooCommerce連携のプラグインを実装し、単体テスト868項目を全PASSさせた状態で実環境(WordPress 7.1 + WooCommerce 11.0.1)へ持ち込んだところ、購入導線が根本から壊れていた。原因はテストが甘かったからではなく、テストダブルが実装より行儀が良かったからだった。

本稿はその1件を、症状・原因・修正コード・「修正前ならFAILする」回帰テストまで実測ベースで記録したものです。

症状: 支払ったのにアクセス権が付与されない

対象は、記事単位の有料販売とチップ(投げ銭)をWooCommerce商品へ紐づけるプラグイン。金額ごとに商品を作り、記事側は金額から対応する商品を引いて決済へ渡す設計だった。

実環境で確認できた挙動は次の3つ。

  • チップ商品が金額を問わずすべて同一商品へ解決される
  • 初回購入の有料記事が、有料記事用ではなくチップ商品へ誤解決される
  • その結果、読者が支払ってもアクセス権が付与されない

決済そのものは成功する。注文は立つ。しかし付与処理が参照する商品IDが誤っているため、購入者は買ったはずの本文を読めない。課金系としては最悪の壊れ方で、しかも購入されるまで表面化しない。

原因: wc_get_products()meta_query を見ない

商品解決のコードはこうなっていた。価格メタ _acscw_price で商品を1件引く、というだけの処理である。

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

$product = $products ? $products[0] : null;

wc_get_products()WC_Product_Query を経由し、WC_Product_Data_Store_CPT::query()自分の知っているクエリ変数だけWP_Query 引数へ組み立てる。meta_query はその対応表に載っていないため、エラーにも警告にもならず、静かに落ちる

結果として実行されるのは「公開中の商品を1件」という条件だけのクエリになる。だから金額が何であろうと同じ1件が返り、上の3症状がまとめて発生する。付近には無検証のフォールバック結果を恒久キャッシュする実装もあり、誤解決を固定化して増悪させていた。

引数が黙って捨てられる点が本質で、これは WP_Query の感覚で wc_get_*() 系を触ると踏みやすい。

修正: WP_Query で引くか、データストアのフィルタへ載せる

最短の修正は WP_Query への置換。meta_query が確実に効き、IDだけ取れば十分なので fieldsno_found_rows も指定する。

// 修正後
$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;

wc_get_products() のインターフェースを維持したい場合は、独自クエリ変数をデータストアのフィルタで WP_Query 引数へ翻訳する。

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
);

// 呼び出し側は独自の引数名を使う(meta_query は渡さない)
$products = wc_get_products( [
    'status'      => 'publish',
    'limit'       => 1,
    'acscw_price' => $price,
] );

どちらでもよいが、meta_query をそのまま wc_get_products() へ渡す」という書き方だけは動かないと覚えておくのが実務的だと思う。

なぜ868項目のテストを素通りしたのか

ここが本題である。当方のテストは、実WordPressを起動せずに動く軽量シム+SQLite実DBのハーネスで、この時点でフェーズ2〜6の5本・計868項目が全PASSしていた。それでも上のバグは1件も検出できなかった。

理由はスタブの実装にある。

// 修正前のスタブ(抜粋)
function wc_get_products( array $args ) {
    $products = TestStore::products();

    if ( ! empty( $args['meta_query'] ) ) {
        // 実物の wc_get_products() はここを見ない
        $products = TestStore::filter_by_meta( $products, $args['meta_query'] );
    }

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

スタブを書いたとき、meta_query は「WordPress界隈で当然サポートされている引数」だと考えて素直に実装してしまっていた。実物は無視する引数を、スタブは律儀に解釈していたわけである。テストダブルが本物より親切だと、本番でだけ落ちるコードが緑のまま通過する。

対処として、スタブを実挙動へ寄せたうえで、未対応の引数を黙って受け取らないように変えた。

// 修正後のスタブ(抜粋)
function wc_get_products( array $args ) {
    // 実物がサポートするクエリ変数だけを許可する
    $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 );
}

実物が黙って捨てる引数を、テストでは明示的に落とす。曖昧なところをスタブ側で厳しくしておくと、実環境へ持ち込む前に同型の事故を拾える。

「修正前ならFAILする」ことを確認する

修正と同時に回帰テストを足したが、テストを足しただけでは「そのテストが本当にこのバグを捕まえるか」は分からない。追加したテストが修正前のコードで確実にFAILするかを機械的に確かめている。

// 追加した回帰テスト(抜粋)
$a = acscw_resolve_product_for_price( 300 );
$b = acscw_resolve_product_for_price( 500 );

assert_true( $a > 0 && $b > 0, '価格に対応する商品が解決できない' );
assert_not_equals( $a, $b, '価格違いが同一商品IDへ解決されている' );

確認手順は単純で、修正コミットを巻き戻すスクリプトで旧実装へ戻し、ハーネスを再走させるだけである。実測は次のとおり。

# 修正を巻き戻した状態
harness-v3-phase3 : PASS 91  FAIL 65

# 修正を戻した状態
harness-v3-phase3 : PASS 156 FAIL 0
(全フェーズ合計 868 項目 PASS / 0 FAIL)

65件が落ちる。つまりこの回帰テスト群は、確かに当該バグを捕まえている。逆に、巻き戻してもFAILが0なら、そのテストは何も検証していない。追加したテストの妥当性は、この一往復でしか確認できないと考えている。

まとめ

  • wc_get_products()meta_query を渡してもエラーにならず無視されるWP_Query を使うか、woocommerce_product_data_store_cpt_get_products_query で独自クエリ変数を翻訳する
  • テストダブルが実装より行儀が良いと、本番でだけ壊れるコードが全PASSで通過する。スタブは「実物が対応している範囲」に寄せ、未対応の引数は例外で落とす
  • 回帰テストは、修正を巻き戻したときにFAILすることまで確認して初めて価値が確定する(今回は65件FAIL→0)

実環境の検証を後回しにすると、緑のテストは安心材料ではなく、単にスタブと実装のどちらが正しいかを確かめていない状態になる。今回は提出前に踏めたが、購入されるまで表面化しない種類の不具合だっただけに、順序を変える価値のある教訓だった。


検証の記録や関連するプラグインは ACS Developer で公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?