merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 52d6b14afc917ca78a5a9d19161a2115343b98b5
parent 87f5baf8e9dfa690fe902bd9c525610da8251e77
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 23:45:39 +0200

fix inventory_deleted webhook trigger

Diffstat:
Msrc/backenddb/pg_do_handle_inventory_changes.sql | 47+++++++++++++++++++++++++++--------------------
Msrc/backenddb/test_merchantdb.c | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 153 insertions(+), 20 deletions(-)

diff --git a/src/backenddb/pg_do_handle_inventory_changes.sql b/src/backenddb/pg_do_handle_inventory_changes.sql @@ -22,7 +22,11 @@ DECLARE webhook RECORD; -- To iterate over all matching webhooks my_instance_id INT8; my_event_type TEXT; - my_do_new BOOLEAN; + -- Record the un-prefixed placeholders are taken from: NEW, except on + -- DELETE where NEW does not exist and the row that went away is the + -- one the webhook is about. + my_rec RECORD; + my_do_cur BOOLEAN; my_do_old BOOLEAN; BEGIN SELECT SUBSTRING(current_schema()::TEXT @@ -32,15 +36,18 @@ BEGIN CASE TG_OP WHEN 'INSERT' THEN my_event_type := 'inventory_added'; - my_do_new := TRUE; + my_rec := NEW; + my_do_cur := TRUE; my_do_old := FALSE; WHEN 'DELETE' THEN my_event_type := 'inventory_deleted'; - my_do_new := FALSE; + my_rec := OLD; + my_do_cur := TRUE; my_do_old := FALSE; WHEN 'UPDATE' THEN my_event_type := 'inventory_updated'; - my_do_new := TRUE; + my_rec := NEW; + my_do_cur := TRUE; my_do_old := TRUE; IF (to_jsonb(OLD) - 'total_locked' - 'total_locked_frac') IS NOT DISTINCT FROM @@ -63,56 +70,56 @@ BEGIN LOOP -- Resolve placeholders for the current webhook resolved_body := webhook.body_template; - IF my_do_new + IF my_do_cur THEN resolved_body := merchant.replace_placeholder(resolved_body, 'webhook_type', my_event_type); resolved_body := merchant.replace_placeholder(resolved_body, 'product_serial', - NEW.product_serial::TEXT); + my_rec.product_serial::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'product_id', - NEW.product_id); + my_rec.product_id); resolved_body := merchant.replace_placeholder(resolved_body, 'description', - NEW.description); + my_rec.description); resolved_body := merchant.replace_placeholder(resolved_body, 'description_i18n', - NEW.description_i18n::TEXT); + my_rec.description_i18n::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'unit', - NEW.unit); + my_rec.unit); resolved_body := merchant.replace_placeholder(resolved_body, 'image', - NEW.image); + my_rec.image); resolved_body := merchant.replace_placeholder(resolved_body, 'taxes', - NEW.taxes::TEXT); + my_rec.taxes::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'price', - NEW.price_array[1]::TEXT); + my_rec.price_array[1]::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'unit_price', - NEW.price_array::TEXT); + my_rec.price_array::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'total_stock', - NEW.total_stock::TEXT); + my_rec.total_stock::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'total_sold', - NEW.total_sold::TEXT); + my_rec.total_sold::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'total_lost', - NEW.total_lost::TEXT); + my_rec.total_lost::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'address', - NEW.address::TEXT); + my_rec.address::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'next_restock', - NEW.next_restock::TEXT); + my_rec.next_restock::TEXT); resolved_body := merchant.replace_placeholder(resolved_body, 'minimum_age', - NEW.minimum_age::TEXT); + my_rec.minimum_age::TEXT); END IF; IF my_do_old THEN diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -256,6 +256,41 @@ query_sql_num (const char *sql, } +/** + * Evaluates @a sql, which must be a parameter-less query returning + * exactly one row with a single TEXT column named "txt". + * + * @param sql the query to run + * @param[out] txt set to the value returned, to be freed by the caller + * @return 0 on success, 1 otherwise. + */ +static int +query_sql_text (const char *sql, + char **txt) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_string ("txt", + txt), + GNUNET_PQ_result_spec_end + }; + + TEST_COND_RET_ON_FAIL (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + sql), + "Preparing direct query failed\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs), + "Direct query failed\n"); + return 0; +} + + /* ********** Instances ********** */ @@ -8538,6 +8573,96 @@ test_delete_webhook (const struct InstanceData *instance, /** + * Tests that deleting a product queues an 'inventory_deleted' webhook + * whose placeholders were resolved from the row that went away. + * + * Regression test: handle_inventory_changes() set both substitution + * flags to FALSE for TG_OP='DELETE', so the pending webhook shipped + * body_template verbatim -- not even {{webhook_type}} was replaced. + * + * Note: this runs on its own instance and *after* the pending webhook + * tests, which rely on the global merchant_pending_webhooks identity + * sequence not having been advanced by anybody else. + * + * @return 0 when successful, 1 otherwise. + */ +static int +test_inventory_deleted_webhook (void) +{ + struct InstanceData instance; + struct WebhookData webhook; + struct ProductData product; + char *body = NULL; + int ret = 1; + + make_instance ("test_inst_inventory_webhooks", + &instance); + TEST_WITH_FAIL_CLAUSE (test_insert_instance (&instance, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT), + free_instance_data (&instance); + return 1; + ); + make_webhook ("test_webhooks_wb_inventory_deleted", + &webhook); + webhook.webhook.event_type = "inventory_deleted"; + webhook.webhook.url = "https://example.com/inventory-deleted"; + webhook.webhook.body_template = + "{\"type\":\"{{webhook_type}}\"" + ",\"product_id\":\"{{product_id}}\"" + ",\"description\":\"{{description}}\"}"; + TEST_WITH_FAIL_CLAUSE (test_insert_webhook (&instance, + &webhook, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT), + free_instance_data (&instance); + return 1; + ); + make_product ("test_webhooks_deleted_product", + &product); + TEST_WITH_FAIL_CLAUSE (test_insert_product (&instance, + &product, + 0, + NULL, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT, + false, + false, + -1), + goto cleanup; + ); + TEST_WITH_FAIL_CLAUSE (test_delete_product (&instance, + &product, + false, + false, + false), + goto cleanup; + ); + TEST_WITH_FAIL_CLAUSE ( + query_sql_text ("SELECT body AS txt" + " FROM merchant.merchant_pending_webhooks" + " WHERE url='https://example.com/inventory-deleted'", + &body), + goto cleanup; + ); + if (0 != strcmp ("{\"type\":\"inventory_deleted\"" + ",\"product_id\":\"test_webhooks_deleted_product\"" + ",\"description\":\"This is a test product\"}", + body)) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "inventory_deleted webhook body not resolved: `%s'\n", + body); + goto cleanup; + } + ret = 0; +cleanup: + GNUNET_free (body); + free_product_data (&product); + free_instance_data (&instance); + return ret; +} + + +/** * Closure for webhook tests. */ struct TestWebhooks_Closure @@ -9635,6 +9760,7 @@ run_tests (void) TEST_RET_ON_FAIL (test_templates ()); TEST_RET_ON_FAIL (test_webhooks ()); TEST_RET_ON_FAIL (test_pending_webhooks ()); + TEST_RET_ON_FAIL (test_inventory_deleted_webhook ()); TEST_RET_ON_FAIL (test_statistics ()); return 0; }