From c22647bcc0b45587d613c6d7154438d8b33b90b0 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Mon, 10 Mar 2025 15:09:00 -0500 Subject: [PATCH 1/2] Initial scripttag manager Add check for app blocks Add docs Update naming rubo cop rubo cop --- .../shopify_app/callback_controller.rb | 12 +- docs/shopify_app/script-tags.md | 52 ++ lib/shopify_app.rb | 3 +- .../auth/post_authenticate_tasks.rb | 8 + lib/shopify_app/configuration.rb | 10 +- lib/shopify_app/engine.rb | 3 +- .../jobs/script_tags_manager_job.rb | 16 + .../managers/script_tags_manager.rb | 370 ++++++++++ test/dummy/config/initializers/shopify_app.rb | 2 +- test/shopify_app/configuration_test.rb | 8 +- .../managers/script_tags_manager_test.rb | 638 ++++++++++++++++++ 11 files changed, 1109 insertions(+), 13 deletions(-) create mode 100644 docs/shopify_app/script-tags.md create mode 100644 lib/shopify_app/jobs/script_tags_manager_job.rb create mode 100644 lib/shopify_app/managers/script_tags_manager.rb create mode 100644 test/shopify_app/managers/script_tags_manager_test.rb diff --git a/app/controllers/shopify_app/callback_controller.rb b/app/controllers/shopify_app/callback_controller.rb index 6131521fe..20fe9fd9c 100644 --- a/app/controllers/shopify_app/callback_controller.rb +++ b/app/controllers/shopify_app/callback_controller.rb @@ -151,7 +151,7 @@ def perform_post_authenticate_jobs(session) session_for_shop = session.online? ? shop_session : session install_webhooks(session_for_shop) - + install_scripttags(session_for_shop) perform_after_authenticate_job(session) end @@ -161,6 +161,16 @@ def install_webhooks(session) WebhooksManager.queue(session.shop, session.access_token) end + def install_scripttags(session) + return unless ShopifyApp.configuration.has_script_tags? + + ScriptTagsManager.queue( + session.shop, + session.access_token, + ShopifyApp.configuration.script_tags, + ) + end + def perform_after_authenticate_job(session) config = ShopifyApp.configuration.after_authenticate_job diff --git a/docs/shopify_app/script-tags.md b/docs/shopify_app/script-tags.md new file mode 100644 index 000000000..d9ebbb111 --- /dev/null +++ b/docs/shopify_app/script-tags.md @@ -0,0 +1,52 @@ +# Script Tags + +ShopifyApp can manage your app's [Script Tags](https://shopify.dev/docs/admin-api/graphql/reference/online-store/scripttag) for you by setting which script tags you require in the initializer. +> [!NOTE] +> Script tags should only be used for vintage themes that do not support app blocks. + +## Configuration + +```ruby +ShopifyApp.configure do |config| + config.script_tags = [ + # Basic script tag + {cache: true, src: 'https://example.com/fancy.js'}, + + # Script tag with template_types for app block detection + { + cache: true, + src: 'https://example.com/product-script.js', + template_types: ['product', 'collection'] + } + ] +end +``` + +## Required Scopes +Both the `write_script_tags` and `read_themes` scopes are required. + +For apps created with the Shopify CLI, set these scopes in your `shopify.app.toml` file: + +```toml +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products,write_script_tags,read_themes" +``` + +For older apps, you can set the scopes in the initializer: + +```ruby +config.scope = 'write_products,write_script_tags,read_themes' +``` + +## How It Works + +### Script Tag Creation + +Script tags are created in the same way as [Webhooks](/docs/shopify_app/webhooks.md), with a background job which will create the required scripttags. + +### App Block Detection + +When you specify `template_types` for a script tag, ShopifyApp will check if the store's active theme supports app blocks for those template types. If any template type doesn't support app blocks, the script tags will be created as a fallback + +This allows your app to automatically adapt to the store's theme capabilities, using app blocks when available and falling back to script tags when necessary. \ No newline at end of file diff --git a/lib/shopify_app.rb b/lib/shopify_app.rb index 176b3b09a..adb5d0806 100644 --- a/lib/shopify_app.rb +++ b/lib/shopify_app.rb @@ -64,10 +64,11 @@ def self.use_webpacker? # jobs require "shopify_app/jobs/webhooks_manager_job" + require "shopify_app/jobs/script_tags_manager_job" # managers require "shopify_app/managers/webhooks_manager" - + require "shopify_app/managers/script_tags_manager" # middleware require "shopify_app/middleware/jwt_middleware" diff --git a/lib/shopify_app/auth/post_authenticate_tasks.rb b/lib/shopify_app/auth/post_authenticate_tasks.rb index 44a3cc8aa..5b44ddc4d 100644 --- a/lib/shopify_app/auth/post_authenticate_tasks.rb +++ b/lib/shopify_app/auth/post_authenticate_tasks.rb @@ -10,6 +10,7 @@ def perform(session) session_for_shop = session.online? ? shop_session(session) : session install_webhooks(session_for_shop) + install_scripttags(session_for_shop) perform_after_authenticate_job(session) end @@ -27,6 +28,13 @@ def install_webhooks(session) WebhooksManager.queue(session.shop, session.access_token) end + def install_scripttags(session) + ShopifyApp::Logger.debug("PostAuthenticateTasks: Installing scripttags") + return unless ShopifyApp.configuration.has_script_tags? + + ScriptTagsManager.queue(session.shop, session.access_token, ShopifyApp.configuration.script_tags) + end + def perform_after_authenticate_job(session) ShopifyApp::Logger.debug("PostAuthenticateTasks: Performing after_authenticate_job") config = ShopifyApp.configuration.after_authenticate_job diff --git a/lib/shopify_app/configuration.rb b/lib/shopify_app/configuration.rb index 7ced26cf0..68eaf862c 100644 --- a/lib/shopify_app/configuration.rb +++ b/lib/shopify_app/configuration.rb @@ -15,7 +15,7 @@ class Configuration attr_accessor :embedded_app alias_method :embedded_app?, :embedded_app attr_accessor :webhooks - attr_accessor :scripttags + attr_accessor :script_tags attr_accessor :after_authenticate_job attr_accessor :api_version @@ -33,7 +33,7 @@ class Configuration attr_accessor :custom_post_authenticate_tasks # customise ActiveJob queue names - attr_accessor :scripttags_manager_queue_name + attr_accessor :script_tags_manager_queue_name attr_accessor :webhooks_manager_queue_name # configure myshopify domain for local shopify development @@ -58,7 +58,7 @@ def initialize @root_url = "/" @myshopify_domain = "myshopify.com" @unified_admin_domain = "shopify.com" - @scripttags_manager_queue_name = Rails.application.config.active_job.queue_name + @script_tags_manager_queue_name = Rails.application.config.active_job.queue_name @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name @disable_webpacker = ENV["SHOPIFY_APP_DISABLE_WEBPACKER"].present? @scope = [] @@ -115,8 +115,8 @@ def has_webhooks? webhooks.present? end - def has_scripttags? - scripttags.present? + def has_script_tags? + script_tags.present? end def requires_billing? diff --git a/lib/shopify_app/engine.rb b/lib/shopify_app/engine.rb index 9e6e12e44..ab1febbf4 100644 --- a/lib/shopify_app/engine.rb +++ b/lib/shopify_app/engine.rb @@ -5,7 +5,7 @@ module RedactJobParams private def args_info(job) - log_disabled_classes = ["ShopifyApp::WebhooksManagerJob"] + log_disabled_classes = ["ShopifyApp::WebhooksManagerJob", "ShopifyApp::ScriptTagsManagerJob"] return "" if log_disabled_classes.include?(job.class.name) super @@ -30,6 +30,7 @@ class Engine < Rails::Engine ActiveSupport.on_load(:active_job) do if ActiveJob::Base.respond_to?(:log_arguments?) WebhooksManagerJob.log_arguments = false + ScriptTagsManagerJob.log_arguments = false elsif ActiveJob::Logging::LogSubscriber.private_method_defined?(:args_info) ActiveJob::Logging::LogSubscriber.prepend(RedactJobParams) end diff --git a/lib/shopify_app/jobs/script_tags_manager_job.rb b/lib/shopify_app/jobs/script_tags_manager_job.rb new file mode 100644 index 000000000..886dd4b61 --- /dev/null +++ b/lib/shopify_app/jobs/script_tags_manager_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ShopifyApp + class ScriptTagsManagerJob < ActiveJob::Base + queue_as do + ShopifyApp.configuration.script_tags_manager_queue_name + end + + def perform(shop_domain:, shop_token:, script_tags:) + ShopifyAPI::Auth::Session.temp(shop: shop_domain, access_token: shop_token) do |session| + manager = ScriptTagsManager.new(script_tags, shop_domain) + manager.create_script_tags(session: session) + end + end + end +end diff --git a/lib/shopify_app/managers/script_tags_manager.rb b/lib/shopify_app/managers/script_tags_manager.rb new file mode 100644 index 000000000..339787774 --- /dev/null +++ b/lib/shopify_app/managers/script_tags_manager.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +module ShopifyApp + class ScriptTagsManager + def self.queue(shop_domain, shop_token, script_tags) + ShopifyApp::ScriptTagsManagerJob.perform_later( + shop_domain: shop_domain, + shop_token: shop_token, + # Procs cannot be serialized so we interpolate now, if necessary + script_tags: build_src(script_tags, shop_domain), + ) + end + + def self.build_src(script_tags, domain) + script_tags.map do |tag| + next tag unless tag[:src].respond_to?(:call) + + tag = tag.dup + tag[:src] = tag[:src].call(domain) + tag + end + end + + attr_reader :required_script_tags, :shop_domain + + def initialize(script_tags, shop_domain) + @required_script_tags = script_tags + @shop_domain = shop_domain + @session = nil + end + + def recreate_script_tags!(session:) + destroy_script_tags(session: session) + create_script_tags(session: session) + end + + def create_script_tags(session:) + @session = session + return unless required_script_tags.present? + + # Check if any scripttag has template_types defined + template_types_to_check = required_script_tags.flat_map { |tag| tag[:template_types] }.compact.uniq + + # If template types are specified, check if the theme supports app blocks for those templates + if template_types_to_check.any? + # First fetch the active theme + active_theme = fetch_active_theme + + # If we failed to fetch the active theme, don't proceed with script tag creation + unless active_theme + ShopifyApp::Logger.info("Failed to fetch active theme. Skipping script tag creation.") + return + end + + # Check if the theme supports app blocks for the specified templates + if template_types_to_check.all? do |template_type| + template_supports_app_blocks?(active_theme["id"], template_type) + end + ShopifyApp::Logger.info( + "Theme supports app blocks for templates: #{template_types_to_check.join(", ")}. " \ + "Skipping script tag creation.", + ) + return + end + end + + expanded_script_tags.each do |script_tag| + create_script_tag(script_tag) unless script_tag_exists?(script_tag[:src]) + end + end + + def destroy_script_tags(session:) + @session = session + script_tags = expanded_script_tags + fetch_all_script_tags.each do |tag| + delete_script_tag(tag) if required_script_tag?(script_tags, tag) + end + + @current_script_tags = nil + end + + private + + def theme_supports_app_blocks?(template_types) + # Get the active theme + active_theme = fetch_active_theme + return false unless active_theme + + # Check each template type for app block support + template_types.all? do |template_type| + template_supports_app_blocks?(active_theme["id"], template_type) + end + rescue => e + ShopifyApp::Logger.warn("Unable to check theme app block support: #{e.message}.") + false + end + + def fetch_active_theme + client = graphql_client + + query = <<~QUERY + { + themes(first: 1, roles: [MAIN]) { + nodes { + id + name + } + } + } + QUERY + + begin + response = client.query(query: query) + + # Check for errors in the response + if response.body["errors"].present? + error_message = response.body["errors"].map { |e| e["message"] }.join(", ") + raise "GraphQL error: #{error_message}" + end + + # Check if the response has the expected structure + unless response.body["data"] && response.body["data"]["themes"] && response.body["data"]["themes"]["nodes"] + raise "Invalid response structure" + end + + themes = response.body["data"]["themes"]["nodes"] + return nil if themes.empty? + + themes.first + rescue => e + ShopifyApp::Logger.warn("Failed to fetch active theme: #{e.message}") + nil + end + end + + def template_supports_app_blocks?(theme_id, template_type) + client = graphql_client + + # First, check if the JSON template exists + files_query = <<~QUERY + query getFiles($themeId: ID!, $filenames: [String!]!) { + theme(id: $themeId) { + files(filenames: $filenames) { + nodes { + filename + body { + ... on OnlineStoreThemeFileBodyText { + content + } + } + } + } + } + } + QUERY + + filename = "templates/#{template_type}.json" + files_variables = { + themeId: theme_id, + filenames: [filename], + } + + begin + files_response = client.query(query: files_query, variables: files_variables) + + # Check for errors in the response + if files_response.body["errors"].present? + error_message = files_response.body["errors"].map { |e| e["message"] }.join(", ") + raise "GraphQL error: #{error_message}" + end + + template_files = files_response.body["data"]["theme"]["files"]["nodes"] + + # If the JSON template doesn't exist, return false + return false if template_files.empty? + + # Parse the JSON template to find the main section + template_content = template_files.first["body"]["content"] + template_data = JSON.parse(template_content) + + main_section = nil + template_data["sections"].each do |id, section| + if id == "main" || section["type"].to_s.start_with?("main-") + main_section = "sections/#{section["type"]}.liquid" + break + end + end + + return false unless main_section + + # Now check if the main section supports app blocks + section_query = <<~QUERY + query getFiles($themeId: ID!, $filenames: [String!]!) { + theme(id: $themeId) { + files(filenames: $filenames) { + nodes { + filename + body { + ... on OnlineStoreThemeFileBodyText { + content + } + } + } + } + } + } + QUERY + + section_variables = { + themeId: theme_id, + filenames: [main_section], + } + + section_response = client.query(query: section_query, variables: section_variables) + + # Check for errors in the section response + if section_response.body["errors"].present? + error_message = section_response.body["errors"].map { |e| e["message"] }.join(", ") + raise "GraphQL error: #{error_message}" + end + + section_files = section_response.body["data"]["theme"]["files"]["nodes"] + + return false if section_files.empty? + + section_content = section_files.first["body"]["content"] + + # Extract schema from the section content + schema_match = section_content.match(/\{\%\s+schema\s+\%\}([\s\S]*?)\{\%\s+endschema\s+\%\}/m) + return false unless schema_match + + schema = JSON.parse(schema_match[1]) + + # Check if the schema has blocks that support app blocks + schema["blocks"]&.any? { |block| block["type"] == "@app" } || false + rescue => e + ShopifyApp::Logger.error("Error checking template support: #{e.message}") + false + end + end + + def expanded_script_tags + self.class.build_src(required_script_tags, shop_domain) + end + + def required_script_tag?(script_tags, tag) + script_tags.map { |w| w[:src] }.include?(tag["src"]) + end + + def create_script_tag(attributes) + client = graphql_client + + variables = { + input: { + src: attributes[:src], + displayScope: "ONLINE_STORE", + cache: attributes[:cache] || false, + }, + } + + query = <<~QUERY + mutation ScriptTagCreate($input: ScriptTagInput!) { + scriptTagCreate(input: $input) { + scriptTag { + id + src + displayScope + cache + } + userErrors { + field + message + } + } + } + QUERY + + begin + response = client.query(query: query, variables: variables) + + if response.body["data"]["scriptTagCreate"]["userErrors"].any? + errors = response.body["data"]["scriptTagCreate"]["userErrors"] + error_messages = errors.map { |e| "#{e["field"]}: #{e["message"]}" }.join(", ") + raise ::ShopifyApp::CreationFailed, "ScriptTag creation failed: #{error_messages}" + end + + response.body["data"]["scriptTagCreate"]["scriptTag"] + rescue ShopifyAPI::Errors::HttpResponseError => e + raise ::ShopifyApp::CreationFailed, e.message + end + end + + def delete_script_tag(tag) + client = graphql_client + + query = <<~QUERY + mutation scriptTagDelete($id: ID!) { + scriptTagDelete(id: $id) { + deletedScriptTagId + userErrors { + field + message + } + } + } + QUERY + + variables = { id: tag["id"] } + + begin + client.query(query: query, variables: variables) + rescue ShopifyAPI::Errors::HttpResponseError => e + ShopifyApp::Logger.error("Failed to delete script tag: #{e.message}") + end + end + + def script_tag_exists?(src) + current_script_tags[src] + end + + def current_script_tags + @current_script_tags ||= fetch_all_script_tags.index_by { |tag| tag["src"] } + end + + def fetch_all_script_tags + client = graphql_client + + query = <<~QUERY + { + scriptTags(first: 250) { + edges { + node { + id + src + displayScope + cache + } + } + } + } + QUERY + + begin + response = client.query(query: query) + + # Check for errors in the response + if response.body["errors"].present? + ShopifyApp::Logger.warn("GraphQL error fetching script tags: #{response.body["errors"].map do |e| + e["message"] + end.join(", ")}") + return [] + end + + # Handle nil data or missing structure + return [] unless response.body["data"] && + response.body["data"]["scriptTags"] && + response.body["data"]["scriptTags"]["edges"] + + response.body["data"]["scriptTags"]["edges"].map { |edge| edge["node"] } + rescue => e + ShopifyApp::Logger.warn("Error fetching script tags: #{e.message}") + [] + end + end + + def graphql_client + ShopifyAPI::Clients::Graphql::Admin.new(session: @session) + end + end +end diff --git a/test/dummy/config/initializers/shopify_app.rb b/test/dummy/config/initializers/shopify_app.rb index f21a2f97b..9907f8de3 100644 --- a/test/dummy/config/initializers/shopify_app.rb +++ b/test/dummy/config/initializers/shopify_app.rb @@ -13,7 +13,7 @@ def self.call config.myshopify_domain = "myshopify.com" config.api_version = ShopifyAPI::LATEST_SUPPORTED_ADMIN_VERSION config.billing = nil - config.scripttags = nil + config.script_tags = nil config.embedded_redirect_url = nil config.shop_session_repository = ShopifyApp::InMemorySessionStore diff --git a/test/shopify_app/configuration_test.rb b/test/shopify_app/configuration_test.rb index 9545f00c4..3b61e6cfe 100644 --- a/test/shopify_app/configuration_test.rb +++ b/test/shopify_app/configuration_test.rb @@ -106,7 +106,7 @@ class ConfigurationTest < ActiveSupport::TestCase ShopifyApp.configuration = nil assert_equal :"custom-queue-name", ShopifyApp.configuration.webhooks_manager_queue_name - assert_equal :"custom-queue-name", ShopifyApp.configuration.scripttags_manager_queue_name + assert_equal :"custom-queue-name", ShopifyApp.configuration.script_tags_manager_queue_name end test "webhooks_manager_queue_name and scripttags_manager_queue_name are nil if not configured and ActiveJob queue_name is nil (activeJob overrides a nil queue_name to default)" do @@ -114,18 +114,18 @@ class ConfigurationTest < ActiveSupport::TestCase ShopifyApp.configuration = nil assert_equal :default, ShopifyApp.configuration.webhooks_manager_queue_name - assert_equal :default, ShopifyApp.configuration.scripttags_manager_queue_name + assert_equal :default, ShopifyApp.configuration.script_tags_manager_queue_name end test "can override queue names" do Rails.application.config.active_job.queue_name = :"custom-queue-name" ShopifyApp.configure do |config| config.webhooks_manager_queue_name = :"my-custom-worker-1" - config.scripttags_manager_queue_name = :"my-custom-worker-2" + config.script_tags_manager_queue_name = :"my-custom-worker-2" end assert_equal :"my-custom-worker-1", ShopifyApp.configuration.webhooks_manager_queue_name - assert_equal :"my-custom-worker-2", ShopifyApp.configuration.scripttags_manager_queue_name + assert_equal :"my-custom-worker-2", ShopifyApp.configuration.script_tags_manager_queue_name end test "webhook_jobs_namespace handles default" do diff --git a/test/shopify_app/managers/script_tags_manager_test.rb b/test/shopify_app/managers/script_tags_manager_test.rb new file mode 100644 index 000000000..644c21c67 --- /dev/null +++ b/test/shopify_app/managers/script_tags_manager_test.rb @@ -0,0 +1,638 @@ +# frozen_string_literal: true + +require "test_helper" + +class ShopifyApp::ScriptTagsManagerTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @script_tags = [ + { cache: true, src: "https://example-app.com/fancy.js" }, + { cache: true, src: "https://example-app.com/foobar.js" }, + { cache: true, src: ->(domain) { "https://example-app.com/#{domain}-123.js" } }, + ] + + @script_tags_with_template_types = [ + { cache: true, src: "https://example-app.com/fancy.js", template_types: ["product", "collection"] }, + { cache: true, src: "https://example-app.com/foobar.js", template_types: ["index"] }, + ] + + @session = ShopifyAPI::Auth::Session.new(shop: "some-shop.myshopify.com") + ShopifyAPI::Context.activate_session(@session) + @manager = ShopifyApp::ScriptTagsManager.new(@script_tags, "example-app.com") + + # Mock the GraphQL client + @mock_client = mock("GraphQLClient") + @manager.stubs(:graphql_client).returns(@mock_client) + end + + test "#create_script_tags creates each required script tag" do + # Mock empty script tags response + mock_empty_script_tags_response + + # Expect GraphQL calls for each script tag creation + expect_script_tag_creation("https://example-app.com/fancy.js") + expect_script_tag_creation("https://example-app.com/foobar.js") + expect_script_tag_creation("https://example-app.com/example-app.com-123.js") + + @manager.create_script_tags(session: @session) + end + + test "#create_script_tags preserves dynamic src references" do + # Mock empty script tags response + empty_response = mock + empty_response.stubs(:body).returns({ + "data" => { + "scriptTags" => { + "edges" => [], + }, + }, + }) + + # Mock successful creation responses + success_response = mock + success_response.stubs(:body).returns({ + "data" => { + "scriptTagCreate" => { + "scriptTag" => { + "id" => "gid://shopify/ScriptTag/123", + "src" => "https://example-app.com/script.js", + "displayScope" => "ONLINE_STORE", + "cache" => true, + }, + "userErrors" => [], + }, + }, + }) + + # Set up the sequence of responses + @mock_client.stubs(:query) + .returns(empty_response) + .then.returns(success_response) + .then.returns(success_response) + .then.returns(success_response) + + @manager.create_script_tags(session: @session) + + assert_equal 3, @manager.required_script_tags.length + assert_respond_to @manager.required_script_tags.last[:src], :call + end + + test "#create_script_tags raises CreationFailed when API returns errors" do + # First mock the empty scripttags response for the initial check + empty_response = mock + empty_response.stubs(:body).returns({ + "data" => { + "scriptTags" => { + "edges" => [], + }, + }, + }) + + # Then mock the error response for the creation attempt + error_response = mock + error_response.stubs(:body).returns({ + "data" => { + "scriptTagCreate" => { + "scriptTag" => nil, + "userErrors" => [ + { "field" => "src", "message" => "Error message" }, + ], + }, + }, + }) + + # Set up the sequence of responses + @mock_client.stubs(:query).returns(empty_response).then.returns(error_response) + + e = assert_raise ::ShopifyApp::CreationFailed do + @manager.create_script_tags(session: @session) + end + + assert_equal "ScriptTag creation failed: src: Error message", e.message + end + + test "#create_script_tags propagates exceptions from dynamic src" do + # Mock the first two script tags to exist + mock_script_tags_response([ + { "id" => "gid://shopify/ScriptTag/1", "src" => "https://example-app.com/fancy.js" }, + { "id" => "gid://shopify/ScriptTag/2", "src" => "https://example-app.com/foobar.js" }, + ]) + + # Don't set any expectations on query since we'll raise an exception + @mock_client.stubs(:query) + + @manager.required_script_tags.last[:src] = ->(_domain) { raise "oops!" } + + e = assert_raise do + @manager.create_script_tags(session: @session) + end + + assert_equal "oops!", e.message + end + + test "#recreate_script_tags! destroys all script tags and recreates them" do + @manager.expects(:destroy_script_tags).with(session: @session) + @manager.expects(:create_script_tags).with(session: @session) + + @manager.recreate_script_tags!(session: @session) + end + + test "#destroy_script_tags removes matching script tags" do + # Mock existing script tags + mock_script_tags_response([ + { "id" => "gid://shopify/ScriptTag/1", "src" => "https://example-app.com/fancy.js" }, + ]) + + # Expect delete call + expect_script_tag_deletion("gid://shopify/ScriptTag/1") + + @manager.destroy_script_tags(session: @session) + end + + test "#destroy_script_tags handles dynamic src values correctly" do + # Mock existing script tags + mock_script_tags_response([ + { "id" => "gid://shopify/ScriptTag/3", "src" => "https://example-app.com/example-app.com-123.js" }, + ]) + + # Expect delete call + expect_script_tag_deletion("gid://shopify/ScriptTag/3") + + @manager.destroy_script_tags(session: @session) + end + + test "#destroy_scripttags preserves dynamic src references" do + # Mock existing script tags + mock_script_tags_response([ + { "id" => "gid://shopify/ScriptTag/3", "src" => "https://example-app.com/example-app.com-123.js" }, + ]) + + # Expect delete call + expect_script_tag_deletion("gid://shopify/ScriptTag/3") + + @manager.destroy_script_tags(session: @session) + assert_respond_to @manager.required_script_tags.last[:src], :call + end + + test "#destroy_script_tags does not remove non-matching script tags" do + # Mock existing script tags with non-matching src + mock_script_tags_response([ + { "id" => "gid://shopify/ScriptTag/7214109", "src" => "http://something-or-the-other.com/badscript.js" }, + ]) + + # No delete call should be made + @mock_client.expects(:query).with(has_entry(variables: { id: "gid://shopify/ScriptTag/7214109" })).never + + @manager.destroy_script_tags(session: @session) + end + + test ".queue enqueues a ScripttagsManagerJob with correct parameters" do + # Configure the script_tags_manager_queue_name + ShopifyApp.configuration.stubs(:script_tags_manager_queue_name).returns(:default) + + args = { + shop_domain: "example-app.com", + shop_token: "token", + script_tags: [cache: true, src: "https://example-app.com/example-app.com-123.js"], + } + + assert_enqueued_with(job: ShopifyApp::ScriptTagsManagerJob, args: [args]) do + ShopifyApp::ScriptTagsManager.queue(args[:shop_domain], args[:shop_token], @script_tags[-1, 1]) + end + end + + test "#create_script_tags skips creation when theme supports app blocks for all template types" do + # Create manager with script tags that have template types + manager = ShopifyApp::ScriptTagsManager.new(@script_tags_with_template_types, "example-app.com") + manager.stubs(:graphql_client).returns(@mock_client) + + # Mock theme response + theme_response = mock + theme_response.stubs(:body).returns({ + "data" => { + "themes" => { + "nodes" => [ + { "id" => "gid://shopify/OnlineStoreTheme/123", "name" => "Test Theme" }, + ], + }, + }, + }) + + # Mock product template response + product_template_response = mock + product_template_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "templates/product.json", + "body" => { + "content" => '{"sections":{"main":{"type":"main-product","settings":{}}}}', + }, + }, + ], + }, + }, + }, + }) + + # Mock collection template response + collection_template_response = mock + collection_template_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "templates/collection.json", + "body" => { + "content" => '{"sections":{"main":{"type":"main-collection","settings":{}}}}', + }, + }, + ], + }, + }, + }, + }) + + # Mock index template response + index_template_response = mock + index_template_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "templates/index.json", + "body" => { + "content" => '{"sections":{"main":{"type":"main-index","settings":{}}}}', + }, + }, + ], + }, + }, + }, + }) + + # Mock product section response with app block support + product_section_response = mock + product_section_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "sections/main-product.liquid", + "body" => { + "content" => '{% schema %} { "blocks": [ { "type": "@app" } ] } {% endschema %}', + }, + }, + ], + }, + }, + }, + }) + + # Mock collection section response with app block support + collection_section_response = mock + collection_section_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "sections/main-collection.liquid", + "body" => { + "content" => '{% schema %} { "blocks": [ { "type": "@app" } ] } {% endschema %}', + }, + }, + ], + }, + }, + }, + }) + + # Mock index section response with app block support + index_section_response = mock + index_section_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "sections/main-index.liquid", + "body" => { + "content" => '{% schema %} { "blocks": [ { "type": "@app" } ] } {% endschema %}', + }, + }, + ], + }, + }, + }, + }) + + # Set up the sequence of responses for the GraphQL client + @mock_client.stubs(:query).returns(theme_response).then + .returns(product_template_response).then + .returns(product_section_response).then + .returns(collection_template_response).then + .returns(collection_section_response).then + .returns(index_template_response).then + .returns(index_section_response) + + # No scripttag creation should be attempted + @mock_client.expects(:query).with(has_entry(variables: has_key(:input))).never + + # Allow logging without capturing + ShopifyApp::Logger.stubs(:info) + + manager.create_script_tags(session: @session) + end + + test "#create_scripttags creates scripttags when theme doesn't support app blocks for all template types" do + # Create manager with script tags that have template types + manager = ShopifyApp::ScriptTagsManager.new(@script_tags_with_template_types, "example-app.com") + manager.stubs(:graphql_client).returns(@mock_client) + + # Mock theme response + theme_response = mock + theme_response.stubs(:body).returns({ + "data" => { + "themes" => { + "nodes" => [ + { "id" => "gid://shopify/OnlineStoreTheme/123", "name" => "Test Theme" }, + ], + }, + }, + }) + + # Mock product template response + product_template_response = mock + product_template_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "templates/product.json", + "body" => { + "content" => '{"sections":{"main":{"type":"main-product","settings":{}}}}', + }, + }, + ], + }, + }, + }, + }) + + # Mock product section response with app block support + product_section_response = mock + product_section_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "sections/main-product.liquid", + "body" => { + "content" => '{% schema %} { "blocks": [ { "type": "@app" } ] } {% endschema %}', + }, + }, + ], + }, + }, + }, + }) + + # Mock collection template response + collection_template_response = mock + collection_template_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "templates/collection.json", + "body" => { + "content" => '{"sections":{"main":{"type":"main-collection","settings":{}}}}', + }, + }, + ], + }, + }, + }, + }) + + # Mock collection section response WITHOUT app block support + collection_section_response = mock + collection_section_response.stubs(:body).returns({ + "data" => { + "theme" => { + "files" => { + "nodes" => [ + { + "filename" => "sections/main-collection.liquid", + "body" => { + "content" => '{% schema %} { "blocks": [ { "type": "text" } ] } {% endschema %}', + }, + }, + ], + }, + }, + }, + }) + + # Mock empty script tags response + empty_script_tags_response = mock + empty_script_tags_response.stubs(:body).returns({ + "data" => { + "scriptTags" => { + "edges" => [], + }, + }, + }) + + # Set up the sequence of responses for the GraphQL client + # The code will stop checking templates once it finds one that doesn't support app blocks + # So we only need to set up expectations for the queries that will actually be made + @mock_client.stubs(:query) + .returns(theme_response) + .then.returns(product_template_response) + .then.returns(product_section_response) + .then.returns(collection_template_response) + .then.returns(collection_section_response) + .then.returns(empty_script_tags_response) + + # Expect script tag creation calls + expect_script_tag_creation("https://example-app.com/fancy.js") + expect_script_tag_creation("https://example-app.com/foobar.js") + + manager.create_script_tags(session: @session) + end + + test "#create_scripttags_skips_creation_when_theme_API_access_fails" do + # Create manager with script tags that have template types + manager = ShopifyApp::ScriptTagsManager.new(@script_tags_with_template_types, "example-app.com") + manager.stubs(:graphql_client).returns(@mock_client) + + # Mock theme response with error + error_response = mock + error_response.stubs(:body).returns({ + "errors" => [ + { + "message" => "Access denied for themes field. Required access: `read_themes` access scope.", + "locations" => [{ "line" => 2, "column" => 11 }], + "path" => ["themes"], + "extensions" => { + "code" => "ACCESS_DENIED", + "documentation" => "https://shopify.dev/api/usage/access-scopes", + "requiredAccess" => "`read_themes` access scope.", + }, + }, + ], + "data" => { "themes" => nil }, + }) + + # Set up the response for the GraphQL client + @mock_client.expects(:query).returns(error_response).once + + # No scripttag creation should be attempted + @mock_client.expects(:query).with(has_entry(variables: has_key(:input))).never + + # Allow logging without capturing + ShopifyApp::Logger.stubs(:info) + ShopifyApp::Logger.stubs(:warn) + + manager.create_script_tags(session: @session) + end + + test "#create_scripttags skips creation when active theme is empty" do + # Create manager with script tags that have template types + manager = ShopifyApp::ScriptTagsManager.new(@script_tags_with_template_types, "example-app.com") + manager.stubs(:graphql_client).returns(@mock_client) + + # Mock theme response with empty nodes array + empty_theme_response = mock + empty_theme_response.stubs(:body).returns({ + "data" => { + "themes" => { + "nodes" => [], + }, + }, + }) + + # Set up the response for the GraphQL client + @mock_client.expects(:query).returns(empty_theme_response).once + + # No scripttag creation should be attempted + @mock_client.expects(:query).with(has_entry(variables: has_key(:input))).never + + # Allow logging without capturing + ShopifyApp::Logger.stubs(:info) + + manager.create_script_tags(session: @session) + end + + test "#create_scripttags skips creation when theme response has invalid structure" do + # Create manager with script tags that have template types + manager = ShopifyApp::ScriptTagsManager.new(@script_tags_with_template_types, "example-app.com") + manager.stubs(:graphql_client).returns(@mock_client) + + # Mock theme response with invalid structure + invalid_response = mock + invalid_response.stubs(:body).returns({ + "data" => { + # Missing "themes" key + }, + }) + + # Set up the response for the GraphQL client + @mock_client.expects(:query).returns(invalid_response).once + + # No scripttag creation should be attempted + @mock_client.expects(:query).with(has_entry(variables: has_key(:input))).never + + # Allow logging without capturing + ShopifyApp::Logger.stubs(:info) + ShopifyApp::Logger.stubs(:warn) + + manager.create_script_tags(session: @session) + end + + private + + def mock_empty_script_tags_response + empty_response = mock + empty_response.stubs(:body).returns({ + "data" => { + "scriptTags" => { + "edges" => [], + }, + }, + }) + @mock_client.stubs(:query).returns(empty_response) + end + + def mock_script_tags_response(script_tags) + edges = script_tags.map { |tag| { "node" => tag } } + + response = mock + response.stubs(:body).returns({ + "data" => { + "scriptTags" => { + "edges" => edges, + }, + }, + }) + @mock_client.stubs(:query).returns(response) + end + + def expect_script_tag_creation(src) + response = mock + response.stubs(:body).returns({ + "data" => { + "scriptTagCreate" => { + "scriptTag" => { + "id" => "gid://shopify/ScriptTag/#{rand(1000)}", + "src" => src, + "displayScope" => "ONLINE_STORE", + "cache" => true, + }, + "userErrors" => [], + }, + }, + }) + + @mock_client.expects(:query).with( + has_entries( + variables: { + input: { + src: src, + displayScope: "ONLINE_STORE", + cache: true, + }, + }, + ), + ).returns(response) + end + + def expect_script_tag_deletion(id) + response = mock + response.stubs(:body).returns({ + "data" => { + "scriptTagDelete" => { + "deletedScriptTagId" => id, + "userErrors" => [], + }, + }, + }) + + @mock_client.expects(:query).with( + has_entries( + variables: { id: id }, + ), + ).returns(response) + end +end From 7aafea297f45b314a9b82e3a07e32a6a4d939f0e Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Tue, 11 Mar 2025 11:42:18 -0500 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b963bf32a..06191be9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ Unreleased ---------- +- Adds a `script_tag_manager` that will automatically create script tags when the app is installed. [1948](https://github.com/Shopify/shopify_app/pull/1948) 22.5.1 (December 11, 2024) ----------