feat: add Printify webhook endpoint for real-time product updates
- Add /webhooks/printify endpoint with HMAC-SHA256 signature verification - Add Webhooks context to handle product:updated, product:deleted events - Add ProductDeleteWorker for async product deletion - Add webhook API methods to Printify client (create, list, delete) - Add register_webhooks/2 to Printify provider - Add mix register_webhooks task for one-time webhook registration - Cache raw request body in endpoint for signature verification Usage: 1. Generate webhook secret: openssl rand -hex 20 2. Add to provider connection config as "webhook_secret" 3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
defmodule SimpleshopTheme.Webhooks.ProductDeleteWorkerTest do
|
||||
use SimpleshopTheme.DataCase
|
||||
use Oban.Testing, repo: SimpleshopTheme.Repo
|
||||
|
||||
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "deletes product when found" do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
|
||||
{:ok, product, :created} =
|
||||
Products.upsert_product(conn, %{
|
||||
provider_product_id: "test-product-123",
|
||||
title: "Test Product",
|
||||
provider_data: %{}
|
||||
})
|
||||
|
||||
assert Products.get_product(product.id) != nil
|
||||
|
||||
assert :ok =
|
||||
perform_job(ProductDeleteWorker, %{
|
||||
provider_product_id: "test-product-123"
|
||||
})
|
||||
|
||||
assert Products.get_product(product.id) == nil
|
||||
end
|
||||
|
||||
test "returns ok when product not found" do
|
||||
_conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
|
||||
assert :ok =
|
||||
perform_job(ProductDeleteWorker, %{
|
||||
provider_product_id: "nonexistent-product"
|
||||
})
|
||||
end
|
||||
|
||||
test "cancels when no provider connection" do
|
||||
# No connection created
|
||||
assert {:cancel, :no_connection} =
|
||||
perform_job(ProductDeleteWorker, %{
|
||||
provider_product_id: "test-product-123"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
65
test/simpleshop_theme/webhooks_test.exs
Normal file
65
test/simpleshop_theme/webhooks_test.exs
Normal file
@@ -0,0 +1,65 @@
|
||||
defmodule SimpleshopTheme.WebhooksTest do
|
||||
use SimpleshopTheme.DataCase
|
||||
|
||||
alias SimpleshopTheme.Webhooks
|
||||
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
setup do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
{:ok, provider_connection: conn}
|
||||
end
|
||||
|
||||
describe "handle_printify_event/2" do
|
||||
test "product:updated triggers sync", %{provider_connection: _conn} do
|
||||
# With inline Oban, the job executes immediately (and fails due to no real API key)
|
||||
# But the handler should still return {:ok, _} after inserting the job
|
||||
result =
|
||||
Webhooks.handle_printify_event(
|
||||
"product:updated",
|
||||
%{"id" => "123", "shop_id" => "456"}
|
||||
)
|
||||
|
||||
assert {:ok, %Oban.Job{}} = result
|
||||
end
|
||||
|
||||
test "product:publish:started triggers sync", %{provider_connection: _conn} do
|
||||
result =
|
||||
Webhooks.handle_printify_event(
|
||||
"product:publish:started",
|
||||
%{"id" => "123"}
|
||||
)
|
||||
|
||||
assert {:ok, %Oban.Job{}} = result
|
||||
end
|
||||
|
||||
test "product:deleted triggers delete", %{provider_connection: _conn} do
|
||||
result =
|
||||
Webhooks.handle_printify_event(
|
||||
"product:deleted",
|
||||
%{"id" => "123"}
|
||||
)
|
||||
|
||||
assert {:ok, %Oban.Job{}} = result
|
||||
end
|
||||
|
||||
test "shop:disconnected returns ok" do
|
||||
assert :ok = Webhooks.handle_printify_event("shop:disconnected", %{})
|
||||
end
|
||||
|
||||
test "unknown event returns ok" do
|
||||
assert :ok = Webhooks.handle_printify_event("unknown:event", %{})
|
||||
end
|
||||
|
||||
test "returns error when no provider connection" do
|
||||
# Delete all connections first
|
||||
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
|
||||
|
||||
assert {:error, :no_connection} =
|
||||
Webhooks.handle_printify_event(
|
||||
"product:updated",
|
||||
%{"id" => "123"}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
defmodule SimpleshopThemeWeb.WebhookControllerTest do
|
||||
use SimpleshopThemeWeb.ConnCase
|
||||
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
@webhook_secret "test_webhook_secret_123"
|
||||
|
||||
setup do
|
||||
_conn =
|
||||
provider_connection_fixture(%{
|
||||
provider_type: "printify",
|
||||
config: %{"shop_id" => "12345", "webhook_secret" => @webhook_secret}
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "POST /webhooks/printify" do
|
||||
test "returns 401 without signature", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> post(~p"/webhooks/printify", %{type: "product:updated"})
|
||||
|
||||
assert json_response(conn, 401)["error"] == "Invalid signature"
|
||||
end
|
||||
|
||||
test "returns 401 with invalid signature", %{conn: conn} do
|
||||
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-pfy-signature", "sha256=invalid")
|
||||
|> post(~p"/webhooks/printify", body)
|
||||
|
||||
assert json_response(conn, 401)["error"] == "Invalid signature"
|
||||
end
|
||||
|
||||
test "accepts valid signature and returns 200", %{conn: conn} do
|
||||
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
|
||||
signature = compute_signature(body, @webhook_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|
||||
|> post(~p"/webhooks/printify", body)
|
||||
|
||||
assert json_response(conn, 200)["status"] == "ok"
|
||||
end
|
||||
|
||||
test "handles product:updated event", %{conn: conn} do
|
||||
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
|
||||
signature = compute_signature(body, @webhook_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|
||||
|> post(~p"/webhooks/printify", body)
|
||||
|
||||
# Should return 200 even if job processing fails (inline mode tries to execute)
|
||||
assert json_response(conn, 200)["status"] == "ok"
|
||||
end
|
||||
|
||||
test "handles product:deleted event", %{conn: conn} do
|
||||
body = Jason.encode!(%{type: "product:deleted", resource: %{id: "456"}})
|
||||
signature = compute_signature(body, @webhook_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|
||||
|> post(~p"/webhooks/printify", body)
|
||||
|
||||
assert json_response(conn, 200)["status"] == "ok"
|
||||
end
|
||||
|
||||
test "returns 401 when no webhook secret configured", %{conn: conn} do
|
||||
# Remove the provider connection to simulate no secret
|
||||
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
|
||||
|
||||
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
|
||||
signature = compute_signature(body, @webhook_secret)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|
||||
|> post(~p"/webhooks/printify", body)
|
||||
|
||||
assert json_response(conn, 401)["error"] == "Invalid signature"
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_signature(body, secret) do
|
||||
:crypto.mac(:hmac, :sha256, secret, body)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user