diff --git a/VERSION_CW b/VERSION_CW index d2b9909a9..0fb7a35b6 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.1 +4.14.2 diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb index 77c6245ea..181e4965e 100644 --- a/app/controllers/api/v1/accounts/onboardings_controller.rb +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll render 'api/v1/accounts/update', format: :json end + def help_center_generation + render json: help_center_generation_status + end + private def finalizing_account_details? @@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll def custom_attributes_params params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) end + + def help_center_generation_status + { + generation_id: nil, + state: nil, + articles_count: 0, + categories_count: 0 + } + end end + +Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController') diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js index 2bc098eef..e15d16da3 100644 --- a/app/javascript/dashboard/api/onboarding.js +++ b/app/javascript/dashboard/api/onboarding.js @@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient { update(data) { return axios.patch(this.url, data); } + + getHelpCenterGeneration() { + return axios.get(`${this.url}/help_center_generation`); + } } export default new OnboardingAPI(); diff --git a/app/services/website_branding_service.rb b/app/services/website_branding_service.rb index 026b51e0e..178994d1e 100644 --- a/app/services/website_branding_service.rb +++ b/app/services/website_branding_service.rb @@ -37,12 +37,18 @@ class WebsiteBrandingService private def fetch_page - response = HTTParty.get(@url, follow_redirects: true, timeout: 15) - @http_status = response.code - return nil unless response.success? + body = nil + SafeFetch.fetch(@url, validate_content_type: false) do |result| + body = result.tempfile.read + end + @http_status = 200 + return nil if body.blank? - Nokogiri::HTML(response.body) - rescue StandardError => e + Nokogiri::HTML(body) + rescue SafeFetch::HttpError => e + @http_status = e.message.to_i + nil + rescue SafeFetch::Error => e Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}" nil end diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 1a5b13c8f..2e8a5028e 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService access_token = exchange_code_for_token phone_info = fetch_phone_info(access_token) - validate_token_access(access_token) channel = create_or_reauthorize_channel(access_token, phone_info) # NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because: @@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform end - def validate_token_access(access_token) - Whatsapp::TokenValidationService.new(access_token, @waba_id).perform - end - def create_or_reauthorize_channel(access_token, phone_info) if @inbox_id.present? Whatsapp::ReauthorizationService.new( diff --git a/app/services/whatsapp/token_validation_service.rb b/app/services/whatsapp/token_validation_service.rb deleted file mode 100644 index 863f9da51..000000000 --- a/app/services/whatsapp/token_validation_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -class Whatsapp::TokenValidationService - def initialize(access_token, waba_id) - @access_token = access_token - @waba_id = waba_id - @api_client = Whatsapp::FacebookApiClient.new(access_token) - end - - def perform - validate_parameters! - validate_token_waba_access - end - - private - - def validate_parameters! - raise ArgumentError, 'Access token is required' if @access_token.blank? - raise ArgumentError, 'WABA ID is required' if @waba_id.blank? - end - - def validate_token_waba_access - token_debug_data = @api_client.debug_token(@access_token) - waba_scope = extract_waba_scope(token_debug_data) - verify_waba_authorization(waba_scope) - end - - def extract_waba_scope(token_data) - granular_scopes = token_data.dig('data', 'granular_scopes') - waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' } - - raise 'No WABA scope found in token' unless waba_scope - - waba_scope - end - - def verify_waba_authorization(waba_scope) - authorized_waba_ids = waba_scope['target_ids'] || [] - - return if authorized_waba_ids.include?(@waba_id) - - raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}" - end -end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 7506592ba..02b3480d5 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -14,6 +14,9 @@ if resource.custom_attributes.present? json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present? json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present? json.onboarding_step resource.onboarding_step if resource.onboarding_step.present? + if resource.custom_attributes['help_center_generation_id'].present? + json.help_center_generation_id resource.custom_attributes['help_center_generation_id'] + end json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present? if resource.custom_attributes['marked_for_deletion_reason'].present? json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason'] diff --git a/config/app.yml b/config/app.yml index fec34cd07..c2494befa 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.14.1' + version: '4.14.2' development: <<: *shared diff --git a/config/routes.rb b/config/routes.rb index 351808744..d78831343 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,9 @@ Rails.application.routes.draw do resource :contact_merge, only: [:create] end resource :bulk_actions, only: [:create] - resource :onboarding, only: [:update] + resource :onboarding, only: [:update] do + get :help_center_generation + end resources :agents, only: [:index, :create, :update, :destroy] do post :bulk_create, on: :collection end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb index fab952960..874197870 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb @@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas def test tool = account_custom_tools.new(custom_tool_params) - result = execute_test_request(tool) - render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) } + body = execute_test_request(tool) + render json: { status: 200, body: body.to_s.truncate(500) } rescue StandardError => e render json: { error: e.message }, status: :unprocessable_content end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb new file mode 100644 index 000000000..1311bc3fc --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb @@ -0,0 +1,38 @@ +module Enterprise::Api::V1::Accounts::OnboardingsController + def help_center_generation + @account = Current.account + render json: help_center_generation_status + end + + private + + def help_center_generation_status + generation_id = help_center_generation_id + return super if generation_id.blank? + + state = Onboarding::HelpCenterGenerationState.current(generation_id) + + { + generation_id: generation_id, + state: state, + articles_count: articles_count, + categories_count: categories_count + } + end + + def help_center_generation_id + @account.custom_attributes['help_center_generation_id'] + end + + def articles_count + onboarding_portal&.articles&.count || 0 + end + + def categories_count + onboarding_portal&.categories&.count || 0 + end + + def onboarding_portal + @onboarding_portal ||= @account.portals.first + end +end diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb index ed561d668..b5f7eb247 100644 --- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb +++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb @@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob queue_as :low retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error| - _account_id, _portal_id, user_id, generation_id = job.arguments + _account_id, _portal_id, _user_id, generation_id = job.arguments reason = "firecrawl exhausted: #{error.message}" Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}" - job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason) + job.send(:skip_generation, generation_id: generation_id, reason: reason) end def perform(account_id, portal_id, user_id, generation_id) @@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob ) rescue Onboarding::HelpCenterErrors::CurationSkipped => e Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}" - skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message) + skip_generation(generation_id: generation_id, reason: e.message) end private @@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:) articles.each do |article| Onboarding::HelpCenterArticleWriterJob.perform_later( - account_id, portal_id, user_id, generation_id, { article: article } + account_id, portal_id, user_id, generation_id, article ) end end - def skip_and_broadcast(user:, generation_id:, reason:) + def skip_generation(generation_id:, reason:) Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason) - Onboarding::HelpCenterBroadcaster.completed( - user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason - ) end end diff --git a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb index 2c25b86e9..68c218a19 100644 --- a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb +++ b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb @@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob job.send(:on_writer_failure, error) end - def perform(account_id, portal_id, user_id, generation_id, article_payload) - user = User.find(user_id) - payload = article_payload.with_indifferent_access - article = Onboarding::HelpCenterArticleBuilder.new( + def perform(account_id, portal_id, user_id, generation_id, article) + Onboarding::HelpCenterArticleBuilder.new( account: Account.find(account_id), portal: Portal.find(portal_id), - user: user, - article: payload[:article] + user: User.find(user_id), + article: article ).perform - finalize(user: user, generation_id: generation_id, article: article) + finalize(generation_id: generation_id) end private def on_writer_failure(error) - user, generation_id = failure_context + generation_id = arguments[3] Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}" - finalize(user: user, generation_id: generation_id, article: nil) + finalize(generation_id: generation_id) end - def failure_context - _account_id, _portal_id, user_id, generation_id = arguments - [User.find_by(id: user_id), generation_id] - end - - def finalize(user:, generation_id:, article:) - result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id) - - if article - Onboarding::HelpCenterBroadcaster.article_generated( - user: user, generation_id: generation_id, article: article, articles_finished: result[:finished] - ) - end - return unless result[:completed] - - Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed') + def finalize(generation_id:) + Onboarding::HelpCenterGenerationState.record_article_finished(generation_id) rescue Onboarding::HelpCenterGenerationState::Missing => e Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}" end diff --git a/enterprise/app/services/onboarding/help_center_broadcaster.rb b/enterprise/app/services/onboarding/help_center_broadcaster.rb deleted file mode 100644 index e12ed4287..000000000 --- a/enterprise/app/services/onboarding/help_center_broadcaster.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Onboarding::HelpCenterBroadcaster - ARTICLE_GENERATED = 'help_center.article_generated'.freeze - GENERATION_COMPLETED = 'help_center.generation_completed'.freeze - - module_function - - def article_generated(user:, generation_id:, article:, articles_finished:) - broadcast(user, ARTICLE_GENERATED, { - generation_id: generation_id, - article_id: article.id, - articles_finished: articles_finished - }) - end - - def completed(user:, generation_id:, status:, skip_reason: nil) - broadcast(user, GENERATION_COMPLETED, { - generation_id: generation_id, - status: status, - skip_reason: skip_reason - }) - end - - def broadcast(user, event, payload) - token = user&.pubsub_token - return if token.blank? - - ActionCableBroadcastJob.perform_later([token], event, payload) - end -end diff --git a/enterprise/app/services/onboarding/help_center_creation_service.rb b/enterprise/app/services/onboarding/help_center_creation_service.rb index 7ccd9bff3..9bbf053b5 100644 --- a/enterprise/app/services/onboarding/help_center_creation_service.rb +++ b/enterprise/app/services/onboarding/help_center_creation_service.rb @@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService generation_id = SecureRandom.uuid Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id) + @account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id)) rescue StandardError => e Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}" end diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb index b4593d27f..18ebcf53a 100644 --- a/enterprise/lib/captain/tools/http_tool.rb +++ b/enterprise/lib/captain/tools/http_tool.rb @@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool url = @custom_tool.build_request_url(params) body = @custom_tool.build_request_body(params) - response = execute_http_request(url, body, tool_context) - @custom_tool.format_response(response.body) + response_body = execute_http_request(url, body, tool_context) + @custom_tool.format_response(response_body) rescue StandardError => e Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") 'An error occurred while executing the request' @@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool private - PRIVATE_IP_RANGES = [ - IPAddr.new('127.0.0.0/8'), # IPv4 Loopback - IPAddr.new('10.0.0.0/8'), # IPv4 Private network - IPAddr.new('172.16.0.0/12'), # IPv4 Private network - IPAddr.new('192.168.0.0/16'), # IPv4 Private network - IPAddr.new('169.254.0.0/16'), # IPv4 Link-local - IPAddr.new('::1'), # IPv6 Loopback - IPAddr.new('fc00::/7'), # IPv6 Unique local addresses - IPAddr.new('fe80::/10') # IPv6 Link-local - ].freeze - # Limit response size to prevent memory exhaustion and match LLM token limits # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows MAX_RESPONSE_SIZE = 1.megabyte + # Route through SafeFetch so custom tool requests share the app's centralized HTTP + # fetching (resolution, timeouts, response size limits, and redirect handling). def execute_http_request(url, body, tool_context) - uri = URI.parse(url) + json_body = body if @custom_tool.http_method == 'POST' - # Check if resolved IP is private - check_private_ip!(uri.host) - - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = uri.scheme == 'https' - http.read_timeout = 30 - http.open_timeout = 10 - http.max_retries = 0 # Disable redirects - - request = build_http_request(uri, body) - apply_authentication(request) - apply_metadata_headers(request, tool_context) - - response = http.request(request) - - raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess) - - validate_response!(response) - - response + response_body = +'' + SafeFetch.fetch( + url, + method: @custom_tool.http_method == 'POST' ? :post : :get, + body: json_body, + headers: request_headers(tool_context, json_body), + http_basic_authentication: @custom_tool.build_basic_auth_credentials, + max_bytes: MAX_RESPONSE_SIZE, + validate_content_type: false + ) { |result| response_body = result.tempfile.read } + response_body end - def check_private_ip!(hostname) - ip_address = IPAddr.new(Resolv.getaddress(hostname)) - - raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) } - rescue Resolv::ResolvError, SocketError => e - raise "DNS resolution failed: #{e.message}" - end - - def validate_response!(response) - content_length = response['content-length']&.to_i - if content_length && content_length > MAX_RESPONSE_SIZE - raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" - end - - return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE - - raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" - end - - def build_http_request(uri, body) - if @custom_tool.http_method == 'POST' - request = Net::HTTP::Post.new(uri.request_uri) - if body - request.body = body - request['Content-Type'] = 'application/json' - end - else - request = Net::HTTP::Get.new(uri.request_uri) - end - request - end - - def apply_authentication(request) + def request_headers(tool_context, json_body) headers = @custom_tool.build_auth_headers - headers.each { |key, value| request[key] = value } - - credentials = @custom_tool.build_basic_auth_credentials - request.basic_auth(*credentials) if credentials - end - - def apply_metadata_headers(request, tool_context) - state = tool_context&.state || {} - metadata_headers = @custom_tool.build_metadata_headers(state) - metadata_headers.each { |key, value| request[key] = value } + headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {})) + headers['Content-Type'] = 'application/json' if json_body.present? + headers end end diff --git a/package.json b/package.json index 41313c8b3..bc51bbd85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.14.1", + "version": "4.14.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb index ec2b4dcaa..6f118624b 100644 --- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb @@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do end end end + + describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as an agent (non-admin)' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when no help center generation has started' do + it 'returns not_started with zero counts' do + get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to include( + 'generation_id' => nil, + 'state' => nil, + 'articles_count' => 0, + 'categories_count' => 0 + ) + end + end + end end diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb index 9ebaf3a55..d3f3cb601 100644 --- a/spec/enterprise/builders/saml_user_builder_spec.rb +++ b/spec/enterprise/builders/saml_user_builder_spec.rb @@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do it 'does not add the user to the target account' do expect do builder.perform - rescue SamlUserBuilder::AuthenticationFailed - nil + rescue StandardError => e + raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison end.not_to change(AccountUser, :count) expect(existing_user.reload.accounts).not_to include(account) end @@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do it 'does not convert the user provider to saml' do expect do builder.perform - rescue SamlUserBuilder::AuthenticationFailed - nil + rescue StandardError => e + raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison end.not_to(change { existing_user.reload.provider }) end end diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb new file mode 100644 index 000000000..59d0564fa --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Onboarding API', type: :request do + let(:account) { create(:account, domain: 'example.com') } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do + context 'when help center generation is in progress' do + let(:generation_id) { 'generation-123' } + let!(:portal) { create(:portal, account_id: account.id) } + let!(:category) { create(:category, portal: portal, account_id: account.id) } + + before do + account.update!(custom_attributes: { 'help_center_generation_id' => generation_id }) + create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id) + Onboarding::HelpCenterGenerationState.start(generation_id, total: 3) + Onboarding::HelpCenterGenerationState.record_article_finished(generation_id) + end + + after do + Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id)) + end + + it 'returns Redis state and help center counts' do + get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to include( + 'generation_id' => generation_id, + 'articles_count' => 1, + 'categories_count' => 1 + ) + expect(response.parsed_body['state']).to include( + 'status' => 'generating', + 'finished' => '1', + 'total' => '3' + ) + end + end + end +end diff --git a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb index 62529866d..b281f0c10 100644 --- a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb +++ b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb @@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do admin.id, generation_id, hash_including( - 'article' => hash_including( - 'title' => 'Hello', - 'urls' => ['https://x.test/a'], - 'category_id' => portal.categories.first.id - ) + 'title' => 'Hello', + 'urls' => ['https://x.test/a'], + 'category_id' => portal.categories.first.id ) ) ) @@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name } expect(writer_jobs.size).to eq(1) expect(writer_jobs.first['arguments']).to include( - hash_including('article' => hash_including('title' => 'Valid')) + hash_including('title' => 'Valid') ) end end @@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name } expect(writer_jobs.size).to eq(1) expect(writer_jobs.first['arguments']).to include( - hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])) + hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']) ) expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1') end @@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do expect(state['skip_reason']).to include('firecrawl exhausted') end end - - describe 'broadcasts' do - it 'broadcasts generation_completed with status: skipped on CurationSkipped' do - curator = instance_double(Onboarding::HelpCenterCurator) - allow(curator).to receive(:perform).and_raise( - Onboarding::HelpCenterErrors::CurationSkipped, 'no website url' - ) - allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator) - - payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url') - expect { described_class.perform_now(*job_args) } - .to have_enqueued_job(ActionCableBroadcastJob) - .with([admin.pubsub_token], 'help_center.generation_completed', payload) - end - end end diff --git a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb index a4db6b1d3..b01ec35e3 100644 --- a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb +++ b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb @@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do let!(:admin) { create(:user, account: account, role: :administrator) } let(:generation_id) { 'generation-123' } let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } } - let(:article_payload) { { 'article' => article_spec } } - let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] } + let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] } let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) } before do @@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1') end - it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do + it 'marks generation completed when the final writer fails with ArticleBuildFailed' do allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise( Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls' ) Onboarding::HelpCenterGenerationState.record_article_finished(generation_id) - payload = hash_including(generation_id: generation_id, status: 'completed') - expect { described_class.perform_now(*job_args) } - .to have_enqueued_job(ActionCableBroadcastJob) - .with([admin.pubsub_token], 'help_center.generation_completed', payload) + described_class.perform_now(*job_args) + expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include( 'status' => 'completed', 'finished' => '2' ) @@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do end end - describe 'broadcasts' do + describe 'missing state' do let(:built_article) { instance_double(Article, id: 9876) } before do @@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder) end - it 'broadcasts help_center.article_generated on success' do - payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1) - expect { described_class.perform_now(*job_args) } - .to have_enqueued_job(ActionCableBroadcastJob) - .with([admin.pubsub_token], 'help_center.article_generated', payload) - end - - it 'broadcasts help_center.generation_completed when the last writer finishes' do - described_class.perform_now(*job_args) - payload = hash_including(generation_id: generation_id, status: 'completed') - - expect { described_class.perform_now(*job_args) } - .to have_enqueued_job(ActionCableBroadcastJob) - .with([admin.pubsub_token], 'help_center.generation_completed', payload) - end - - it 'does not broadcast article_generated on builder failure' do - allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise( - Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls' - ) - - expect { described_class.perform_now(*job_args) } - .not_to have_enqueued_job(ActionCableBroadcastJob) - .with(anything, 'help_center.article_generated', anything) - end - - it 'broadcasts generation_completed on late retries past total' do - described_class.perform_now(*job_args) - described_class.perform_now(*job_args) - clear_enqueued_jobs - - expect { described_class.perform_now(*job_args) } - .to have_enqueued_job(ActionCableBroadcastJob) - .with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id)) - end - - it 'skips progress broadcasts when state is missing' do + it 'does not raise when state is missing' do Redis::Alfred.delete(state_key) - expect { described_class.perform_now(*job_args) } - .not_to have_enqueued_job(ActionCableBroadcastJob) + expect { described_class.perform_now(*job_args) }.not_to raise_error end end end diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb index 4c8fa3394..e2c03c55a 100644 --- a/spec/jobs/mutex_application_job_spec.rb +++ b/spec/jobs/mutex_application_job_spec.rb @@ -58,7 +58,7 @@ RSpec.describe MutexApplicationJob do describe '.retry_on_lock_conflict' do let(:job_class) do - Class.new(described_class) do + Class.new(MutexApplicationJob) do retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock attr_reader :fallback_args @@ -90,7 +90,7 @@ RSpec.describe MutexApplicationJob do context 'without an exhaustion handler' do let(:job_class) do - Class.new(described_class) do + Class.new(MutexApplicationJob) do retry_on_lock_conflict wait: 1.second, attempts: 1 def perform(lock_key) diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index a20e36ac8..560b1993e 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do .with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service) allow(phone_service).to receive(:perform).and_return(phone_info) - validation_service = instance_double(Whatsapp::TokenValidationService) - allow(Whatsapp::TokenValidationService).to receive(:new) - .with(access_token, params[:waba_id]).and_return(validation_service) - allow(validation_service).to receive(:perform) - channel_creation = instance_double(Whatsapp::ChannelCreationService) allow(Whatsapp::ChannelCreationService).to receive(:new) .with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token) diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb deleted file mode 100644 index d9cf40257..000000000 --- a/spec/services/whatsapp/token_validation_service_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -require 'rails_helper' - -describe Whatsapp::TokenValidationService do - let(:access_token) { 'test_access_token' } - let(:waba_id) { 'test_waba_id' } - let(:service) { described_class.new(access_token, waba_id) } - let(:api_client) { instance_double(Whatsapp::FacebookApiClient) } - - before do - allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client) - end - - describe '#perform' do - context 'when token has access to WABA' do - let(:debug_response) do - { - 'data' => { - 'granular_scopes' => [ - { - 'scope' => 'whatsapp_business_management', - 'target_ids' => [waba_id, 'another_waba_id'] - } - ] - } - } - end - - before do - allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response) - end - - it 'validates successfully' do - expect { service.perform }.not_to raise_error - end - end - - context 'when token does not have access to WABA' do - let(:debug_response) do - { - 'data' => { - 'granular_scopes' => [ - { - 'scope' => 'whatsapp_business_management', - 'target_ids' => ['different_waba_id'] - } - ] - } - } - end - - before do - allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response) - end - - it 'raises an error' do - expect { service.perform }.to raise_error(/Token does not have access to WABA/) - end - end - - context 'when no WABA scope is found' do - let(:debug_response) do - { - 'data' => { - 'granular_scopes' => [ - { - 'scope' => 'some_other_scope', - 'target_ids' => ['some_id'] - } - ] - } - } - end - - before do - allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response) - end - - it 'raises an error' do - expect { service.perform }.to raise_error('No WABA scope found in token') - end - end - - context 'when access_token is blank' do - let(:access_token) { '' } - - it 'raises ArgumentError' do - expect { service.perform }.to raise_error(ArgumentError, 'Access token is required') - end - end - - context 'when waba_id is blank' do - let(:waba_id) { '' } - - it 'raises ArgumentError' do - expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required') - end - end - end -end