feat: Add support for bulk action for Captain FAQs (#10905)

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Sivin Varghese
2025-02-28 06:35:33 +05:30
committed by GitHub
parent a8febc00d3
commit 6eecd84b22
17 changed files with 680 additions and 31 deletions

View File

@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainBulkActionsAPI extends ApiClient {
constructor() {
super('captain/bulk_actions', { accountScoped: true });
}
}
export default new CaptainBulkActionsAPI();

View File

@@ -4,6 +4,10 @@ defineProps({
type: String,
default: 'col',
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
@@ -18,10 +22,11 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick"
>
<slot />

View File

@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
@@ -46,14 +47,27 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate']);
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const statusAction = computed(() => {
if (props.status === 'pending') {
return [
@@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
</script>
<template>
<CardLayout :class="{ 'rounded-md': compact }">
<div class="flex justify-between w-full gap-1">
<CardLayout
selectable
class="relative"
:class="{ 'rounded-md': compact }"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
@@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
>
<i class="i-ph-chat-circle-dots text-base" />
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
{{ documentable.name }}
</span>

View File

@@ -0,0 +1,59 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
type: {
type: String,
required: true,
},
bulkIds: {
type: Object,
required: true,
},
});
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.ERROR_MESSAGE`));
}
};
const handleDialogConfirm = async () => {
await handleBulkDelete(Array.from(props.bulkIds));
bulkDeleteDialogRef.value?.close();
};
defineExpose({ dialogRef: bulkDeleteDialogRef });
</script>
<template>
<Dialog
ref="bulkDeleteDialogRef"
type="alert"
:title="t(`CAPTAIN.${i18nKey}.BULK_DELETE.TITLE`)"
:description="t(`CAPTAIN.${i18nKey}.BULK_DELETE.DESCRIPTION`)"
:confirm-button-label="t(`CAPTAIN.${i18nKey}.BULK_DELETE.CONFIRM`)"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import Checkbox from './Checkbox.vue';
import { ref } from 'vue';
const defaultValue = ref(false);
const isChecked = ref(false);
const checkedValue = ref(true);
const indeterminateValue = ref(true);
</script>
<template>
<Story title="Components/Checkbox" :layout="{ type: 'grid', width: '250px' }">
<Variant title="States">
<div class="p-2 space-y-4">
<div class="flex items-center justify-between gap-4">
<span>Default:</span>
<Checkbox v-model="defaultValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Checked:</span>
<Checkbox v-model="checkedValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate:</span>
<Checkbox v-model="indeterminateValue" indeterminate />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate disabled:</span>
<Checkbox v-model="indeterminateValue" indeterminate disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled:</span>
<Checkbox v-model="defaultValue" disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled Checked:</span>
<Checkbox v-model="isChecked" disabled />
</div>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
defineProps({
indeterminate: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel('modelValue', {
type: Boolean,
default: false,
});
const handleChange = event => {
modelValue.value = event.target.checked;
emit('change', event);
};
</script>
<template>
<div class="relative w-4 h-4">
<input
:checked="modelValue"
:indeterminate="indeterminate"
type="checkbox"
:disabled="disabled"
class="peer absolute inset-0 z-10 h-4 w-4 disabled:opacity-50 appearance-none rounded border border-n-slate-6 ring-transparent transition-all duration-200 checked:border-n-brand checked:bg-n-brand dark:border-gray-600 dark:checked:border-n-brand indeterminate:border-n-brand indeterminate:bg-n-brand hover:enabled:bg-n-blue-border cursor-pointer"
@change="handleChange"
/>
<!-- Checkmark SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- Minus/Indeterminate SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-indeterminate:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 7L11 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>

View File

@@ -437,6 +437,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",

View File

@@ -8,8 +8,10 @@ import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
@@ -28,6 +30,7 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedStatus = ref('all');
const selectedAssistant = ref('all');
@@ -129,7 +132,69 @@ const fetchResponses = (page = 1) => {
store.dispatch('captainResponses/get', filterParams);
};
const onPageChange = page => fetchResponses(page);
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = responses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
allSelected: totalCount > 0 && selectedCount === totalCount,
};
});
const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(responses.value.map(r => r.id))
: new Set();
},
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
// Clear selection
bulkSelectedIds.value = new Set();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
// Store current selection state before fetching new page
const wasAllPageSelected = bulkSelectionState.value.allSelected;
const hadPartialSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
// Reset selection if we had any selections on page change
if (wasAllPageSelected || hadPartialSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
@@ -137,6 +202,20 @@ const onDeleteSuccess = () => {
}
};
const onBulkDeleteSuccess = () => {
// Only fetch if no records left
if (responses.value?.length === 0) {
const page =
responseMeta.value?.page > 1
? responseMeta.value.page - 1
: responseMeta.value.page;
fetchResponses(page);
}
// Clear selection
bulkSelectedIds.value = new Set();
};
const handleStatusFilterChange = ({ value }) => {
selectedStatus.value = value;
isStatusFilterOpen.value = false;
@@ -177,29 +256,76 @@ onMounted(() => {
</template>
<template #controls>
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
>
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.size,
})
}}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-2">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
slate
@click="handleBulkApprove"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
slate
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
</div>
</transition>
</div>
</template>
@@ -218,8 +344,12 @@ onMounted(() => {
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
@@ -232,6 +362,14 @@ onMounted(() => {
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"

View File

@@ -0,0 +1,56 @@
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
import { createStore } from './storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainBulkAction',
API: CaptainBulkActionsAPI,
actions: mutations => ({
processBulkAction: async function processBulkAction(
{ commit },
{ type, actionType, ids }
) {
commit(mutations.SET_UI_FLAG, { isUpdating: true });
try {
const response = await CaptainBulkActionsAPI.create({
type: type,
ids,
fields: { status: actionType },
});
commit(mutations.SET_UI_FLAG, { isUpdating: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { isUpdating: false });
return throwErrorMessage(error);
}
},
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
actionType: 'delete',
ids,
});
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
return response;
},
handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
actionType: 'approve',
ids,
});
// Update response store after successful API call
await dispatch('captainResponses/updateBulkResponses', response, {
root: true,
});
return response;
},
}),
});

View File

@@ -4,4 +4,29 @@ import { createStore } from './storeFactory';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
actions: mutations => ({
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, updatedRecords);
},
updateBulkResponses: ({ commit, state }, approvedResponses) => {
// Create a map of updated responses for faster lookup
const updatedResponsesMap = approvedResponses.reduce((map, response) => {
map[response.id] = response;
return map;
}, {});
// Update existing records with updated data
const updatedRecords = state.records.map(record => {
if (updatedResponsesMap[record.id]) {
return updatedResponsesMap[record.id]; // Replace with the updated response
}
return record;
});
commit(mutations.SET, updatedRecords);
},
}),
});

View File

@@ -50,6 +50,7 @@ import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
const plugins = [];
export default createStore({
@@ -104,6 +105,7 @@ export default createStore({
captainDocuments,
captainResponses,
captainInboxes,
captainBulkActions,
},
plugins,
});

View File

@@ -54,6 +54,7 @@ Rails.application.routes.draw do
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :assistant_responses
resources :bulk_actions, only: [:create]
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member

View File

@@ -0,0 +1,51 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze
def create
@responses = process_bulk_action
end
private
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
end
def process_bulk_action
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
end
end
def handle_assistant_responses
responses = Current.account.captain_assistant_responses.where(id: params[:ids])
return unless responses.exists?
case params[:fields][:status]
when 'approve'
responses.pending.update(status: 'approved')
responses
when 'delete'
responses.destroy_all
[]
end
end
def permitted_params
params.permit(:type, ids: [], fields: [:status])
end
end

View File

@@ -1,7 +1,7 @@
class Captain::Tools::FirecrawlService
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.nil?
raise 'Missing API key' if @api_key.empty?
end
def perform(url, webhook_url, crawl_limit = 10)

View File

@@ -0,0 +1,3 @@
json.array! @responses do |response|
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: response
end

View File

@@ -0,0 +1,143 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:pending_responses) do
create_list(
:captain_assistant_response,
2,
assistant: assistant,
account: account,
status: 'pending'
)
end
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do
context 'when approving responses' do
let(:valid_params) do
{
type: 'AssistantResponse',
ids: pending_responses.map(&:id),
fields: { status: 'approve' }
}
end
it 'approves the responses and returns the updated records' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(2)
# Verify responses were approved
pending_responses.each do |response|
expect(response.reload.status).to eq('approved')
end
end
end
context 'when deleting responses' do
let(:delete_params) do
{
type: 'AssistantResponse',
ids: pending_responses.map(&:id),
fields: { status: 'delete' }
}
end
it 'deletes the responses and returns an empty array' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: delete_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-2)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
# Verify responses were deleted
pending_responses.each do |response|
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
context 'with invalid type' do
let(:invalid_params) do
{
type: 'InvalidType',
ids: pending_responses.map(&:id),
fields: { status: 'approve' }
}
end
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:success]).to be(false)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
end
end
context 'with missing parameters' do
let(:missing_params) do
{
type: 'AssistantResponse',
fields: { status: 'approve' }
}
end
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: missing_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:success]).to be(false)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
end
end
context 'with unauthorized user' do
let(:unauthorized_user) { create(:user, account: create(:account)) }
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } },
headers: unauthorized_user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
end
end
end
end

View File

@@ -32,6 +32,16 @@ RSpec.describe Captain::Tools::FirecrawlService do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil)
end
it 'raises an error' do
expect { described_class.new }.to raise_error(NoMethodError)
end
end
context 'when API key is empty' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '')
end
it 'raises an error' do
expect { described_class.new }.to raise_error('Missing API key')
end