Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef19fcd8c0 | ||
|
|
d261dfd394 | ||
|
|
1b9673b272 | ||
|
|
45fa697885 | ||
|
|
51fbf583b6 | ||
|
|
38d6ee6dd2 | ||
|
|
a547c28c8d | ||
|
|
5608f5a1a2 | ||
|
|
da4110a495 | ||
|
|
9a2136caf1 | ||
|
|
a44192bbe7 | ||
|
|
5a5b30fe1e | ||
|
|
08b9134486 | ||
|
|
7874a6a3dd |
+196
-49
@@ -3,6 +3,7 @@ orbs:
|
|||||||
node: circleci/node@6.1.0
|
node: circleci/node@6.1.0
|
||||||
qlty-orb: qltysh/qlty-orb@0.0
|
qlty-orb: qltysh/qlty-orb@0.0
|
||||||
|
|
||||||
|
# Shared defaults for setup steps
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
machine:
|
||||||
@@ -12,10 +13,106 @@ defaults: &defaults
|
|||||||
RAILS_LOG_TO_STDOUT: false
|
RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
LOG_LEVEL: warn
|
||||||
parallelism: 4
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Separate job for linting (no parallelism needed)
|
||||||
|
lint:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
|
||||||
|
# Install minimal system dependencies for linting
|
||||||
|
- run:
|
||||||
|
name: Install System Dependencies
|
||||||
|
command: |
|
||||||
|
sudo apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libyaml-dev \
|
||||||
|
openjdk-11-jdk \
|
||||||
|
jq \
|
||||||
|
software-properties-common \
|
||||||
|
ca-certificates \
|
||||||
|
imagemagick \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
file \
|
||||||
|
g++ \
|
||||||
|
gcc \
|
||||||
|
autoconf \
|
||||||
|
gnupg2 \
|
||||||
|
patch \
|
||||||
|
ruby-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libgmp-dev \
|
||||||
|
libncurses5-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libgdbm6 \
|
||||||
|
libgdbm-dev \
|
||||||
|
libvips
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install RVM and Ruby 3.4.4
|
||||||
|
command: |
|
||||||
|
sudo apt-get install -y gpg
|
||||||
|
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||||
|
\curl -sSL https://get.rvm.io | bash -s stable
|
||||||
|
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
rvm install "3.4.4"
|
||||||
|
rvm use 3.4.4 --default
|
||||||
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install Application Dependencies
|
||||||
|
command: |
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
|
# Swagger verification
|
||||||
|
- run:
|
||||||
|
name: Verify swagger API specification
|
||||||
|
command: |
|
||||||
|
bundle exec rake swagger:build
|
||||||
|
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||||
|
then
|
||||||
|
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p ~/tmp
|
||||||
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
|
# Bundle audit
|
||||||
|
- run:
|
||||||
|
name: Bundle audit
|
||||||
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
|
# Rubocop linting
|
||||||
|
- run:
|
||||||
|
name: Rubocop
|
||||||
|
command: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
# ESLint linting
|
||||||
|
- run:
|
||||||
|
name: eslint
|
||||||
|
command: pnpm run eslint
|
||||||
|
|
||||||
|
# Separate job for frontend tests
|
||||||
|
frontend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@@ -25,8 +122,38 @@ jobs:
|
|||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
- run: node --version
|
|
||||||
- run: pnpm --version
|
- run:
|
||||||
|
name: Run frontend tests (with coverage)
|
||||||
|
command: pnpm run test:coverage
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Move coverage files if they exist
|
||||||
|
command: |
|
||||||
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/frontend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: ~/build
|
||||||
|
paths:
|
||||||
|
- coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
<<: *defaults
|
||||||
|
parallelism: 16
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Add PostgreSQL repository and update
|
name: Add PostgreSQL repository and update
|
||||||
command: |
|
command: |
|
||||||
@@ -91,20 +218,6 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
# Swagger verification
|
|
||||||
- run:
|
|
||||||
name: Verify swagger API specification
|
|
||||||
command: |
|
|
||||||
bundle exec rake swagger:build
|
|
||||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
|
||||||
then
|
|
||||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p ~/tmp
|
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
|
||||||
|
|
||||||
# Configure environment and database
|
# Configure environment and database
|
||||||
- run:
|
- run:
|
||||||
name: Database Setup and Configure Environment Variables
|
name: Database Setup and Configure Environment Variables
|
||||||
@@ -127,57 +240,91 @@ jobs:
|
|||||||
name: Run DB migrations
|
name: Run DB migrations
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
command: bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
# Bundle audit
|
# Run backend tests (parallelized)
|
||||||
- run:
|
|
||||||
name: Bundle audit
|
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
|
||||||
name: Rubocop
|
|
||||||
command: bundle exec rubocop
|
|
||||||
|
|
||||||
# ESLint linting
|
|
||||||
- run:
|
|
||||||
name: eslint
|
|
||||||
command: pnpm run eslint
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Run frontend tests (with coverage)
|
|
||||||
command: |
|
|
||||||
mkdir -p ~/build/coverage/frontend
|
|
||||||
pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run backend tests
|
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p ~/build/coverage/backend
|
||||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
|
||||||
|
# Use round-robin distribution (same as GitHub Actions) for better test isolation
|
||||||
|
# This prevents tests with similar timing from being grouped on the same runner
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||||
--format RspecJunitFormatter \
|
--format RspecJunitFormatter \
|
||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- ${TESTFILES}
|
-- $TESTS
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
# Qlty coverage publish
|
# Store test results for better splitting in future runs
|
||||||
- qlty-orb/coverage_publish:
|
- store_test_results:
|
||||||
files: |
|
path: ~/tmp/test-results
|
||||||
coverage/coverage.json
|
|
||||||
coverage/lcov.info
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: List coverage directory contents
|
name: Move coverage files if they exist
|
||||||
command: |
|
command: |
|
||||||
ls -R ~/build/coverage
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/backend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: ~/build
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- coverage
|
||||||
|
|
||||||
|
# Collect coverage from all jobs
|
||||||
|
coverage:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- attach_workspace:
|
||||||
|
at: ~/build
|
||||||
|
|
||||||
|
# Qlty coverage publish
|
||||||
|
- qlty-orb/coverage_publish:
|
||||||
|
files: |
|
||||||
|
coverage/frontend/lcov.info
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: List coverage directory contents
|
||||||
|
command: |
|
||||||
|
ls -R ~/build/coverage || echo "No coverage directory"
|
||||||
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage
|
path: coverage
|
||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
|
build:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- run:
|
||||||
|
name: Legacy build aggregator
|
||||||
|
command: |
|
||||||
|
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
build:
|
||||||
|
jobs:
|
||||||
|
- lint
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- coverage:
|
||||||
|
requires:
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- build:
|
||||||
|
requires:
|
||||||
|
- lint
|
||||||
|
- coverage
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
name: Run Chatwoot CE spec
|
name: Run Chatwoot CE spec
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -8,11 +10,58 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
# Separate linting jobs for faster feedback
|
||||||
runs-on: ubuntu-22.04
|
lint-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
- name: Run Rubocop
|
||||||
|
run: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run ESLint
|
||||||
|
run: pnpm run eslint
|
||||||
|
|
||||||
|
# Frontend tests run in parallel with backend
|
||||||
|
frontend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: pnpm run test:coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
ci_node_total: [16]
|
||||||
|
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg15
|
image: pgvector/pgvector:pg16
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ''
|
||||||
@@ -20,8 +69,6 @@ jobs:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
# needed because the postgres container does not provide a healthcheck
|
|
||||||
# tmpfs makes DB faster by using RAM
|
|
||||||
options: >-
|
options: >-
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -29,7 +76,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis:alpine
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
@@ -43,7 +90,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -64,19 +111,36 @@ jobs:
|
|||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run backend tests (parallelized)
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run rails tests
|
|
||||||
- name: Run backend tests
|
|
||||||
run: |
|
run: |
|
||||||
bundle exec rspec --profile=10 --format documentation
|
# Get all spec files and split them using round-robin distribution
|
||||||
|
# This ensures slow tests are distributed evenly across all nodes
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
# Assign spec to this node if: index % total == node_index
|
||||||
|
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$TESTS" ]; then
|
||||||
|
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||||
|
fi
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
- name: Upload rails log folder
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: rails-log-folder
|
name: rspec-results-${{ matrix.ci_node_index }}
|
||||||
|
path: tmp/rspec_results.json
|
||||||
|
|
||||||
|
- name: Upload rails log folder
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||||
path: log
|
path: log
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.4.0
|
4.8.0
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const formattedUpdatedAt = computed(() => {
|
|||||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
>
|
>
|
||||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||||
{{ t('COMPANIES.CONTACTS_COUNT', { count: contactsCount }) }}
|
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ defineProps({
|
|||||||
totalItems: { type: Number, default: 100 },
|
totalItems: { type: Number, default: 100 },
|
||||||
activeSort: { type: String, default: 'name' },
|
activeSort: { type: String, default: 'name' },
|
||||||
activeOrdering: { type: String, default: '' },
|
activeOrdering: { type: String, default: '' },
|
||||||
|
showPaginationFooter: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||||
@@ -36,7 +37,7 @@ const updateCurrentPage = page => {
|
|||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer class="sticky bottom-0 z-0 px-4 pb-4">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
@@ -11,7 +12,6 @@ import { downloadFile } from '@chatwoot/utils';
|
|||||||
|
|
||||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['error']);
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||||
@@ -20,14 +20,16 @@ const attachment = computed(() => {
|
|||||||
return attachments.value[0];
|
return attachments.value[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasError = ref(false);
|
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
||||||
|
|
||||||
const showGallery = ref(false);
|
const showGallery = ref(false);
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
|
|
||||||
const handleError = () => {
|
onMounted(() => {
|
||||||
hasError.value = true;
|
if (attachment.value?.dataUrl) {
|
||||||
emit('error');
|
loadWithRetry(attachment.value.dataUrl);
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const downloadAttachment = async () => {
|
const downloadAttachment = async () => {
|
||||||
const { fileType, dataUrl, extension } = attachment.value;
|
const { fileType, dataUrl, extension } = attachment.value;
|
||||||
@@ -40,6 +42,10 @@ const downloadAttachment = async () => {
|
|||||||
isDownloading.value = false;
|
isDownloading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
hasError.value = true;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -54,14 +60,12 @@ const downloadAttachment = async () => {
|
|||||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
class="skip-context-menu"
|
class="skip-context-menu"
|
||||||
:src="attachment.dataUrl"
|
:src="attachment.dataUrl"
|
||||||
:width="attachment.width"
|
:width="attachment.width"
|
||||||
:height="attachment.height"
|
:height="attachment.height"
|
||||||
@click="onClick"
|
|
||||||
@error="handleError"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||||
@@ -86,7 +90,7 @@ const downloadAttachment = async () => {
|
|||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
@error="handleError"
|
@error="handleImageError"
|
||||||
@close="() => (showGallery = false)"
|
@close="() => (showGallery = false)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const useLoadWithRetry = (config = {}) => {
|
||||||
|
const maxRetry = config.max_retry || 3;
|
||||||
|
const backoff = config.backoff || 1000;
|
||||||
|
|
||||||
|
const isLoaded = ref(false);
|
||||||
|
const hasError = ref(false);
|
||||||
|
|
||||||
|
const loadWithRetry = async url => {
|
||||||
|
const attemptLoad = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
isLoaded.value = true;
|
||||||
|
hasError.value = false;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = ms => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = async (attempt = 0) => {
|
||||||
|
try {
|
||||||
|
await attemptLoad();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt + 1 >= maxRetry) {
|
||||||
|
hasError.value = true;
|
||||||
|
isLoaded.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(backoff * (attempt + 1));
|
||||||
|
await retry(attempt + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await retry();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoaded,
|
||||||
|
hasError,
|
||||||
|
loadWithRetry,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
"SEARCH_PLACEHOLDER": "Search companies...",
|
||||||
"LOADING": "Loading companies...",
|
"LOADING": "Loading companies...",
|
||||||
"UNNAMED": "Unnamed Company",
|
"UNNAMED": "Unnamed Company",
|
||||||
"CONTACTS_COUNT": "{count} contacts",
|
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||||
"EMPTY_STATE": {
|
"EMPTY_STATE": {
|
||||||
"TITLE": "No companies found"
|
"TITLE": "No companies found"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, reactive } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
|
||||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||||
|
|
||||||
|
const DEFAULT_SORT_FIELD = 'created_at';
|
||||||
const DEBOUNCE_DELAY = 300;
|
const DEBOUNCE_DELAY = 300;
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -16,19 +18,33 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { updateUISettings, uiSettings } = useUISettings();
|
||||||
|
|
||||||
const searchQuery = computed(() => route.query?.search || '');
|
const searchQuery = computed(() => route.query?.search || '');
|
||||||
const searchValue = ref(searchQuery.value);
|
const searchValue = ref(searchQuery.value);
|
||||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||||
|
|
||||||
const activeSort = computed(() => {
|
const parseSortSettings = (sortString = '') => {
|
||||||
const sortParam = route.query?.sort || 'name';
|
const hasDescending = sortString.startsWith('-');
|
||||||
return sortParam.startsWith('-') ? sortParam.slice(1) : sortParam;
|
const sortField = hasDescending ? sortString.slice(1) : sortString;
|
||||||
|
return {
|
||||||
|
sort: sortField || DEFAULT_SORT_FIELD,
|
||||||
|
order: hasDescending ? '-' : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { companies_sort_by: companySortBy = `-${DEFAULT_SORT_FIELD}` } =
|
||||||
|
uiSettings.value ?? {};
|
||||||
|
const { sort: initialSort, order: initialOrder } =
|
||||||
|
parseSortSettings(companySortBy);
|
||||||
|
|
||||||
|
const sortState = reactive({
|
||||||
|
activeSort: initialSort,
|
||||||
|
activeOrdering: initialOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeOrdering = computed(() => {
|
const activeSort = computed(() => sortState.activeSort);
|
||||||
const sortParam = route.query?.sort || 'name';
|
const activeOrdering = computed(() => sortState.activeOrdering);
|
||||||
return sortParam.startsWith('-') ? '-' : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const companies = useMapGetter('companies/getCompaniesList');
|
const companies = useMapGetter('companies/getCompaniesList');
|
||||||
const meta = useMapGetter('companies/getMeta');
|
const meta = useMapGetter('companies/getMeta');
|
||||||
@@ -36,11 +52,10 @@ const uiFlags = useMapGetter('companies/getUIFlags');
|
|||||||
|
|
||||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||||
|
|
||||||
const sortParam = computed(() => {
|
const buildSortAttr = () =>
|
||||||
return activeOrdering.value === '-'
|
`${sortState.activeOrdering}${sortState.activeSort}`;
|
||||||
? `-${activeSort.value}`
|
|
||||||
: activeSort.value;
|
const sortParam = computed(() => buildSortAttr());
|
||||||
});
|
|
||||||
|
|
||||||
const updateURLParams = (page, search = '', sort = '') => {
|
const updateURLParams = (page, search = '', sort = '') => {
|
||||||
const query = {
|
const query = {
|
||||||
@@ -96,9 +111,14 @@ const onPageChange = page => {
|
|||||||
fetchCompanies(page, searchValue.value, sortParam.value);
|
fetchCompanies(page, searchValue.value, sortParam.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = ({ sort, order }) => {
|
const handleSort = async ({ sort, order }) => {
|
||||||
const newSortParam = order === '-' ? `-${sort}` : sort;
|
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||||
fetchCompanies(1, searchValue.value, newSortParam);
|
|
||||||
|
await updateUISettings({
|
||||||
|
companies_sort_by: buildSortAttr(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchCompanies(1, searchValue.value, buildSortAttr());
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -116,6 +136,7 @@ onMounted(() => {
|
|||||||
:active-sort="activeSort"
|
:active-sort="activeSort"
|
||||||
:active-ordering="activeOrdering"
|
:active-ordering="activeOrdering"
|
||||||
:is-fetching-list="isFetchingList"
|
:is-fetching-list="isFetchingList"
|
||||||
|
:show-pagination-footer="!!companies.length"
|
||||||
@update:current-page="onPageChange"
|
@update:current-page="onPageChange"
|
||||||
@update:sort="handleSort"
|
@update:sort="handleSort"
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, toRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||||
@@ -30,13 +30,15 @@ const { t } = useI18n();
|
|||||||
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
|
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
|
||||||
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
|
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
|
||||||
|
|
||||||
|
// Pass toRef(props, 'agents') instead of props.agents to maintain reactivity
|
||||||
|
// when the parent component's agents prop updates (e.g., after API response)
|
||||||
const {
|
const {
|
||||||
currentTime,
|
currentTime,
|
||||||
hasOnlineAgents,
|
hasOnlineAgents,
|
||||||
isOnline,
|
isOnline,
|
||||||
inboxConfig,
|
inboxConfig,
|
||||||
isInWorkingHours,
|
isInWorkingHours,
|
||||||
} = useAvailability(props.agents);
|
} = useAvailability(toRef(props, 'agents'));
|
||||||
|
|
||||||
const workingHours = computed(() => inboxConfig.value.workingHours || []);
|
const workingHours = computed(() => inboxConfig.value.workingHours || []);
|
||||||
const workingHoursEnabled = computed(
|
const workingHoursEnabled = computed(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, toRef } from 'vue';
|
import { computed, unref } from 'vue';
|
||||||
import {
|
import {
|
||||||
isOnline as checkIsOnline,
|
isOnline as checkIsOnline,
|
||||||
isInWorkingHours as checkInWorkingHours,
|
isInWorkingHours as checkInWorkingHours,
|
||||||
@@ -14,7 +14,11 @@ const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
|
|||||||
* @returns {Object} Availability utilities and computed properties
|
* @returns {Object} Availability utilities and computed properties
|
||||||
*/
|
*/
|
||||||
export function useAvailability(agents = []) {
|
export function useAvailability(agents = []) {
|
||||||
const availableAgents = toRef(agents);
|
// Now receives toRef(props, 'agents') from caller, which maintains reactivity.
|
||||||
|
// Use unref() inside computed to unwrap the ref value properly.
|
||||||
|
// This ensures availableAgents updates when the parent's agents prop changes
|
||||||
|
// (e.g., after API response updates the Vuex store).
|
||||||
|
const availableAgents = computed(() => unref(agents));
|
||||||
|
|
||||||
const channelConfig = computed(() => window.chatwootWebChannel || {});
|
const channelConfig = computed(() => window.chatwootWebChannel || {});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
# conversation_id :integer not null
|
# conversation_id :integer not null
|
||||||
# inbox_id :integer not null
|
# inbox_id :integer not null
|
||||||
# sender_id :bigint
|
# sender_id :bigint
|
||||||
# source_id :string
|
# source_id :text
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -180,7 +180,6 @@
|
|||||||
display_name: Captain V2
|
display_name: Captain V2
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
chatwoot_internal: true
|
|
||||||
- name: whatsapp_embedded_signup
|
- name: whatsapp_embedded_signup
|
||||||
display_name: WhatsApp Embedded Signup
|
display_name: WhatsApp Embedded Signup
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class ChangeMessagesSourceIdToText < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
change_column :messages, :source_id, :text
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
change_column :messages, :source_id, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
|
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -954,7 +954,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
|
|||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.boolean "private", default: false, null: false
|
t.boolean "private", default: false, null: false
|
||||||
t.integer "status", default: 0
|
t.integer "status", default: 0
|
||||||
t.string "source_id"
|
t.text "source_id"
|
||||||
t.integer "content_type", default: 0, null: false
|
t.integer "content_type", default: 0, null: false
|
||||||
t.json "content_attributes", default: {}
|
t.json "content_attributes", default: {}
|
||||||
t.string "sender_type"
|
t.string "sender_type"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ KillMode=mixed
|
|||||||
StandardInput=null
|
StandardInput=null
|
||||||
SyslogIdentifier=%p
|
SyslogIdentifier=%p
|
||||||
|
|
||||||
MemoryMax=1.5G
|
MemoryMax=1.2G
|
||||||
MemoryHigh=infinity
|
MemoryHigh=infinity
|
||||||
MemorySwapMax=0
|
MemorySwapMax=0
|
||||||
OOMPolicy=stop
|
OOMPolicy=stop
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ describe V2::ReportBuilder do
|
|||||||
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
|
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
|
||||||
|
|
||||||
describe '#timeseries' do
|
describe '#timeseries' do
|
||||||
before do
|
# Use before_all to share expensive setup across all tests in this describe block
|
||||||
|
# This runs once instead of 21 times, dramatically speeding up the suite
|
||||||
|
before_all do
|
||||||
travel_to(Time.zone.today) do
|
travel_to(Time.zone.today) do
|
||||||
user = create(:user, account: account)
|
user = create(:user, account: account)
|
||||||
inbox = create(:inbox, account: account)
|
inbox = create(:inbox, account: account)
|
||||||
|
|||||||
@@ -94,10 +94,8 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
|
|
||||||
context 'when state parameter is invalid' do
|
context 'when state parameter is invalid' do
|
||||||
before do
|
before do
|
||||||
controller = described_class.new
|
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
|
||||||
allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
|
allow_any_instance_of(Shopify::CallbacksController).to receive(:account).and_return(nil)
|
||||||
allow(controller).to receive(:account).and_return(nil)
|
|
||||||
allow(described_class).to receive(:new).and_return(controller)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'redirects to the frontend URL with error' do
|
it 'redirects to the frontend URL with error' do
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ RSpec.describe Captain::Scenario, type: :model do
|
|||||||
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
|
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
|
||||||
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
|
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
|
||||||
|
|
||||||
expect(described_class.enabled).to include(enabled_scenario)
|
expect(described_class.enabled.pluck(:id)).to include(enabled_scenario.id)
|
||||||
expect(described_class.enabled).not_to include(disabled_scenario)
|
expect(described_class.enabled.pluck(:id)).not_to include(disabled_scenario.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ RSpec.describe DeleteObjectJob, type: :job do
|
|||||||
|
|
||||||
described_class.perform_now(inbox)
|
described_class.perform_now(inbox)
|
||||||
|
|
||||||
expect(Conversation.where(id: conv_ids)).to be_empty
|
# Reload associations to ensure database state is current
|
||||||
expect(ContactInbox.where(id: ci_ids)).to be_empty
|
expect(Conversation.where(id: conv_ids).reload).to be_empty
|
||||||
expect(ReportingEvent.where(id: re_ids)).to be_empty
|
expect(ContactInbox.where(id: ci_ids).reload).to be_empty
|
||||||
|
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
|
||||||
# Contacts should not be deleted for inbox destroy
|
# Contacts should not be deleted for inbox destroy
|
||||||
expect(Contact.where(id: contact_ids)).not_to be_empty
|
expect(Contact.where(id: contact_ids).reload).not_to be_empty
|
||||||
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -53,10 +54,11 @@ RSpec.describe DeleteObjectJob, type: :job do
|
|||||||
|
|
||||||
described_class.perform_now(account)
|
described_class.perform_now(account)
|
||||||
|
|
||||||
expect(Conversation.where(id: conv_ids)).to be_empty
|
# Reload associations to ensure database state is current
|
||||||
expect(Contact.where(id: contact_ids)).to be_empty
|
expect(Conversation.where(id: conv_ids).reload).to be_empty
|
||||||
expect(Inbox.where(id: inbox_ids)).to be_empty
|
expect(Contact.where(id: contact_ids).reload).to be_empty
|
||||||
expect(ReportingEvent.where(id: re_ids)).to be_empty
|
expect(Inbox.where(id: inbox_ids).reload).to be_empty
|
||||||
|
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
|
||||||
expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ RSpec.describe MutexApplicationJob do
|
|||||||
# Do nothing
|
# Do nothing
|
||||||
end
|
end
|
||||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||||
expect(lock_manager).not_to receive(:unlock)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises StandardError if it execution raises it' do
|
it 'raises StandardError if it execution raises it' do
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ RSpec.describe AdministratorNotifications::AccountNotificationMailer do
|
|||||||
|
|
||||||
describe '#format_deletion_date' do
|
describe '#format_deletion_date' do
|
||||||
it 'formats a valid date string' do
|
it 'formats a valid date string' do
|
||||||
date_str = '2024-12-31T23:59:59Z'
|
date_str = '2024-12-31T12:00:00Z'
|
||||||
formatted = described_class.new.send(:format_deletion_date, date_str)
|
formatted = described_class.new.send(:format_deletion_date, date_str)
|
||||||
expect(formatted).to eq('December 31, 2024')
|
expect(formatted).to eq('December 31, 2024')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -203,12 +203,12 @@ RSpec.describe Account do
|
|||||||
context 'when using with_auto_resolve scope' do
|
context 'when using with_auto_resolve scope' do
|
||||||
it 'finds accounts with auto_resolve_after set' do
|
it 'finds accounts with auto_resolve_after set' do
|
||||||
account.update(auto_resolve_after: 40 * 24 * 60)
|
account.update(auto_resolve_after: 40 * 24 * 60)
|
||||||
expect(described_class.with_auto_resolve).to include(account)
|
expect(described_class.with_auto_resolve.pluck(:id)).to include(account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not find accounts without auto_resolve_after' do
|
it 'does not find accounts without auto_resolve_after' do
|
||||||
account.update(auto_resolve_after: nil)
|
account.update(auto_resolve_after: nil)
|
||||||
expect(described_class.with_auto_resolve).not_to include(account)
|
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ require 'rails_helper'
|
|||||||
shared_examples_for 'avatarable' do
|
shared_examples_for 'avatarable' do
|
||||||
let(:avatarable) { create(described_class.to_s.underscore) }
|
let(:avatarable) { create(described_class.to_s.underscore) }
|
||||||
|
|
||||||
it { is_expected.to have_one_attached(:avatar) }
|
it 'has avatar attachment defined' do
|
||||||
|
expect(avatarable).to respond_to(:avatar)
|
||||||
|
expect(avatarable.avatar).to respond_to(:attach)
|
||||||
|
end
|
||||||
|
|
||||||
it 'add avatar_url method' do
|
it 'add avatar_url method' do
|
||||||
expect(avatarable.respond_to?(:avatar_url)).to be true
|
expect(avatarable.respond_to?(:avatar_url)).to be true
|
||||||
|
|||||||
@@ -81,14 +81,15 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#switch_locale_using_account_locale' do
|
describe '#switch_locale_using_account_locale' do
|
||||||
before do
|
|
||||||
routes.draw { get 'account_locale' => 'anonymous#account_locale' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'sets locale from account' do
|
it 'sets locale from account' do
|
||||||
controller.instance_variable_set(:@current_account, account)
|
controller.instance_variable_set(:@current_account, account)
|
||||||
get :account_locale
|
|
||||||
expect(response.body).to eq('es')
|
result = nil
|
||||||
|
controller.send(:switch_locale_using_account_locale) do
|
||||||
|
result = I18n.locale.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(result).to eq('es')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ RSpec.describe Integrations::Hook do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'returns account hooks' do
|
it 'returns account hooks' do
|
||||||
expect(described_class.account_hooks).to include(account_hook)
|
expect(described_class.account_hooks.pluck(:id)).to include(account_hook.id)
|
||||||
expect(described_class.account_hooks).not_to include(inbox_hook)
|
expect(described_class.account_hooks.pluck(:id)).not_to include(inbox_hook.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns inbox hooks' do
|
it 'returns inbox hooks' do
|
||||||
expect(described_class.inbox_hooks).to include(inbox_hook)
|
expect(described_class.inbox_hooks.pluck(:id)).to include(inbox_hook.id)
|
||||||
expect(described_class.inbox_hooks).not_to include(account_hook)
|
expect(described_class.inbox_hooks.pluck(:id)).not_to include(account_hook.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,48 @@ RSpec.describe Message do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when it validates source_id length' do
|
||||||
|
it 'valid when source_id is within text limit (20000 chars)' do
|
||||||
|
long_source_id = 'a' * 10_000
|
||||||
|
message.source_id = long_source_id
|
||||||
|
expect(message.valid?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'valid when source_id is exactly 20000 characters' do
|
||||||
|
long_source_id = 'a' * 20_000
|
||||||
|
message.source_id = long_source_id
|
||||||
|
expect(message.valid?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'invalid when source_id exceeds text limit (20000 chars)' do
|
||||||
|
long_source_id = 'a' * 20_001
|
||||||
|
message.source_id = long_source_id
|
||||||
|
message.valid?
|
||||||
|
|
||||||
|
expect(message.errors[:source_id]).to include('is too long (maximum is 20000 characters)')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles long email Message-ID headers correctly' do
|
||||||
|
# Simulate a long Message-ID like some email systems generate
|
||||||
|
long_message_id = "msg-#{SecureRandom.hex(240)}@verylongdomainname.example.com"[0...500]
|
||||||
|
message.source_id = long_message_id
|
||||||
|
message.content_type = 'incoming_email'
|
||||||
|
|
||||||
|
expect(message.valid?).to be true
|
||||||
|
expect(message.source_id.length).to eq(500)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows nil source_id' do
|
||||||
|
message.source_id = nil
|
||||||
|
expect(message.valid?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows empty string source_id' do
|
||||||
|
message.source_id = ''
|
||||||
|
expect(message.valid?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'concerns' do
|
describe 'concerns' do
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ RSpec.describe Notification do
|
|||||||
create(:notification)
|
create(:notification)
|
||||||
notification3 = create(:notification)
|
notification3 = create(:notification)
|
||||||
|
|
||||||
expect(described_class.all.first).to eq notification1
|
expect(described_class.all.first.id).to eq notification1.id
|
||||||
expect(described_class.all.last).to eq notification3
|
expect(described_class.all.last.id).to eq notification3.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
|||||||
|
|
||||||
it 'raises ApiError' do
|
it 'raises ApiError' do
|
||||||
expect { client.create_or_update_lead(lead_data) }
|
expect { client.create_or_update_lead(lead_data) }
|
||||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
|||||||
|
|
||||||
it 'raises ApiError' do
|
it 'raises ApiError' do
|
||||||
expect { client.update_lead(lead_data, lead_id) }
|
expect { client.update_lead(lead_data, lead_id) }
|
||||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
system_message
|
system_message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted_line_for(msg, hook_for_tz)
|
||||||
|
tz = Time.find_zone(hook_for_tz.settings['timezone']) || Time.zone
|
||||||
|
ts = msg.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')
|
||||||
|
sender = msg.sender&.name.presence || (msg.sender.present? ? "#{msg.sender_type} #{msg.sender_id}" : 'System')
|
||||||
|
"[#{ts}] #{sender}: #{msg.content.presence || I18n.t('crm.no_content')}"
|
||||||
|
end
|
||||||
|
|
||||||
it 'generates transcript with messages in reverse chronological order' do
|
it 'generates transcript with messages in reverse chronological order' do
|
||||||
result = described_class.map_transcript_activity(hook, conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
@@ -115,13 +122,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
expect(result).to include('Channel: Test Inbox')
|
expect(result).to include('Channel: Test Inbox')
|
||||||
|
|
||||||
# Check that messages appear in reverse order (newest first)
|
# Check that messages appear in reverse order (newest first)
|
||||||
|
newer = formatted_line_for(message2, hook)
|
||||||
|
older = formatted_line_for(message1, hook)
|
||||||
message_positions = {
|
message_positions = {
|
||||||
'[2024-01-01 10:00] John Doe: Hello' => result.index('[2024-01-01 10:00] John Doe: Hello'),
|
newer => result.index(newer),
|
||||||
'[2024-01-01 10:01] Jane Smith: Hi there' => result.index('[2024-01-01 10:01] Jane Smith: Hi there')
|
older => result.index(older)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Latest message (10:01) should come before older message (10:00)
|
# Latest message (10:01) should come before older message (10:00)
|
||||||
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
|
expect(message_positions[newer]).to be < message_positions[older]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'formats message times according to hook timezone setting' do
|
it 'formats message times according to hook timezone setting' do
|
||||||
@@ -210,13 +219,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
sender: user,
|
sender: user,
|
||||||
content: "#{long_message_content} #{i}",
|
content: "#{long_message_content} #{i}",
|
||||||
message_type: :outgoing,
|
message_type: :outgoing,
|
||||||
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
|
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
|
||||||
end
|
end
|
||||||
|
|
||||||
result = described_class.map_transcript_activity(hook, conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
# Verify latest message is included (message 14)
|
# Verify latest message is included (message 14)
|
||||||
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
|
tz = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||||
|
latest_label = "[#{messages.last.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')}] John Doe: #{long_message_content} 14"
|
||||||
|
expect(result).to include(latest_label)
|
||||||
|
|
||||||
# Calculate the expected character count of the formatted messages
|
# Calculate the expected character count of the formatted messages
|
||||||
messages.map do |msg|
|
messages.map do |msg|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe Widget::TokenService do
|
|||||||
|
|
||||||
describe 'inheritance' do
|
describe 'inheritance' do
|
||||||
it 'inherits from BaseTokenService' do
|
it 'inherits from BaseTokenService' do
|
||||||
expect(described_class.superclass).to eq(BaseTokenService)
|
expect(described_class.superclass.name).to eq('BaseTokenService')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
globals: true,
|
globals: true,
|
||||||
outputFile: 'coverage/sonar-report.xml',
|
outputFile: 'coverage/sonar-report.xml',
|
||||||
|
pool: 'threads',
|
||||||
|
poolOptions: {
|
||||||
|
threads: {
|
||||||
|
singleThread: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
deps: {
|
deps: {
|
||||||
inline: ['tinykeys', '@material/mwc-icon'],
|
inline: ['tinykeys', '@material/mwc-icon'],
|
||||||
|
|||||||
Reference in New Issue
Block a user