diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index dcb41d614..cc6785242 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,7 +30,6 @@ class Api::V1::AccountsController < Api::BaseController locale: account_params[:locale], user: current_user ).perform - enqueue_branding_enrichment if @user # Authenticated users (dashboard "add account") and api_only signups # need the full response with account_id. API-only deployments have no @@ -74,17 +73,6 @@ class Api::V1::AccountsController < Api::BaseController Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION) end - def enqueue_branding_enrichment - email = account_params[:email].presence || @user&.email - return if email.blank? - - Account::BrandingEnrichmentJob.perform_later(@account.id, email) - Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) - rescue StandardError => e - # Enrichment is optional — never let queue/Redis failures abort signup - ChatwootExceptionTracker.new(e).capture_exception - end - def ensure_account_name # ensure that account_name and user_full_name is present # this is becuase the account builder and the models validations are not triggered diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index ad27ee40b..a0047d855 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -59,7 +59,6 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.unread_count_changed': this.onConversationUnreadCountChanged, 'account.cache_invalidated': this.onCacheInvalidate, - 'account.enrichment_completed': this.onEnrichmentCompleted, 'copilot.message.created': this.onCopilotMessageCreated, 'voice_call.incoming': this.onVoiceCallIncoming, 'voice_call.accepted': this.onVoiceCallAccepted, @@ -338,10 +337,6 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('copilotMessages/upsert', data); }; - onEnrichmentCompleted = () => { - this.app.$store.dispatch('accounts/get', { silent: true }); - }; - onCacheInvalidate = data => { const keys = data.cache_keys; this.app.$store.dispatch('labels/revalidate', { newKey: keys.label }); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue index 7e4fbc84e..cc038f94e 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue @@ -16,9 +16,7 @@ import OnboardingLayout from './shared/OnboardingLayout.vue'; import OnboardingSection from './shared/OnboardingSection.vue'; import OnboardingFormRow from './account-details/OnboardingFormRow.vue'; import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue'; -import { useAccountEnrichment } from './account-details/useAccountEnrichment'; import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; -import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import { COMPANY_SIZE_OPTIONS, INDUSTRY_OPTIONS, @@ -75,21 +73,6 @@ const v$ = useVuelidate(validationRules, { const userName = computed(() => currentUser.value?.name || ''); const userEmail = computed(() => currentUser.value?.email || ''); const accountName = computed(() => currentAccount.value?.name || ''); -const { isEnriching, getChangedFields } = useAccountEnrichment({ - locale, - website, - timezone, - companySize, - industry, - referralSource, -}); - -const companyLogo = computed(() => { - const logos = currentAccount.value?.custom_attributes?.brand_info?.logos; - if (!logos?.length) return ''; - const square = logos.find(l => l.resolution?.aspect_ratio === 1); - return (square || logos[0])?.url || ''; -}); const languageOptions = computed(() => { const langs = [...(enabledLanguages || [])]; @@ -129,12 +112,10 @@ const normalizeWebsiteUrl = raw => { }; const handleSubmit = async () => { - // Block submit while enrichment is still running so users can't bypass - // the form with empty values — the controller would otherwise clear - // onboarding_step and persist incomplete data. Also guard against - // re-entry while a submit is in flight (double-click/Enter), which would - // fire parallel requests that can duplicate the auto-created inbox/portal. - if (isEnriching.value || isSubmitting.value) return; + // Guard against re-entry while a submit is in flight (double-click/Enter), + // which would fire parallel requests that can duplicate the auto-created + // inbox/portal. + if (isSubmitting.value) return; v$.value.$touch(); if (v$.value.$invalid) { @@ -149,12 +130,8 @@ const handleSubmit = async () => { return; } - // Capture which enrichable fields the user edited *before* normalizing the - // website, so an untouched auto-filled domain isn't falsely flagged. - const fieldsChanged = getChangedFields(); - - // Persist with a scheme so downstream consumers (Firecrawl, portal - // homepage_link) get a fully-qualified URL regardless of what the user typed. + // Persist with a scheme so downstream portal consumers get a fully-qualified + // URL regardless of what the user typed. website.value = normalizeWebsiteUrl(website.value); isSubmitting.value = true; @@ -172,10 +149,6 @@ const handleSubmit = async () => { }); useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, { - has_enriched_data: Boolean( - currentAccount.value?.custom_attributes?.brand_info - ), - fields_changed: fieldsChanged, user_role: userRole.value, company_size: companySize.value, industry: industry.value, @@ -211,7 +184,6 @@ const handleSubmit = async () => { :subtitle="t('ONBOARDING_NEXT.SUBTITLE')" :continue-label="t('ONBOARDING_NEXT.CONTINUE')" :is-loading="isSubmitting" - :disabled="isEnriching" > { :title="t('ONBOARDING_NEXT.COMPANY_DETAILS')" icon="i-lucide-briefcase-business" > -
- - - {{ t('ONBOARDING_NEXT.SETTING_UP') }} +
+ + {{ accountName }}
- + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/account-details/useAccountEnrichment.js b/app/javascript/dashboard/routes/dashboard/onboarding/account-details/useAccountEnrichment.js deleted file mode 100644 index 028d7e2a6..000000000 --- a/app/javascript/dashboard/routes/dashboard/onboarding/account-details/useAccountEnrichment.js +++ /dev/null @@ -1,136 +0,0 @@ -import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; -import { useAccount } from 'dashboard/composables/useAccount'; -import { useConfig } from 'dashboard/composables/useConfig'; - -const ENRICHMENT_TIMEOUT = 30000; - -// Manages the post-signup enrichment lifecycle for the account-details form. -// After signup the account is enriched asynchronously (onboarding_step === -// 'enrichment'); this fills empty form fields from the enriched -// custom_attributes/brand_info as it arrives — idempotently, so it never -// clobbers a value the user has already typed — waits the step out (with a -// timeout fallback), and tracks which enrichable fields the user edited. -// -// `fields` is the set of form refs to populate, owned by the component so it can -// still wire them to validation and the template. -export function useAccountEnrichment(fields) { - const { currentAccount } = useAccount(); - const { enabledLanguages } = useConfig(); - - const enrichmentTimedOut = ref(false); - const isEnriching = computed( - () => - !enrichmentTimedOut.value && - currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment' - ); - - // Best-effort match browser language to enabled Chatwoot locales: exact match - // first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account - // locale or 'en'. - const detectBestLocale = () => { - const codes = (enabledLanguages || []).map(l => l.iso_639_1_code); - const browserLang = navigator.language?.replace('-', '_'); - const accountLocale = currentAccount.value?.locale || 'en'; - if (!browserLang) return accountLocale; - - if (codes.includes(browserLang)) return browserLang; - const base = browserLang.split('_')[0]; - if (codes.includes(base)) return base; - - return accountLocale; - }; - - // Snapshot of the auto-populated values, used to detect user edits at submit. - const initialValues = ref({}); - const snapshotInitialValues = () => { - initialValues.value = { - website: fields.website.value, - company_size: fields.companySize.value, - industry: fields.industry.value, - }; - }; - - // Idempotent: only fills empty fields, so late-arriving enrichment data - // populates untouched fields without clobbering user edits. - const populateFormFields = () => { - const { - website, - timezone, - company_size: companySize, - industry, - referral_source: referralSource, - brand_info: brandInfo, - } = currentAccount.value?.custom_attributes || {}; - - const fillIfEmpty = (field, value) => { - if (!field.value) field.value = value || ''; - }; - - fillIfEmpty(fields.locale, detectBestLocale()); - fillIfEmpty(fields.website, website || brandInfo?.domain); - fillIfEmpty( - fields.timezone, - timezone || Intl.DateTimeFormat().resolvedOptions().timeZone - ); - fillIfEmpty(fields.companySize, companySize); - fillIfEmpty( - fields.industry, - industry || brandInfo?.industries?.[0]?.industry - ); - fillIfEmpty(fields.referralSource, referralSource); - - snapshotInitialValues(); - }; - - let enrichmentTimer = null; - const startEnrichmentTimer = () => { - if (enrichmentTimer) clearTimeout(enrichmentTimer); - enrichmentTimer = setTimeout(() => { - enrichmentTimedOut.value = true; - populateFormFields(); - }, ENRICHMENT_TIMEOUT); - }; - - onMounted(() => { - populateFormFields(); - if (isEnriching.value) startEnrichmentTimer(); - }); - - onUnmounted(() => { - if (enrichmentTimer) clearTimeout(enrichmentTimer); - }); - - watch(isEnriching, enriching => { - if (enriching) { - startEnrichmentTimer(); - } else { - if (enrichmentTimer) clearTimeout(enrichmentTimer); - populateFormFields(); - } - }); - - // Re-populate when account data arrives after mount, or when brand_info - // appears after enrichment. populateFormFields is idempotent so this is safe. - watch( - () => currentAccount.value?.custom_attributes, - () => populateFormFields() - ); - - // Enrichable fields the user actually edited since they were auto-filled. - // Compare against the snapshot *before* the caller normalizes any values — - // otherwise an untouched auto-filled domain (acme.com -> https://acme.com) - // compares unequal and gets falsely reported as changed. - const getChangedFields = () => { - const init = initialValues.value; - const current = { - website: fields.website.value, - company_size: fields.companySize.value, - industry: fields.industry.value, - }; - return Object.entries(current) - .filter(([key, value]) => value !== init[key]) - .map(([key]) => key); - }; - - return { isEnriching, getChangedFields }; -} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js index 91e800d06..1e651e5b4 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js @@ -112,38 +112,6 @@ export const CHANNEL_LIST = [ const channelByType = type => CHANNEL_LIST.find(channel => channel.type === type); -// Icons shown next to "View all" when every detected channel is already -// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs -// aren't duplicated. export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map( channelByType ); - -// Social channels that detected brand_info socials map to, keyed by social type -// in the order they're offered as rows. Derived from CHANNEL_LIST so channel -// identity (label, channel_type) has a single source. Keys mirror -// SocialLinkParser::SOCIAL_DOMAIN_MAP. -const SOCIAL_PLATFORM_TYPES = [ - 'whatsapp', - 'facebook', - 'line', - 'instagram', - 'telegram', - 'tiktok', -]; - -export const SOCIAL_PLATFORMS = Object.fromEntries( - SOCIAL_PLATFORM_TYPES.map(type => { - const { labelKey, inbox } = channelByType(type); - return [type, { labelKey, channelType: inbox.channel_type }]; - }) -); - -// Mailbox providers inferred from the signup domain's MX records, keyed by -// Channel::Email#provider. Derived from CHANNEL_LIST's email entries. -export const EMAIL_PROVIDERS = Object.fromEntries( - CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [ - channel.inbox.provider, - { labelKey: channel.labelKey }, - ]) -); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js index 6ba68c6bf..c6c290ce6 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js @@ -1,94 +1,40 @@ import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; -import { useAccount } from 'dashboard/composables/useAccount'; -import { - SOCIAL_PLATFORMS, - EMAIL_PROVIDERS, - DEFAULT_CHANNEL_TYPES, -} from './constants'; +import { CHANNEL_LIST, DEFAULT_CHANNEL_TYPES } from './constants'; import { findConnectedInbox } from './channelMatchers'; import { useChannelConfig } from './useChannelConfig'; -// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES -// is config-gated like everything else, then sliced to this limit. +// How many channel rows to show. The list is installation-configured, then +// sliced so the onboarding step stays compact on small screens. const DISPLAYED_CHANNEL_LIMIT = 3; +const ONBOARDING_CHANNEL_TYPES = [ + 'whatsapp', + 'facebook', + 'instagram', + 'telegram', + 'line', + 'tiktok', +]; -// Pull the handle/username out of a detected social URL, formatted per channel. -const extractHandle = ({ type, url }) => { - try { - const { pathname } = new URL(url); - const path = pathname.replace(/^\/+|\/+$/g, ''); - if (type === 'whatsapp') { - const digits = path.replace(/\D/g, ''); - return digits ? `+${digits}` : ''; - } - if (type === 'line') return path; - return path.startsWith('@') ? path : `@${path}`; - } catch { - return ''; - } -}; +const channelByType = type => + CHANNEL_LIST.find(channel => channel.type === type); -// Derives the channel rows for the inbox-setup step from the account's detected -// brand_info (socials + mailbox provider) and the real connected inboxes, -// keeping InboxSetup.vue focused on layout, connect routing, and completion. -export function useDetectedChannels() { - const { currentAccount } = useAccount(); - const inboxes = useMapGetter('inboxes/getInboxes'); - const { isConfigured } = useChannelConfig(); - - const brandSocials = computed( - () => currentAccount.value?.custom_attributes?.brand_info?.socials || [] - ); - - const connectedChannels = computed(() => - brandSocials.value - .filter(social => SOCIAL_PLATFORMS[social.type] && social.url) - .map(social => ({ - type: social.type, - handle: extractHandle(social), - labelKey: SOCIAL_PLATFORMS[social.type].labelKey, - inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType }, - })) - ); - - const detectedEmailChannel = computed(() => { - const brandInfo = currentAccount.value?.custom_attributes?.brand_info; - const provider = brandInfo?.email_provider; - if (!EMAIL_PROVIDERS[provider]) return null; - - return { - type: 'email', - handle: brandInfo?.email || '', - labelKey: EMAIL_PROVIDERS[provider].labelKey, - inbox: { channel_type: 'Channel::Email', provider }, - }; - }); - - // The real inbox backing a channel, if one exists — returned (not just a - // boolean) so the row can show the connected account's real name. - const connectedInbox = channel => - findConnectedInbox(inboxes.value, channel.inbox); - - // A channel row built from a social type, with no detected handle — used for - // the default suggestions when nothing was detected. - const toChannelRow = type => ({ +const toChannelRow = type => { + const channel = channelByType(type); + return { type, handle: '', - labelKey: SOCIAL_PLATFORMS[type].labelKey, - inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType }, - }); + labelKey: channel.labelKey, + inbox: channel.inbox, + }; +}; - const detectedChannels = computed(() => - [detectedEmailChannel.value, ...connectedChannels.value] - .filter(Boolean) - // Email channels (including Gmail/Outlook OAuth) are disabled for this - // phase; they will be enabled in a future PR. - .filter(channel => channel.type !== 'email') - // Hide channels whose installation OAuth credentials are missing — their - // connect flow would only error. - .filter(channel => isConfigured(channel.type)) - ); +// Onboarding intentionally offers explicit channel suggestions only. It no +// longer derives social or mailbox channels from signup email-domain probes or +// automatic remote enrichment. +export function useDetectedChannels() { + const inboxes = useMapGetter('inboxes/getInboxes'); + const { isConfigured } = useChannelConfig(); const defaultChannels = computed(() => DEFAULT_CHANNEL_TYPES.filter(isConfigured) @@ -96,30 +42,27 @@ export function useDetectedChannels() { .map(toChannelRow) ); - // Show the detected channels, or fall back to the default suggestions so the - // step is never an empty list. - const displayedChannels = computed(() => - detectedChannels.value.length - ? detectedChannels.value - : defaultChannels.value - ); + const displayedChannels = defaultChannels; const remainingChannels = computed(() => { - // Exclude whatever is already shown as a row (detected or defaulted) so the - // footer preview doesn't duplicate it. const shownTypes = new Set(displayedChannels.value.map(c => c.type)); - return Object.entries(SOCIAL_PLATFORMS) - .filter(([type]) => !shownTypes.has(type)) - .filter(([type]) => isConfigured(type)) + return ONBOARDING_CHANNEL_TYPES.filter(type => !shownTypes.has(type)) + .filter(isConfigured) .slice(0, 3) - .map(([type, { labelKey, channelType }]) => ({ - type, - labelKey, - inbox: { channel_type: channelType }, - })); + .map(type => { + const channel = channelByType(type); + return { + type, + labelKey: channel.labelKey, + inbox: channel.inbox, + }; + }); }); - const hasDetectedChannels = computed(() => detectedChannels.value.length > 0); + const connectedInbox = channel => + findConnectedInbox(inboxes.value, channel.inbox); + + const hasDetectedChannels = computed(() => false); return { displayedChannels, diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js deleted file mode 100644 index a8d85010a..000000000 --- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import { defineComponent, h, ref } from 'vue'; -import { createStore } from 'vuex'; -import { mount } from '@vue/test-utils'; -import { useRoute } from 'vue-router'; -import { useAccountEnrichment } from '../../account-details/useAccountEnrichment'; - -vi.mock('vue-router'); - -const ENABLED_LANGUAGES = [ - { iso_639_1_code: 'en', name: 'English' }, - { iso_639_1_code: 'fr', name: 'French' }, -]; - -// Mounts the composable against a real store and the real useAccount/useConfig -// (only useRoute and the underlying account getter / window config are faked), -// so a change to how those resolve their data is exercised here too. `presets` -// seeds form fields as if the user had already typed them. -const mountComposable = ({ - account = {}, - enabledLanguages = ENABLED_LANGUAGES, - presets = {}, -} = {}) => { - window.chatwootConfig = { enabledLanguages }; - - const store = createStore({ - modules: { - accounts: { - namespaced: true, - getters: { getAccount: () => () => account }, - }, - }, - }); - - const fields = { - locale: ref(presets.locale || ''), - website: ref(presets.website || ''), - timezone: ref(presets.timezone || ''), - companySize: ref(presets.companySize || ''), - industry: ref(presets.industry || ''), - referralSource: ref(presets.referralSource || ''), - }; - - let api; - const Component = defineComponent({ - setup() { - api = useAccountEnrichment(fields); - return () => h('div'); - }, - }); - const wrapper = mount(Component, { global: { plugins: [store] } }); - return { ...api, fields, wrapper }; -}; - -beforeEach(() => { - useRoute.mockReturnValue({ params: { accountId: '1' } }); -}); - -afterEach(() => { - delete window.chatwootConfig; -}); - -describe('useAccountEnrichment', () => { - describe('populateFormFields', () => { - it('fills empty fields from the enriched attributes on mount', () => { - const { fields } = mountComposable({ - account: { - locale: 'en', - custom_attributes: { - website: 'https://acme.com', - timezone: 'America/New_York', - company_size: '11-50', - industry: 'Technology', - referral_source: 'google', - }, - }, - }); - - expect(fields.website.value).toBe('https://acme.com'); - expect(fields.timezone.value).toBe('America/New_York'); - expect(fields.companySize.value).toBe('11-50'); - expect(fields.industry.value).toBe('Technology'); - expect(fields.referralSource.value).toBe('google'); - }); - - it('falls back to brand_info for website and industry', () => { - const { fields } = mountComposable({ - account: { - custom_attributes: { - brand_info: { - domain: 'acme.com', - industries: [{ industry: 'Retail & E-commerce' }], - }, - }, - }, - }); - - expect(fields.website.value).toBe('acme.com'); - expect(fields.industry.value).toBe('Retail & E-commerce'); - }); - - it('does not clobber fields the user already set', () => { - const { fields } = mountComposable({ - presets: { website: 'mysite.com', industry: 'Finance' }, - account: { - custom_attributes: { - website: 'https://enriched.com', - industry: 'Technology', - }, - }, - }); - - expect(fields.website.value).toBe('mysite.com'); - expect(fields.industry.value).toBe('Finance'); - }); - - it('detects the locale from the browser, else the account locale', () => { - // jsdom reports navigator.language as 'en-US' -> base 'en' is enabled. - const { fields } = mountComposable({ account: { locale: 'de' } }); - expect(fields.locale.value).toBe('en'); - - // No enabled language matches the browser -> fall back to account locale. - const { fields: other } = mountComposable({ - account: { locale: 'de' }, - enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }], - }); - expect(other.locale.value).toBe('de'); - }); - }); - - describe('isEnriching', () => { - it('is true while the account is on the enrichment step', () => { - const { isEnriching } = mountComposable({ - account: { custom_attributes: { onboarding_step: 'enrichment' } }, - }); - expect(isEnriching.value).toBe(true); - }); - - it('is false on any other step', () => { - const { isEnriching } = mountComposable({ - account: { custom_attributes: { onboarding_step: 'account_details' } }, - }); - expect(isEnriching.value).toBe(false); - }); - - it('times out after 30s, flipping to false and populating', () => { - vi.useFakeTimers(); - try { - const { isEnriching, fields } = mountComposable({ - account: { - custom_attributes: { - onboarding_step: 'enrichment', - company_size: '51-200', - }, - }, - }); - expect(isEnriching.value).toBe(true); - - vi.advanceTimersByTime(30000); - - expect(isEnriching.value).toBe(false); - expect(fields.companySize.value).toBe('51-200'); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe('getChangedFields', () => { - it('lists only enrichable fields edited after auto-fill', () => { - const { fields, getChangedFields } = mountComposable({ - account: { - custom_attributes: { - website: 'https://acme.com', - company_size: '11-50', - industry: 'Technology', - }, - }, - }); - - expect(getChangedFields()).toEqual([]); - - fields.industry.value = 'Finance'; - expect(getChangedFields()).toEqual(['industry']); - }); - }); -}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js index a0cb0fefc..c29285500 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js @@ -6,17 +6,10 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels'; vi.mock('vue-router'); -// Mounts the composable against a real store and the real useAccount (only -// useRoute and the underlying getters are faked), so a change to how useAccount -// resolves the current account is exercised here too. The real ./constants are -// used, so assertions validate against the actual channel identity (label keys, -// channel_type, social ordering) derived from CHANNEL_LIST. const mountComposable = ({ - brandInfo, + customAttributes = {}, features = { channel_instagram: true }, inboxes = [], - isOnChatwootCloud = false, - disableMetaInboxCreation = false, } = {}) => { const store = createStore({ modules: { @@ -24,9 +17,8 @@ const mountComposable = ({ namespaced: true, getters: { get: () => ({}), - isOnChatwootCloud: () => isOnChatwootCloud, - isMetaInboxCreationDisabled: () => - isOnChatwootCloud && disableMetaInboxCreation, + isOnChatwootCloud: () => false, + isMetaInboxCreationDisabled: () => false, isMetaMessageSendingDisabled: () => false, }, }, @@ -36,7 +28,7 @@ const mountComposable = ({ getAccount: () => () => ({ id: 1, features, - custom_attributes: { brand_info: brandInfo }, + custom_attributes: customAttributes, }), isFeatureEnabledonAccount: () => (_accountId, feature) => Boolean(features[feature]), @@ -62,8 +54,6 @@ const mountComposable = ({ beforeEach(() => { useRoute.mockReturnValue({ params: { accountId: '1' } }); - // Configure the installation OAuth credentials so detected channels aren't - // hidden by the config gate; individual tests clear this to assert hiding. window.chatwootConfig = { fbAppId: 'fb', instagramAppId: 'ig', @@ -78,282 +68,57 @@ afterEach(() => { }); describe('useDetectedChannels', () => { - describe('displayedChannels', () => { - it('maps detected socials with a url to channel rows', () => { - const { displayedChannels } = mountComposable({ - brandInfo: { - socials: [ - { type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' }, - { type: 'instagram', url: 'https://instagram.com/acme' }, - ], - }, - }); - - expect(displayedChannels.value).toEqual([ - { - type: 'whatsapp', - handle: '+14155552671', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', - inbox: { channel_type: 'Channel::Whatsapp' }, - }, - { - type: 'instagram', - handle: '@acme', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', - inbox: { channel_type: 'Channel::Instagram' }, - }, - ]); - }); - - it('skips socials without a url or with an unknown type', () => { - const { displayedChannels } = mountComposable({ - brandInfo: { - socials: [ - { type: 'telegram' }, // no url - { type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown - { type: 'tiktok', url: 'https://tiktok.com/@acme' }, - ], - }, - }); - - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'tiktok', - ]); - }); - - it('uses the raw path for line and falls back to empty on a bad url', () => { - const { displayedChannels } = mountComposable({ - brandInfo: { - socials: [ - { type: 'line', url: 'https://line.me/acme' }, - { type: 'facebook', url: 'not-a-url' }, - ], - }, - }); - - expect(displayedChannels.value).toEqual([ - { - type: 'line', - handle: 'acme', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', - inbox: { channel_type: 'Channel::Line' }, - }, - { - type: 'facebook', - handle: '', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', - inbox: { channel_type: 'Channel::FacebookPage' }, - }, - ]); - }); - - it('omits the detected email channel while email is disabled for this phase', () => { - const { displayedChannels } = mountComposable({ - brandInfo: { + it('does not derive channel rows from persisted remote enrichment data', () => { + const { displayedChannels, hasDetectedChannels } = mountComposable({ + customAttributes: { + brand_info: { + socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], email_provider: 'google', - email: 'support@acme.com', - socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], }, - }); - - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'whatsapp', - ]); + }, }); - it('falls back to the default channel suggestions when nothing is detected', () => { - const { displayedChannels } = mountComposable({ brandInfo: undefined }); - - // The configured mainstream channels, with no detected handle. - expect(displayedChannels.value).toEqual([ - { - type: 'whatsapp', - handle: '', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', - inbox: { channel_type: 'Channel::Whatsapp' }, - }, - { - type: 'facebook', - handle: '', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', - inbox: { channel_type: 'Channel::FacebookPage' }, - }, - { - type: 'instagram', - handle: '', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', - inbox: { channel_type: 'Channel::Instagram' }, - }, - ]); - }); - - it('gates the default suggestions by installation config, keeping the list non-empty', () => { - window.chatwootConfig = {}; // no OAuth credentials configured - const { displayedChannels } = mountComposable({ brandInfo: undefined }); - - // Only the credential-free defaults survive (Telegram, LINE). - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'telegram', - 'line', - ]); - }); - - it('hides detected channels whose installation OAuth credentials are missing', () => { - window.chatwootConfig = {}; // nothing configured - const { displayedChannels } = mountComposable({ - brandInfo: { - socials: [ - { type: 'facebook', url: 'https://facebook.com/acme' }, - { type: 'line', url: 'https://line.me/acme' }, - ], - }, - }); - - // Facebook needs fbAppId (absent → hidden); LINE needs no install credential. - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'line', - ]); - }); - - it('hides Meta channels on Chatwoot Cloud during the Meta restriction', () => { - const { displayedChannels } = mountComposable({ - features: { - channel_instagram: true, - whatsapp_embedded_signup_inbox_creation: true, - }, - isOnChatwootCloud: true, - disableMetaInboxCreation: true, - brandInfo: { - socials: [ - { type: 'whatsapp', url: 'https://wa.me/14155552671' }, - { type: 'facebook', url: 'https://facebook.com/acme' }, - { type: 'instagram', url: 'https://instagram.com/acme' }, - { type: 'tiktok', url: 'https://tiktok.com/@acme' }, - ], - }, - }); - - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'tiktok', - ]); - }); - - it('hides Instagram when disabled for the account', () => { - const { displayedChannels } = mountComposable({ - features: { channel_instagram: false }, - isOnChatwootCloud: true, - brandInfo: { - socials: [ - { type: 'instagram', url: 'https://instagram.com/acme' }, - { type: 'tiktok', url: 'https://tiktok.com/@acme' }, - ], - }, - }); - - expect(displayedChannels.value.map(channel => channel.type)).toEqual([ - 'tiktok', - ]); - }); + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'whatsapp', + 'facebook', + 'instagram', + ]); + expect(displayedChannels.value.every(channel => !channel.handle)).toBe( + true + ); + expect(hasDetectedChannels.value).toBe(false); }); - describe('remainingChannels', () => { - it('returns the platforms not already shown as default rows', () => { - // Nothing detected → displayed falls back to the defaults (WhatsApp, - // Facebook, Instagram), so the footer previews the remaining platforms. - const { remainingChannels } = mountComposable({ brandInfo: {} }); + it('keeps credential-free suggestions available on an unconfigured install', () => { + window.chatwootConfig = {}; + const { displayedChannels } = mountComposable(); - expect(remainingChannels.value).toEqual([ - { - type: 'line', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', - inbox: { channel_type: 'Channel::Line' }, - }, - { - type: 'telegram', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE', - inbox: { channel_type: 'Channel::Telegram' }, - }, - { - type: 'tiktok', - labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE', - inbox: { channel_type: 'Channel::Tiktok' }, - }, - ]); - }); - - it('excludes already-detected socials, preserving order', () => { - const { remainingChannels } = mountComposable({ - brandInfo: { - socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], - }, - }); - - expect(remainingChannels.value.map(channel => channel.type)).toEqual([ - 'facebook', - 'line', - 'instagram', - ]); - }); - - it('excludes channels whose installation OAuth credentials are missing', () => { - window.chatwootConfig = {}; // nothing configured - const { remainingChannels } = mountComposable({ brandInfo: {} }); - - // The only configured channels (Telegram, LINE) are shown as default rows, - // and every other platform is gated out — so nothing remains for the footer. - expect(remainingChannels.value).toEqual([]); - }); + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'telegram', + 'line', + ]); }); - describe('connectedInbox', () => { - it('returns the real inbox sharing the channel type', () => { - const inbox = { - id: 1, - channel_type: 'Channel::Whatsapp', - name: 'WA Biz', - }; - const { connectedInbox } = mountComposable({ - brandInfo: {}, - inboxes: [inbox], - }); + it('returns remaining configured channel suggestions in stable order', () => { + const { remainingChannels } = mountComposable(); - expect( - connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } }) - ).toBe(inbox); - }); + expect(remainingChannels.value.map(channel => channel.type)).toEqual([ + 'telegram', + 'line', + 'tiktok', + ]); + }); - it('matches email inboxes on provider', () => { - const gmail = { - id: 1, - channel_type: 'Channel::Email', - provider: 'google', - }; - const outlook = { - id: 2, - channel_type: 'Channel::Email', - provider: 'microsoft', - }; - const { connectedInbox } = mountComposable({ - brandInfo: {}, - inboxes: [outlook, gmail], - }); + it('finds an existing inbox by channel identity', () => { + const inbox = { + id: 1, + channel_type: 'Channel::Whatsapp', + name: 'WA Biz', + }; + const { connectedInbox } = mountComposable({ inboxes: [inbox] }); - expect( - connectedInbox({ - inbox: { channel_type: 'Channel::Email', provider: 'google' }, - }) - ).toBe(gmail); - }); - - it('returns undefined when nothing matches', () => { - const { connectedInbox } = mountComposable({ - brandInfo: {}, - inboxes: [], - }); - - expect( - connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } }) - ).toBeUndefined(); - }); + expect( + connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } }) + ).toBe(inbox); }); }); diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js index c82029801..057017ff2 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -7,7 +7,7 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import AnalyticsHelper from '../helper/AnalyticsHelper'; -const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup']; +const ONBOARDING_STEPS = ['account_details', 'inbox_setup']; const routes = [...dashboard.routes]; const onboardingPath = step => diff --git a/app/javascript/dashboard/routes/index.spec.js b/app/javascript/dashboard/routes/index.spec.js index c60e54418..d4cb23e02 100644 --- a/app/javascript/dashboard/routes/index.spec.js +++ b/app/javascript/dashboard/routes/index.spec.js @@ -104,5 +104,33 @@ describe('#validateAuthenticateRoutePermission', () => { expect(next).toHaveBeenCalledWith(); }); }); + + describe('when the account has a removed enrichment step', () => { + it('does not keep the account in onboarding', async () => { + store.getters.getCurrentUser = { + account_id: 1, + id: 1, + accounts: [ + { + id: 1, + role: 'administrator', + permissions: ['administrator'], + status: 'active', + onboarding_step: 'enrichment', + }, + ], + }; + + const to = { + name: 'onboarding_account_details', + params: { accountId: 1 }, + meta: { permissions: ['administrator'] }, + }; + + await validateAuthenticateRoutePermission(to, next); + + expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard'); + }); + }); }); }); diff --git a/app/jobs/account/branding_enrichment_job.rb b/app/jobs/account/branding_enrichment_job.rb deleted file mode 100644 index 3deb81821..000000000 --- a/app/jobs/account/branding_enrichment_job.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Account::BrandingEnrichmentJob < ApplicationJob - queue_as :low - - def perform(account_id, email) - result = WebsiteBrandingService.new(email).perform - if result.blank? - Rails.logger.info "[BrandingEnrichment] Enrichment failed for account=#{account_id} email=#{email}" - return - end - - account = Account.find(account_id) - account.name = result[:title] if result[:title].present? - account.custom_attributes['brand_info'] = result if account.custom_attributes['brand_info'].blank? - account.save! if account.changed? - ensure - finish_enrichment(account_id) - end - - private - - def finish_enrichment(account_id) - Redis::Alfred.delete(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: account_id)) - - account = Account.find(account_id) - if account.custom_attributes['onboarding_step'] == 'enrichment' - account.custom_attributes['onboarding_step'] = 'account_details' - account.save! - end - - user = account.administrators.first - return unless user - - ActionCableBroadcastJob.perform_later([user.pubsub_token], 'account.enrichment_completed', { account_id: account_id }) - end -end diff --git a/app/models/account.rb b/app/models/account.rb index 20aaced28..74718a6c4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -176,11 +176,7 @@ class Account < ApplicationRecord end def onboarding_step - step = custom_attributes['onboarding_step'] - return nil if step.blank? - - enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id) - Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step + custom_attributes['onboarding_step'] end def reset_cache_keys diff --git a/app/services/website_branding_service.rb b/app/services/website_branding_service.rb deleted file mode 100644 index 178994d1e..000000000 --- a/app/services/website_branding_service.rb +++ /dev/null @@ -1,149 +0,0 @@ -require 'resolv' - -class WebsiteBrandingService - include SocialLinkParser - - attr_reader :http_status - - DATA_DEFAULTS = { description: nil, slogan: nil, phone: nil, address: nil, links: nil, stock: nil, industries: [], is_nsfw: false }.freeze - - def initialize(email) - @email = email - @domain = email.split('@').last&.downcase&.strip - @url = "https://#{@domain}" - @http_status = nil - end - - def perform - doc = fetch_page - return nil if doc.nil? - - links = extract_links(doc) - - DATA_DEFAULTS.merge({ - domain: @domain, - title: extract_title(doc), - colors: extract_colors(doc), - logos: extract_logos(doc), - socials: build_socials(links), - email: @email, - email_provider: detect_email_provider - }) - rescue StandardError => e - Rails.logger.error "[WebsiteBranding] #{e.message}" - nil - end - - private - - def fetch_page - 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(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 - - def extract_title(doc) - og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content') - return og_site_name.strip if og_site_name.present? - - title = doc.at_xpath('//title')&.text - title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first - end - - def extract_colors(doc) - color = doc.at_css('meta[name="theme-color"]')&.[]('content') - return [] if color.blank? - - [{ hex: color, name: nil }] - end - - def extract_logos(doc) - favicon = doc.at_css('link[rel*="icon"]')&.[]('href') - return [] if favicon.blank? - - url = resolve_url(favicon) - return [] if url.blank? - - [{ url: url, type: nil, mode: nil, colors: [], resolution: { aspect_ratio: 1 } }] - end - - def build_socials(links) - handles = extract_social_from_links(links) - handles.filter_map do |platform, handle| - next if handle.blank? - - url = reconstruct_social_url(platform, handle) - { type: platform.to_s, url: url } - end - end - - def reconstruct_social_url(platform, handle) - base_urls = { whatsapp: 'https://wa.me/', line: 'https://line.me/', facebook: 'https://facebook.com/', - instagram: 'https://instagram.com/', telegram: 'https://t.me/', tiktok: 'https://tiktok.com/' } - "#{base_urls[platform]}#{handle}" - end - - def extract_links(doc) - doc.css('a[href]').filter_map do |a| - href = a['href']&.strip - next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:') - - href.start_with?('http') ? href : URI.join(@url, href).to_s - rescue URI::InvalidURIError - nil - end.uniq - end - - def resolve_url(url) - return nil if url.blank? - return url if url.start_with?('http') - - URI.join(@url, url).to_s - rescue URI::InvalidURIError - nil - end - - GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze - MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze - - # Probes the domain's MX records to infer the mailbox provider, returning - # 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown. - def detect_email_provider - hosts = mx_records - return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS) - return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS) - - nil - end - - # Matches on the registrable domain of the MX host (anchored on a label - # boundary) so lookalikes like "notgoogle.com" don't get misclassified. - def mx_hosted_by?(hosts, provider_domains) - hosts.any? do |host| - provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") } - end - end - - def mx_records - Resolv::DNS.open do |resolver| - resolver.timeouts = 5 - resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase } - end - rescue StandardError => e - Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}" - [] - end -end - -WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService') diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index c71f30f10..c92047498 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -99,7 +99,6 @@ module Redis::RedisKeys AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%d'.freeze ## Account Onboarding - ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%d'.freeze HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%s'.freeze ## Account Email Rate Limiting diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index e5d77b009..1333440b9 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -31,6 +31,22 @@ RSpec.describe 'Accounts API', type: :request do end end + it 'completes signup without triggering remote enrichment' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + allow(GlobalConfigService).to receive(:account_signup_enabled?).and_return(true) + allow(account_builder).to receive(:perform).and_return([user, account]) + captcha = instance_double(ChatwootCaptcha, valid?: true) + allow(ChatwootCaptcha).to receive(:new).and_return(captcha) + expect(Redis::Alfred).not_to receive(:set) + + post api_v1_accounts_url, + params: { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }, + as: :json + + expect(response).to have_http_status(:success) + end + end + it 'calls ChatwootCaptcha' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do captcha = double diff --git a/spec/services/website_branding_service_spec.rb b/spec/services/website_branding_service_spec.rb deleted file mode 100644 index e90da4c64..000000000 --- a/spec/services/website_branding_service_spec.rb +++ /dev/null @@ -1,151 +0,0 @@ -require 'rails_helper' - -RSpec.describe WebsiteBrandingService do - describe '#perform' do - let(:email) { 'user@example.com' } - let(:url) { 'https://example.com' } - let(:html_body) do - <<~HTML - - - Acme Corp | Home - - - - - - - - -
- Facebook - Instagram -
- - - - - HTML - end - - before do - stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' }) - end - - it 'extracts basic brand info' do - result = described_class.new(email).perform - - expect(result).to include(domain: 'example.com', title: 'Acme Corp', email: email, - description: nil, slogan: nil, is_nsfw: false, industries: []) - end - - it 'extracts colors, logos, and socials' do - result = described_class.new(email).perform - - expect(result[:colors]).to eq([{ hex: '#FF5733', name: nil }]) - expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico') - expect(result[:socials].map { |s| s[:type] }).to contain_exactly('facebook', 'instagram', 'whatsapp', 'telegram', 'tiktok') - end - - context 'when og:site_name is missing' do - let(:html_body) do - <<~HTML - - Mon Entreprise - Bienvenue - - - HTML - end - - it 'falls back to the first segment of the title' do - result = described_class.new(email).perform - expect(result[:title]).to eq('Mon Entreprise') - end - end - - context 'when the page fails to load' do - before { stub_request(:get, url).to_return(status: 500, body: '') } - - it 'returns nil and sets http_status' do - service = described_class.new(email) - expect(service.perform).to be_nil - expect(service.http_status).to eq(500) - end - end - - context 'when a network error occurs' do - before { stub_request(:get, url).to_raise(StandardError.new('connection refused')) } - - it 'logs the error and returns nil' do - expect(Rails.logger).to receive(:error).with(/connection refused/) - expect(described_class.new(email).perform).to be_nil - end - end - - context 'when WhatsApp link uses api.whatsapp.com format' do - let(:html_body) do - <<~HTML - - Test - Chat - - HTML - end - - it 'extracts phone from query param' do - result = described_class.new(email).perform - whatsapp = result[:socials].find { |s| s[:type] == 'whatsapp' } - expect(whatsapp[:url]).to eq('https://wa.me/5511999999999') - end - end - - context 'when links contain lookalike domains' do - let(:html_body) do - <<~HTML - - Test - - Not FB - Not IG - - - HTML - end - - it 'does not match lookalike domains' do - result = described_class.new(email).perform - types = result[:socials].map { |s| s[:type] } - expect(types).not_to include('facebook') - expect(types).not_to include('instagram') - end - end - - context 'when favicon uses a relative path without leading slash' do - let(:html_body) do - <<~HTML - - - Test - - - - - HTML - end - - it 'resolves the relative favicon URL' do - result = described_class.new(email).perform - expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico') - end - end - end -end