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:
parent
a2157177b8
commit
a9c15ea6ae
69
lib/mix/tasks/register_webhooks.ex
Normal file
69
lib/mix/tasks/register_webhooks.ex
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
defmodule Mix.Tasks.RegisterWebhooks do
|
||||||
|
@moduledoc """
|
||||||
|
Registers Printify webhooks for a shop.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
mix register_webhooks https://yourshop.com/webhooks/printify
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Printify provider connection must exist with shop_id configured
|
||||||
|
- webhook_secret must be set in the connection config
|
||||||
|
|
||||||
|
Generate a webhook secret with:
|
||||||
|
openssl rand -hex 20
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@shortdoc "Register Printify webhooks for product updates"
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run([url]) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Providers.Printify
|
||||||
|
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
nil ->
|
||||||
|
Mix.shell().error("No Printify connection found. Create one at /admin/providers first.")
|
||||||
|
|
||||||
|
conn ->
|
||||||
|
Mix.shell().info("Registering webhooks for URL: #{url}")
|
||||||
|
|
||||||
|
case Printify.register_webhooks(conn, url) do
|
||||||
|
{:ok, results} ->
|
||||||
|
Enum.each(results, fn
|
||||||
|
{:ok, event, response} ->
|
||||||
|
Mix.shell().info("✓ Registered: #{event} (ID: #{response["id"]})")
|
||||||
|
|
||||||
|
{:error, event, reason} ->
|
||||||
|
Mix.shell().error("✗ Failed: #{event} - #{inspect(reason)}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mix.shell().info("\nDone!")
|
||||||
|
|
||||||
|
{:error, :no_webhook_secret} ->
|
||||||
|
Mix.shell().error("""
|
||||||
|
No webhook secret configured.
|
||||||
|
|
||||||
|
Generate one with: openssl rand -hex 20
|
||||||
|
|
||||||
|
Then add it to your provider connection config as "webhook_secret".
|
||||||
|
""")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mix.shell().error("Failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(_) do
|
||||||
|
Mix.shell().error("""
|
||||||
|
Usage: mix register_webhooks <url>
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mix register_webhooks https://yourshop.com/webhooks/printify
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -183,6 +183,41 @@ defmodule SimpleshopTheme.Clients.Printify do
|
|||||||
get("/shops/#{shop_id}/orders/#{order_id}.json")
|
get("/shops/#{shop_id}/orders/#{order_id}.json")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Webhooks
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Register a webhook with Printify.
|
||||||
|
|
||||||
|
## Event types
|
||||||
|
- "product:publish:started"
|
||||||
|
- "product:updated"
|
||||||
|
- "product:deleted"
|
||||||
|
- "shop:disconnected"
|
||||||
|
"""
|
||||||
|
def create_webhook(shop_id, url, topic, secret) do
|
||||||
|
post("/shops/#{shop_id}/webhooks.json", %{
|
||||||
|
topic: topic,
|
||||||
|
url: url,
|
||||||
|
secret: secret
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
List registered webhooks for a shop.
|
||||||
|
"""
|
||||||
|
def list_webhooks(shop_id) do
|
||||||
|
get("/shops/#{shop_id}/webhooks.json")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Delete a webhook.
|
||||||
|
"""
|
||||||
|
def delete_webhook(shop_id, webhook_id) do
|
||||||
|
delete("/shops/#{shop_id}/webhooks/#{webhook_id}.json")
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Download a file from a URL to a local path.
|
Download a file from a URL to a local path.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -114,6 +114,66 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Webhook Registration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers webhooks for product events with Printify.
|
||||||
|
|
||||||
|
Returns {:ok, results} or {:error, reason}.
|
||||||
|
"""
|
||||||
|
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
|
||||||
|
shop_id = config["shop_id"]
|
||||||
|
secret = config["webhook_secret"]
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(shop_id) ->
|
||||||
|
{:error, :no_shop_id}
|
||||||
|
|
||||||
|
is_nil(secret) or secret == "" ->
|
||||||
|
{:error, :no_webhook_secret}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||||
|
:ok <- set_api_key(api_key) do
|
||||||
|
results =
|
||||||
|
Enum.map(@webhook_events, fn event ->
|
||||||
|
case Client.create_webhook(shop_id, webhook_url, event, secret) do
|
||||||
|
{:ok, response} -> {:ok, event, response}
|
||||||
|
{:error, reason} -> {:error, event, reason}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, results}
|
||||||
|
else
|
||||||
|
nil -> {:error, :no_api_key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists registered webhooks for the shop.
|
||||||
|
"""
|
||||||
|
def list_webhooks(%ProviderConnection{config: config} = conn) do
|
||||||
|
shop_id = config["shop_id"]
|
||||||
|
|
||||||
|
if is_nil(shop_id) do
|
||||||
|
{:error, :no_shop_id}
|
||||||
|
else
|
||||||
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||||
|
:ok <- set_api_key(api_key),
|
||||||
|
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
|
||||||
|
{:ok, webhooks}
|
||||||
|
else
|
||||||
|
nil -> {:error, :no_api_key}
|
||||||
|
{:error, _} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Data Normalization
|
# Data Normalization
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
45
lib/simpleshop_theme/webhooks.ex
Normal file
45
lib/simpleshop_theme/webhooks.ex
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
defmodule SimpleshopTheme.Webhooks do
|
||||||
|
@moduledoc """
|
||||||
|
Handles incoming webhook events from POD providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Sync.ProductSyncWorker
|
||||||
|
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handles a Printify webhook event.
|
||||||
|
|
||||||
|
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
||||||
|
"""
|
||||||
|
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
||||||
|
enqueue_product_sync()
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event("product:publish:started", %{"id" => _product_id}) do
|
||||||
|
enqueue_product_sync()
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event("product:deleted", %{"id" => product_id}) do
|
||||||
|
ProductDeleteWorker.enqueue(product_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event("shop:disconnected", _resource) do
|
||||||
|
Logger.warning("Printify shop disconnected - manual intervention needed")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event(event_type, _resource) do
|
||||||
|
Logger.info("Ignoring unhandled Printify event: #{event_type}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp enqueue_product_sync do
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
nil -> {:error, :no_connection}
|
||||||
|
conn -> ProductSyncWorker.enqueue(conn.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
41
lib/simpleshop_theme/webhooks/product_delete_worker.ex
Normal file
41
lib/simpleshop_theme/webhooks/product_delete_worker.ex
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
defmodule SimpleshopTheme.Webhooks.ProductDeleteWorker do
|
||||||
|
@moduledoc """
|
||||||
|
Oban worker for deleting products removed from POD providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, queue: :sync, max_attempts: 3
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"provider_product_id" => provider_product_id}}) do
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("No Printify connection found for product deletion")
|
||||||
|
{:cancel, :no_connection}
|
||||||
|
|
||||||
|
conn ->
|
||||||
|
case Products.get_product_by_provider(conn.id, provider_product_id) do
|
||||||
|
nil ->
|
||||||
|
Logger.info("Product #{provider_product_id} already deleted or not found")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
product ->
|
||||||
|
Logger.info("Deleting product #{product.id} (provider: #{provider_product_id})")
|
||||||
|
|
||||||
|
case Products.delete_product(product) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue(provider_product_id) do
|
||||||
|
%{provider_product_id: to_string(provider_product_id)}
|
||||||
|
|> new()
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
end
|
||||||
36
lib/simpleshop_theme_web/controllers/webhook_controller.ex
Normal file
36
lib/simpleshop_theme_web/controllers/webhook_controller.ex
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.WebhookController do
|
||||||
|
use SimpleshopThemeWeb, :controller
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Webhooks
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Receives Printify webhook events.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- product:publish:started - Product publish initiated
|
||||||
|
- product:updated - Product was modified
|
||||||
|
- product:deleted - Product was deleted
|
||||||
|
- shop:disconnected - Shop was disconnected
|
||||||
|
"""
|
||||||
|
def printify(conn, params) do
|
||||||
|
event_type = params["type"] || params["event"]
|
||||||
|
resource = params["resource"] || params["data"] || %{}
|
||||||
|
|
||||||
|
Logger.info("Received Printify webhook: #{event_type}")
|
||||||
|
|
||||||
|
case Webhooks.handle_printify_event(event_type, resource) do
|
||||||
|
:ok ->
|
||||||
|
json(conn, %{status: "ok"})
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
json(conn, %{status: "ok"})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Webhook handling failed: #{inspect(reason)}")
|
||||||
|
# Still return 200 to prevent Printify retrying
|
||||||
|
json(conn, %{status: "ok"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -54,6 +54,7 @@ defmodule SimpleshopThemeWeb.Endpoint do
|
|||||||
plug Plug.Parsers,
|
plug Plug.Parsers,
|
||||||
parsers: [:urlencoded, :multipart, :json],
|
parsers: [:urlencoded, :multipart, :json],
|
||||||
pass: ["*/*"],
|
pass: ["*/*"],
|
||||||
|
body_reader: {SimpleshopThemeWeb.Plugs.CacheRawBody, :read_body, []},
|
||||||
json_decoder: Phoenix.json_library()
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
plug Plug.MethodOverride
|
plug Plug.MethodOverride
|
||||||
|
|||||||
22
lib/simpleshop_theme_web/plugs/cache_raw_body.ex
Normal file
22
lib/simpleshop_theme_web/plugs/cache_raw_body.ex
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.Plugs.CacheRawBody do
|
||||||
|
@moduledoc """
|
||||||
|
Custom body reader that caches the raw request body for webhook signature verification.
|
||||||
|
Used with Plug.Parsers :body_reader option.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def read_body(conn, opts) do
|
||||||
|
case Plug.Conn.read_body(conn, opts) do
|
||||||
|
{:ok, body, conn} ->
|
||||||
|
conn = Plug.Conn.assign(conn, :raw_body, body)
|
||||||
|
{:ok, body, conn}
|
||||||
|
|
||||||
|
{:more, body, conn} ->
|
||||||
|
existing = conn.assigns[:raw_body] || ""
|
||||||
|
conn = Plug.Conn.assign(conn, :raw_body, existing <> body)
|
||||||
|
{:more, body, conn}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
63
lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex
Normal file
63
lib/simpleshop_theme_web/plugs/verify_printify_webhook.ex
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook do
|
||||||
|
@moduledoc """
|
||||||
|
Verifies Printify webhook signatures using HMAC-SHA256.
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- Raw body cached in conn.assigns[:raw_body]
|
||||||
|
- X-Pfy-Signature header in format "sha256={hex_digest}"
|
||||||
|
- Webhook secret stored in provider connection config
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
with {:ok, signature} <- get_signature(conn),
|
||||||
|
{:ok, secret} <- get_webhook_secret(),
|
||||||
|
:ok <- verify_signature(conn.assigns[:raw_body], secret, signature) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Printify webhook verification failed: #{reason}")
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> send_resp(401, Jason.encode!(%{error: "Invalid signature"}))
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_signature(conn) do
|
||||||
|
case get_req_header(conn, "x-pfy-signature") do
|
||||||
|
["sha256=" <> hex_digest] -> {:ok, hex_digest}
|
||||||
|
[_other] -> {:error, :invalid_signature_format}
|
||||||
|
[] -> {:error, :missing_signature}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_webhook_secret do
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
%{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" ->
|
||||||
|
{:ok, secret}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :no_webhook_secret}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_signature(body, secret, expected_hex) do
|
||||||
|
computed =
|
||||||
|
:crypto.mac(:hmac, :sha256, secret, body || "")
|
||||||
|
|> Base.encode16(case: :lower)
|
||||||
|
|
||||||
|
if Plug.Crypto.secure_compare(computed, String.downcase(expected_hex)) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, :signature_mismatch}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -17,6 +17,10 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
pipeline :printify_webhook do
|
||||||
|
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
|
||||||
|
end
|
||||||
|
|
||||||
pipeline :shop do
|
pipeline :shop do
|
||||||
plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root}
|
plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root}
|
||||||
plug SimpleshopThemeWeb.Plugs.LoadTheme
|
plug SimpleshopThemeWeb.Plugs.LoadTheme
|
||||||
@ -46,10 +50,12 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
get "/:id/recolored/:color", ImageController, :recolored_svg
|
get "/:id/recolored/:color", ImageController, :recolored_svg
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Webhook endpoints (no CSRF, signature verified)
|
||||||
# scope "/api", SimpleshopThemeWeb do
|
scope "/webhooks", SimpleshopThemeWeb do
|
||||||
# pipe_through :api
|
pipe_through [:api, :printify_webhook]
|
||||||
# end
|
|
||||||
|
post "/printify", WebhookController, :printify
|
||||||
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
if Application.compile_env(:simpleshop_theme, :dev_routes) do
|
if Application.compile_env(:simpleshop_theme, :dev_routes) do
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user