diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0e19725..41c1152 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,7 +56,7 @@ jobs:
run: |
curl -sSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | sudo tee /usr/share/keyrings/google.gpg > /dev/null
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
- sudo apt-get update
+ sudo apt-get update --allow-releaseinfo-change-label
sudo apt-get install -y google-chrome-stable
sudo apt install socat
@@ -87,7 +87,7 @@ jobs:
- name: Run PHPMD
run: vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml
- name: Run PHP_CodeSniffer
- run: vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/
+ run: vendor/bin/phpcs --ignore=tests/Unit/assets --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/
- name: Install Prism
run: npm install -g @stoplight/prism-cli
diff --git a/.husky/pre-commit b/.husky/pre-commit
index bb5f8a8..989fba2 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -8,4 +8,7 @@ echo "๐ Running PHPMD..."
php vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml || exit 1
echo "๐งน Running PHPCS..."
-php vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ || exit 1
+php vendor/bin/phpcs \
+ --standard=vendor/phplist/core/config/PhpCodeSniffer/ \
+ --ignore=tests/Unit/assets \
+ src/ tests/ || exit 1
diff --git a/assets/router/index.js b/assets/router/index.js
index 0ca5d30..702e426 100644
--- a/assets/router/index.js
+++ b/assets/router/index.js
@@ -8,6 +8,7 @@ import CampaignEditView from '../vue/views/CampaignEditView.vue'
import TemplatesView from '../vue/views/TemplatesView.vue'
import TemplateEditView from '../vue/views/TemplateEditView.vue'
import BouncesView from '../vue/views/BouncesView.vue'
+import AnalyticsView from '../vue/views/AnalyticsView.vue'
import PublicPagesView from '../vue/views/PublicPagesView.vue'
import PublicPageEditView from '../vue/views/PublicPageEditView.vue'
import SettingsView from '../vue/views/SettingsView.vue'
@@ -26,6 +27,7 @@ export const router = createRouter({
{ path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } },
{ path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } },
{ path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } },
+ { path: '/analytics', name: 'analytics', component: AnalyticsView, meta: { title: 'Analytics' } },
{ path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } },
{ path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } },
{ path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } },
diff --git a/assets/vue/api.js b/assets/vue/api.js
index 31d22f8..f4e44ba 100644
--- a/assets/vue/api.js
+++ b/assets/vue/api.js
@@ -11,7 +11,8 @@ import {
SubscriberAttributesClient,
TemplatesClient,
BouncesClient,
- ConfigClient, AdminAttributeClient,
+ ConfigClient,
+ AdminAttributeClient,
} from '@tatevikgr/rest-api-client';
const AUTHENTICATION_REDIRECT_PATH = '/login';
diff --git a/assets/vue/components/dashboard/KpiCard.vue b/assets/vue/components/dashboard/KpiCard.vue
index 57df775..f90e008 100644
--- a/assets/vue/components/dashboard/KpiCard.vue
+++ b/assets/vue/components/dashboard/KpiCard.vue
@@ -25,6 +25,7 @@ import BaseCard from '../../components/base/BaseCard.vue'
import BaseIcon from '../../components/base/BaseIcon.vue'
const props = defineProps({
+ id: String,
label: String,
value: [String, Number],
change: String, // "+12.5%" etc.
diff --git a/assets/vue/components/settings/EditAdminModal.vue b/assets/vue/components/settings/EditAdminModal.vue
index ccce1a7..f989f61 100644
--- a/assets/vue/components/settings/EditAdminModal.vue
+++ b/assets/vue/components/settings/EditAdminModal.vue
@@ -212,10 +212,11 @@ const resetForm = () => {
watch(
() => [props.isOpen, props.admin?.id],
([isOpen]) => {
- if (isOpen) {
- resetForm()
- }
- }
+ if (isOpen) {
+ resetForm()
+ }
+ },
+ { immediate: true }
)
const close = () => {
diff --git a/assets/vue/views/AnalyticsView.vue b/assets/vue/views/AnalyticsView.vue
new file mode 100644
index 0000000..025c5a7
--- /dev/null
+++ b/assets/vue/views/AnalyticsView.vue
@@ -0,0 +1,497 @@
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+ {{ metric.description }}
+
+
+
+
+
+
+
+
+
+ Loading analytics...
+
+
+
+ No campaign statistics found.
+
+
+
+
+
+
+
+
+
+
+
+
+
Domain
+
+ {{ domainConfirmation.domain || 'Unknown domain' }}
+
+
+
+
+
+
Confirmed
+
+ {{ formatCount(domainConfirmation.confirmed) }}
+
+
+
+
Unconfirmed
+
+ {{ formatCount(domainConfirmation.unconfirmed) }}
+
+
+
+
+
+
+ Confirmation rate
+ {{ formatPercentage(domainConfirmation.confirmationRate) }}
+
+
+
+
+
+
+
- Total
+ - {{ formatCount(domainConfirmation.total) }}
+
+
+
- Confirmed
+ - {{ formatCount(domainConfirmation.confirmed) }}
+
+
+
- Unconfirmed
+ - {{ formatCount(domainConfirmation.unconfirmed) }}
+
+
+
+
+
+ No confirmation data found.
+
+
+
+
+
+
+
+
+
+
+
+
+ | Domain |
+ Subscribers |
+
+
+
+
+ |
+ No domain statistics found.
+ |
+
+
+ |
+ {{ domain.domain || 'Unknown domain' }}
+ |
+
+ {{ formatCount(domain.subscribers) }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Local part |
+ Count |
+ Share |
+
+
+
+
+ |
+ No local-part statistics found.
+ |
+
+
+ |
+ {{ part.localPart || 'Unknown' }}
+ |
+
+ {{ formatCount(part.count) }}
+ |
+
+ {{ formatPercentage(part.percentage) }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Campaign |
+ Sent |
+ Views |
+ Open rate |
+ Clicks |
+ Bounce |
+
+
+
+
+ |
+ No campaign statistics found.
+ |
+
+
+ |
+
+ {{ campaign.subject || `Campaign #${campaign.campaignId}` }}
+
+
+ Sent {{ campaign.dateSent ? formatDate(campaign.dateSent) : 'unknown date' }}
+
+ |
+
+ {{ formatCount(campaign.sent) }}
+ |
+
+ {{ formatCount(campaign.uniqueViews) }}
+ |
+
+ {{ formatPercentage(calcRate(campaign.uniqueViews, campaign.sent)) }}
+ |
+
+ {{ formatCount(campaign.totalClicks) }}
+ |
+
+ {{ formatCount(campaign.bounces) }}
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/vue/views/DashboardView.spec.js b/assets/vue/views/DashboardView.spec.js
deleted file mode 100644
index 2b5b350..0000000
--- a/assets/vue/views/DashboardView.spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { mount } from '@vue/test-utils'
-
-const layoutStub = {
- name: 'AdminLayout',
- template: '
',
-}
-
-const blockStub = {
- name: 'DashboardBlock',
- template: '',
-}
-
-describe('DashboardView', () => {
- beforeEach(() => {
- document.body.innerHTML = ''
- })
-
- it('renders the dashboard error banner when provided by the server', async () => {
- document.body.innerHTML = `
-
- `
-
- vi.resetModules()
-
- const { default: DashboardView } = await import('./DashboardView.vue')
-
- const wrapper = mount(DashboardView, {
- global: {
- stubs: {
- AdminLayout: layoutStub,
- KpiGrid: blockStub,
- PerformanceChartCard: blockStub,
- QuickActionsCard: blockStub,
- RecentCampaignsCard: blockStub,
- },
- },
- })
-
- expect(wrapper.text()).toContain('Session expired')
- expect(wrapper.find('[role="alert"]').exists()).toBe(true)
- })
-})
diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php
new file mode 100644
index 0000000..d8486ff
--- /dev/null
+++ b/src/Controller/AnalyticsController.php
@@ -0,0 +1,24 @@
+render('@PhpListFrontend/spa.html.twig', [
+ 'page' => 'Analytics',
+ 'api_token' => $request->getSession()->get('auth_token'),
+ 'api_base_url' => $this->getParameter('api_base_url'),
+ ]);
+ }
+}
diff --git a/tests/Integration/Controller/AnalyticsControllerTest.php b/tests/Integration/Controller/AnalyticsControllerTest.php
new file mode 100644
index 0000000..5691010
--- /dev/null
+++ b/tests/Integration/Controller/AnalyticsControllerTest.php
@@ -0,0 +1,45 @@
+get('router');
+
+ self::assertSame('/analytics/', $router->generate('analytics_list'));
+ }
+
+ public function testAnalyticsPageRendersExpectedSpaPayload(): void
+ {
+ self::bootKernel();
+ /** @var AnalyticsController $controller */
+ $controller = static::getContainer()->get(AnalyticsController::class);
+ $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url');
+
+ $request = Request::create('/analytics/');
+ $session = new Session(new MockArraySessionStorage());
+ $session->set('auth_token', 'integration-token');
+ $request->setSession($session);
+
+ $response = $controller->index($request);
+ $content = (string) $response->getContent();
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertStringContainsString('phpList - Analytics', $content);
+ self::assertStringContainsString('data-api-token="integration-token"', $content);
+ self::assertStringContainsString(sprintf('data-api-base-url="%s"', $apiBaseUrl), $content);
+ }
+}
diff --git a/assets/vue/components/base/BaseBadge.spec.js b/tests/Unit/assets/vue/components/base/BaseBadge.spec.js
similarity index 95%
rename from assets/vue/components/base/BaseBadge.spec.js
rename to tests/Unit/assets/vue/components/base/BaseBadge.spec.js
index 3728e8a..e590be0 100644
--- a/assets/vue/components/base/BaseBadge.spec.js
+++ b/tests/Unit/assets/vue/components/base/BaseBadge.spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
-import BaseBadge from './BaseBadge.vue'
+import BaseBadge from '../../../../../../assets/vue/components/base/BaseBadge.vue'
describe('BaseBadge', () => {
it('applies shared base badge classes', () => {
diff --git a/assets/vue/components/base/BaseButton.spec.js b/tests/Unit/assets/vue/components/base/BaseButton.spec.js
similarity index 93%
rename from assets/vue/components/base/BaseButton.spec.js
rename to tests/Unit/assets/vue/components/base/BaseButton.spec.js
index 5a0106b..46f27a5 100644
--- a/assets/vue/components/base/BaseButton.spec.js
+++ b/tests/Unit/assets/vue/components/base/BaseButton.spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
-import BaseButton from './BaseButton.vue'
+import BaseButton from '../../../../../../assets/vue/components/base/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
diff --git a/assets/vue/components/base/BaseCard.spec.js b/tests/Unit/assets/vue/components/base/BaseCard.spec.js
similarity index 97%
rename from assets/vue/components/base/BaseCard.spec.js
rename to tests/Unit/assets/vue/components/base/BaseCard.spec.js
index eaaad91..b298f8b 100644
--- a/assets/vue/components/base/BaseCard.spec.js
+++ b/tests/Unit/assets/vue/components/base/BaseCard.spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
-import BaseCard from './BaseCard.vue'
+import BaseCard from '../../../../../../assets/vue/components/base/BaseCard.vue'
describe('BaseCard.vue', () => {
describe('default variant', () => {
diff --git a/assets/vue/components/base/BaseIcon.spec.js b/tests/Unit/assets/vue/components/base/BaseIcon.spec.js
similarity index 90%
rename from assets/vue/components/base/BaseIcon.spec.js
rename to tests/Unit/assets/vue/components/base/BaseIcon.spec.js
index 047d803..c132b38 100644
--- a/assets/vue/components/base/BaseIcon.spec.js
+++ b/tests/Unit/assets/vue/components/base/BaseIcon.spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
-import BaseIcon from './BaseIcon.vue'
+import BaseIcon from '../../../../../../assets/vue/components/base/BaseIcon.vue'
describe('BaseIcon', () => {
it('renders icon svg for known icon name', () => {
diff --git a/assets/vue/components/base/BaseProgressBar.spec.js b/tests/Unit/assets/vue/components/base/BaseProgressBar.spec.js
similarity index 90%
rename from assets/vue/components/base/BaseProgressBar.spec.js
rename to tests/Unit/assets/vue/components/base/BaseProgressBar.spec.js
index bc4a6fd..b9df57e 100644
--- a/assets/vue/components/base/BaseProgressBar.spec.js
+++ b/tests/Unit/assets/vue/components/base/BaseProgressBar.spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
-import BaseProgressBar from './BaseProgressBar.vue'
+import BaseProgressBar from '../../../../../../assets/vue/components/base/BaseProgressBar.vue'
describe('BaseProgressBar', () => {
it('applies default height and progress attributes', () => {
diff --git a/assets/vue/components/base/CkEditorField.spec.js b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js
similarity index 97%
rename from assets/vue/components/base/CkEditorField.spec.js
rename to tests/Unit/assets/vue/components/base/CkEditorField.spec.js
index ea92648..25047ce 100644
--- a/assets/vue/components/base/CkEditorField.spec.js
+++ b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js
@@ -2,7 +2,7 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import CkEditorField from './CkEditorField.vue'
+import CkEditorField from '../../../../../../assets/vue/components/base/CkEditorField.vue'
vi.mock('ckeditor5', () => ({
ClassicEditor: {},
diff --git a/assets/vue/components/bounces/BounceOverview.spec.js b/tests/Unit/assets/vue/components/bounces/BounceOverview.spec.js
similarity index 97%
rename from assets/vue/components/bounces/BounceOverview.spec.js
rename to tests/Unit/assets/vue/components/bounces/BounceOverview.spec.js
index 82d85ad..d7072d6 100644
--- a/assets/vue/components/bounces/BounceOverview.spec.js
+++ b/tests/Unit/assets/vue/components/bounces/BounceOverview.spec.js
@@ -1,9 +1,9 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import BounceOverview from './BounceOverview.vue'
-import { bouncesClient } from '../../api'
+import BounceOverview from '../../../../../../assets/vue/components/bounces/BounceOverview.vue'
+import { bouncesClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
bouncesClient: {
list: vi.fn(),
},
diff --git a/assets/vue/components/bounces/BouncePer.spec.js b/tests/Unit/assets/vue/components/bounces/BouncePer.spec.js
similarity index 98%
rename from assets/vue/components/bounces/BouncePer.spec.js
rename to tests/Unit/assets/vue/components/bounces/BouncePer.spec.js
index c1dd8a6..24c0a48 100644
--- a/assets/vue/components/bounces/BouncePer.spec.js
+++ b/tests/Unit/assets/vue/components/bounces/BouncePer.spec.js
@@ -1,9 +1,9 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import BouncePer from './BouncePer.vue'
-import { bouncesClient } from '../../api'
+import BouncePer from '../../../../../../assets/vue/components/bounces/BouncePer.vue'
+import { bouncesClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
bouncesClient: {
listByCampaign: vi.fn(),
listBySubscriber: vi.fn(),
diff --git a/assets/vue/components/bounces/BounceRules.spec.js b/tests/Unit/assets/vue/components/bounces/BounceRules.spec.js
similarity index 98%
rename from assets/vue/components/bounces/BounceRules.spec.js
rename to tests/Unit/assets/vue/components/bounces/BounceRules.spec.js
index aea0505..691d389 100644
--- a/assets/vue/components/bounces/BounceRules.spec.js
+++ b/tests/Unit/assets/vue/components/bounces/BounceRules.spec.js
@@ -1,9 +1,9 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import BounceRules from './BounceRules.vue'
-import { bouncesClient } from '../../api'
+import BounceRules from '../../../../../../assets/vue/components/bounces/BounceRules.vue'
+import { bouncesClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
bouncesClient: {
listRegex: vi.fn(),
upsertRegex: vi.fn(),
diff --git a/assets/vue/components/bounces/BouncesActionsPanel.spec.js b/tests/Unit/assets/vue/components/bounces/BouncesActionsPanel.spec.js
similarity index 93%
rename from assets/vue/components/bounces/BouncesActionsPanel.spec.js
rename to tests/Unit/assets/vue/components/bounces/BouncesActionsPanel.spec.js
index dfc0b92..caea82d 100644
--- a/assets/vue/components/bounces/BouncesActionsPanel.spec.js
+++ b/tests/Unit/assets/vue/components/bounces/BouncesActionsPanel.spec.js
@@ -1,11 +1,11 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
-import BouncesActionsPanel from './BouncesActionsPanel.vue'
+import BouncesActionsPanel from '../../../../../../assets/vue/components/bounces/BouncesActionsPanel.vue'
-vi.mock('./BounceOverview.vue', () => ({ default: { template: '' } }))
-vi.mock('./BounceRules.vue', () => ({ default: { template: '' } }))
-vi.mock('./BouncePer.vue', () => ({ default: { template: '' } }))
+vi.mock('../../../../../../assets/vue/components/bounces/BounceOverview.vue', () => ({ default: { template: '' } }))
+vi.mock('../../../../../../assets/vue/components/bounces/BounceRules.vue', () => ({ default: { template: '' } }))
+vi.mock('../../../../../../assets/vue/components/bounces/BouncePer.vue', () => ({ default: { template: '' } }))
const makeRouter = (query = {}) =>
createRouter({
diff --git a/assets/vue/components/campaigns/CampaignDirectory.spec.js b/tests/Unit/assets/vue/components/campaigns/CampaignDirectory.spec.js
similarity index 99%
rename from assets/vue/components/campaigns/CampaignDirectory.spec.js
rename to tests/Unit/assets/vue/components/campaigns/CampaignDirectory.spec.js
index 74a9063..081581c 100644
--- a/assets/vue/components/campaigns/CampaignDirectory.spec.js
+++ b/tests/Unit/assets/vue/components/campaigns/CampaignDirectory.spec.js
@@ -1,15 +1,15 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
-import CampaignDirectory from './CampaignDirectory.vue'
+import CampaignDirectory from '../../../../../../assets/vue/components/campaigns/CampaignDirectory.vue'
import {
campaignClient,
fetchAllLists,
listMessagesClient,
statisticsClient,
-} from '../../api'
+} from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
campaignClient: {
getCampaigns: vi.fn(),
getCampaign: vi.fn(),
diff --git a/assets/vue/components/campaigns/ViewCampaignModal.spec.js b/tests/Unit/assets/vue/components/campaigns/ViewCampaignModal.spec.js
similarity index 99%
rename from assets/vue/components/campaigns/ViewCampaignModal.spec.js
rename to tests/Unit/assets/vue/components/campaigns/ViewCampaignModal.spec.js
index dd21ab4..44f0590 100644
--- a/assets/vue/components/campaigns/ViewCampaignModal.spec.js
+++ b/tests/Unit/assets/vue/components/campaigns/ViewCampaignModal.spec.js
@@ -1,6 +1,6 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import ViewCampaignModal from './ViewCampaignModal.vue'
+import ViewCampaignModal from '../../../../../../assets/vue/components/campaigns/ViewCampaignModal.vue'
const makeCampaign = (overrides = {}) => ({
id: 42,
diff --git a/assets/vue/components/dashboard/CampaignsTable.spec.js b/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js
similarity index 97%
rename from assets/vue/components/dashboard/CampaignsTable.spec.js
rename to tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js
index a8952e3..a1c3f2e 100644
--- a/assets/vue/components/dashboard/CampaignsTable.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
-import CampaignsTable from './CampaignsTable.vue'
+import CampaignsTable from '../../../../../../assets/vue/components/dashboard/CampaignsTable.vue'
const makeRow = (overrides = {}) => ({
id: 1,
diff --git a/assets/vue/components/dashboard/KpiCard.spec.js b/tests/Unit/assets/vue/components/dashboard/KpiCard.spec.js
similarity index 97%
rename from assets/vue/components/dashboard/KpiCard.spec.js
rename to tests/Unit/assets/vue/components/dashboard/KpiCard.spec.js
index 384eab5..f3057b5 100644
--- a/assets/vue/components/dashboard/KpiCard.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/KpiCard.spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
-import KpiCard from './KpiCard.vue'
+import KpiCard from '../../../../../../assets/vue/components/dashboard/KpiCard.vue'
// Stub child components to isolate KpiCard logic
vi.mock('@/components/base/BaseCard.vue', () => ({
diff --git a/assets/vue/components/dashboard/KpiGrid.spec.js b/tests/Unit/assets/vue/components/dashboard/KpiGrid.spec.js
similarity index 87%
rename from assets/vue/components/dashboard/KpiGrid.spec.js
rename to tests/Unit/assets/vue/components/dashboard/KpiGrid.spec.js
index 4f4f0f3..7c65641 100644
--- a/assets/vue/components/dashboard/KpiGrid.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/KpiGrid.spec.js
@@ -47,7 +47,7 @@ describe('KpiGrid', () => {
})
it('renders four KPI cards', async () => {
- const { default: KpiGrid } = await import('./KpiGrid.vue')
+ const { default: KpiGrid } = await import('../../../../../../assets/vue/components/dashboard/KpiGrid.vue')
const wrapper = mount(KpiGrid)
@@ -57,7 +57,7 @@ describe('KpiGrid', () => {
})
it('passes formatted props to KPI cards', async () => {
- const { default: KpiGrid } = await import('./KpiGrid.vue')
+ const { default: KpiGrid } = await import('../../../../../../assets/vue/components/dashboard/KpiGrid.vue')
const wrapper = mount(KpiGrid)
@@ -107,7 +107,7 @@ describe('KpiGrid', () => {
vi.resetModules()
- const { default: KpiGrid } = await import('./KpiGrid.vue')
+ const { default: KpiGrid } = await import('../../../../../../assets/vue/components/dashboard/KpiGrid.vue')
const wrapper = mount(KpiGrid)
@@ -125,7 +125,7 @@ describe('KpiGrid', () => {
vi.resetModules()
- const { default: KpiGrid } = await import('./KpiGrid.vue')
+ const { default: KpiGrid } = await import('../../../../../../assets/vue/components/dashboard/KpiGrid.vue')
const wrapper = mount(KpiGrid)
diff --git a/assets/vue/components/dashboard/PerformanceChartCard.spec.js b/tests/Unit/assets/vue/components/dashboard/PerformanceChartCard.spec.js
similarity index 97%
rename from assets/vue/components/dashboard/PerformanceChartCard.spec.js
rename to tests/Unit/assets/vue/components/dashboard/PerformanceChartCard.spec.js
index 86022c5..fd6ca60 100644
--- a/assets/vue/components/dashboard/PerformanceChartCard.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/PerformanceChartCard.spec.js
@@ -1,7 +1,7 @@
// PerformanceChartCard.spec.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import PerformanceChartCard from './PerformanceChartCard.vue'
+import PerformanceChartCard from '../../../../../../assets/vue/components/dashboard/PerformanceChartCard.vue'
vi.mock('vue3-apexcharts', () => ({
default: {
diff --git a/assets/vue/components/dashboard/QuickActionsCard.spec.js b/tests/Unit/assets/vue/components/dashboard/QuickActionsCard.spec.js
similarity index 96%
rename from assets/vue/components/dashboard/QuickActionsCard.spec.js
rename to tests/Unit/assets/vue/components/dashboard/QuickActionsCard.spec.js
index 9db9981..9757cac 100644
--- a/assets/vue/components/dashboard/QuickActionsCard.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/QuickActionsCard.spec.js
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
-import QuickActionsCard from './QuickActionsCard.vue'
+import QuickActionsCard from '../../../../../../assets/vue/components/dashboard/QuickActionsCard.vue'
const BaseCardStub = {
name: 'BaseCard',
diff --git a/assets/vue/components/dashboard/RecentCampaignsCard.spec.js b/tests/Unit/assets/vue/components/dashboard/RecentCampaignsCard.spec.js
similarity index 91%
rename from assets/vue/components/dashboard/RecentCampaignsCard.spec.js
rename to tests/Unit/assets/vue/components/dashboard/RecentCampaignsCard.spec.js
index 5cf7a30..cff73bf 100644
--- a/assets/vue/components/dashboard/RecentCampaignsCard.spec.js
+++ b/tests/Unit/assets/vue/components/dashboard/RecentCampaignsCard.spec.js
@@ -2,9 +2,9 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import RecentCampaignsCard from './RecentCampaignsCard.vue'
+import RecentCampaignsCard from '../../../../../../assets/vue/components/dashboard/RecentCampaignsCard.vue'
-vi.mock('./CampaignsTable.vue', () => ({
+vi.mock('../../../../../../assets/vue/components/dashboard/CampaignsTable.vue', () => ({
default: {
name: 'CampaignsTable',
props: ['rows'],
diff --git a/assets/vue/components/lists/AddSubscribersModal.spec.js b/tests/Unit/assets/vue/components/lists/AddSubscribersModal.spec.js
similarity index 96%
rename from assets/vue/components/lists/AddSubscribersModal.spec.js
rename to tests/Unit/assets/vue/components/lists/AddSubscribersModal.spec.js
index f4169bd..322ea96 100644
--- a/assets/vue/components/lists/AddSubscribersModal.spec.js
+++ b/tests/Unit/assets/vue/components/lists/AddSubscribersModal.spec.js
@@ -2,10 +2,10 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
-import AddSubscribersModal from './AddSubscribersModal.vue'
-import { subscriptionClient } from '../../api'
+import AddSubscribersModal from '../../../../../../assets/vue/components/lists/AddSubscribersModal.vue'
+import { subscriptionClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
subscriptionClient: {
createSubscriptions: vi.fn(),
},
diff --git a/assets/vue/components/lists/CreateListModal.spec.js b/tests/Unit/assets/vue/components/lists/CreateListModal.spec.js
similarity index 96%
rename from assets/vue/components/lists/CreateListModal.spec.js
rename to tests/Unit/assets/vue/components/lists/CreateListModal.spec.js
index 3cff125..4675161 100644
--- a/assets/vue/components/lists/CreateListModal.spec.js
+++ b/tests/Unit/assets/vue/components/lists/CreateListModal.spec.js
@@ -3,11 +3,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
-import CreateListModal from './CreateListModal.vue'
-import { listClient } from '../../api'
+import CreateListModal from '../../../../../../assets/vue/components/lists/CreateListModal.vue'
+import { listClient } from '../../../../../../assets/vue/api'
import { Requests } from '@tatevikgr/rest-api-client'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
listClient: {
createList: vi.fn(),
},
diff --git a/assets/vue/components/lists/EditListModal.spec.js b/tests/Unit/assets/vue/components/lists/EditListModal.spec.js
similarity index 97%
rename from assets/vue/components/lists/EditListModal.spec.js
rename to tests/Unit/assets/vue/components/lists/EditListModal.spec.js
index ee6e383..e33e029 100644
--- a/assets/vue/components/lists/EditListModal.spec.js
+++ b/tests/Unit/assets/vue/components/lists/EditListModal.spec.js
@@ -3,11 +3,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
-import EditListModal from './EditListModal.vue'
-import { listClient } from '../../api'
+import EditListModal from '../../../../../../assets/vue/components/lists/EditListModal.vue'
+import { listClient } from '../../../../../../assets/vue/api'
import { Requests } from '@tatevikgr/rest-api-client'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
listClient: {
updateList: vi.fn(),
},
diff --git a/assets/vue/components/lists/ListDirectory.spec.js b/tests/Unit/assets/vue/components/lists/ListDirectory.spec.js
similarity index 97%
rename from assets/vue/components/lists/ListDirectory.spec.js
rename to tests/Unit/assets/vue/components/lists/ListDirectory.spec.js
index ad06e5e..7dbd9ad 100644
--- a/assets/vue/components/lists/ListDirectory.spec.js
+++ b/tests/Unit/assets/vue/components/lists/ListDirectory.spec.js
@@ -2,8 +2,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import ListDirectory from './ListDirectory.vue'
-import { fetchAllLists, listClient } from '../../api'
+import ListDirectory from '../../../../../../assets/vue/components/lists/ListDirectory.vue'
+import { fetchAllLists, listClient } from '../../../../../../assets/vue/api'
const pushMock = vi.fn()
@@ -13,7 +13,7 @@ vi.mock('vue-router', () => ({
}),
}))
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
fetchAllLists: vi.fn(),
listClient: {
deleteList: vi.fn(),
diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.spec.js b/tests/Unit/assets/vue/components/lists/ListSubscribersExportPanel.spec.js
similarity index 96%
rename from assets/vue/components/lists/ListSubscribersExportPanel.spec.js
rename to tests/Unit/assets/vue/components/lists/ListSubscribersExportPanel.spec.js
index 6393baa..6b98d7e 100644
--- a/assets/vue/components/lists/ListSubscribersExportPanel.spec.js
+++ b/tests/Unit/assets/vue/components/lists/ListSubscribersExportPanel.spec.js
@@ -2,10 +2,10 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import ListSubscribersExportPanel from './ListSubscribersExportPanel.vue'
-import client from '../../api'
+import ListSubscribersExportPanel from '../../../../../../assets/vue/components/lists/ListSubscribersExportPanel.vue'
+import client from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
default: {
get: vi.fn(),
},
diff --git a/assets/vue/components/public-pages/PublicPageEditor.spec.js b/tests/Unit/assets/vue/components/public-pages/PublicPageEditor.spec.js
similarity index 96%
rename from assets/vue/components/public-pages/PublicPageEditor.spec.js
rename to tests/Unit/assets/vue/components/public-pages/PublicPageEditor.spec.js
index 9dcf755..3afb358 100644
--- a/assets/vue/components/public-pages/PublicPageEditor.spec.js
+++ b/tests/Unit/assets/vue/components/public-pages/PublicPageEditor.spec.js
@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
-import PublicPageEditor from './PublicPageEditor.vue'
+import PublicPageEditor from '../../../../../../assets/vue/components/public-pages/PublicPageEditor.vue'
const {
routeMock,
@@ -36,7 +36,7 @@ vi.mock('vue-router', () => ({
}),
}))
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
backendFetch: backendFetchMock,
fetchAllAdmins: fetchAllAdminsMock,
fetchAllLists: fetchAllListsMock,
diff --git a/assets/vue/components/public-pages/PublicPagesDirectory.spec.js b/tests/Unit/assets/vue/components/public-pages/PublicPagesDirectory.spec.js
similarity index 95%
rename from assets/vue/components/public-pages/PublicPagesDirectory.spec.js
rename to tests/Unit/assets/vue/components/public-pages/PublicPagesDirectory.spec.js
index 58895c6..03eca53 100644
--- a/assets/vue/components/public-pages/PublicPagesDirectory.spec.js
+++ b/tests/Unit/assets/vue/components/public-pages/PublicPagesDirectory.spec.js
@@ -1,6 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import { Requests } from '@tatevikgr/rest-api-client'
-import PublicPagesDirectory from './PublicPagesDirectory.vue'
+import PublicPagesDirectory from '../../../../../../assets/vue/components/public-pages/PublicPagesDirectory.vue'
const {
pushMock,
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({
}),
}))
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
subscribePagesClient: subscribePagesClientMock,
}))
diff --git a/tests/Unit/assets/vue/components/settings/CreateAdminAttributeModal.spec.js b/tests/Unit/assets/vue/components/settings/CreateAdminAttributeModal.spec.js
new file mode 100644
index 0000000..1cd59e5
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/CreateAdminAttributeModal.spec.js
@@ -0,0 +1,119 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import CreateAdminAttributeModal from '../../../../../../assets/vue/components/settings/CreateAdminAttributeModal.vue'
+
+const {
+ createAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ createAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ adminAttributeClient: {
+ createAttributeDefinition: createAttributeDefinitionMock,
+ },
+}))
+
+const mountComponent = (props = {}) =>
+ mount(CreateAdminAttributeModal, {
+ props: {
+ isOpen: true,
+ ...props,
+ },
+ global: {
+ stubs: {
+ teleport: true,
+ },
+ },
+ })
+
+describe('CreateAdminAttributeModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ createAttributeDefinitionMock.mockResolvedValue({
+ id: 1,
+ })
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Create Attribute')
+ expect(wrapper.find('input').exists()).toBe(true)
+ expect(wrapper.find('select').exists()).toBe(true)
+ expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
+ })
+
+ it('resets form each time the modal opens', async () => {
+ const wrapper = mountComponent({
+ isOpen: false,
+ })
+
+ await wrapper.setProps({
+ isOpen: true,
+ })
+
+ await flushPromises()
+
+ expect(wrapper.find('input').element.value).toBe('')
+ expect(wrapper.find('select').element.value).toBe('textline')
+ expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false)
+ })
+
+ it('creates an attribute and emits created', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('input').setValue('Email')
+ await wrapper.find('select').setValue('hidden')
+ await wrapper.find('input[type="checkbox"]').setValue(true)
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAttributeDefinitionMock).toHaveBeenCalledWith({
+ name: 'Email',
+ type: 'hidden',
+ required: true,
+ })
+
+ expect(wrapper.emitted('created')).toHaveLength(1)
+ })
+
+ it('shows an error when creation fails', async () => {
+ createAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Unable to save')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.find('input').setValue('Email')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAttributeDefinitionMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Unable to save')
+ expect(wrapper.emitted('created')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the X button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const closeButton = wrapper.findAll('button')[0]
+
+ await closeButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/CreateAdminModal.spec.js b/tests/Unit/assets/vue/components/settings/CreateAdminModal.spec.js
new file mode 100644
index 0000000..bf5aa33
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/CreateAdminModal.spec.js
@@ -0,0 +1,180 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import CreateAdminModal from '../../../../../../assets/vue/components/settings/CreateAdminModal.vue'
+
+const {
+ createAdministratorMock,
+} = vi.hoisted(() => ({
+ createAdministratorMock: vi.fn(),
+}))
+
+vi.mock('@tatevikgr/rest-api-client', () => ({
+ Requests: {
+ CreateAdministratorRequest: class {
+ constructor(login_name, password, email, super_user, privileges) {
+ this.login_name = login_name
+ this.password = password
+ this.email = email
+ this.super_user = super_user
+ this.privileges = privileges
+ }
+ },
+ },
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ adminClient: {
+ createAdministrator: createAdministratorMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+const mountComponent = (props = {}) =>
+ mount(CreateAdminModal, {
+ props: {
+ isOpen: true,
+ ...props,
+ },
+ })
+
+describe('CreateAdminModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ createAdministratorMock.mockResolvedValue({
+ id: 1,
+ login_name: 'admin',
+ })
+
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Create New Administrator')
+ expect(wrapper.find('#admin-login-name').exists()).toBe(true)
+ expect(wrapper.find('#admin-email').exists()).toBe(true)
+ expect(wrapper.find('#admin-password').exists()).toBe(true)
+ })
+
+ it('resets form whenever opened', async () => {
+ const wrapper = mountComponent({
+ isOpen: false,
+ })
+
+ await wrapper.setProps({
+ isOpen: true,
+ })
+
+ await flushPromises()
+
+ expect(wrapper.find('#admin-login-name').element.value).toBe('')
+ expect(wrapper.find('#admin-email').element.value).toBe('')
+ expect(wrapper.find('#admin-password').element.value).toBe('')
+ expect(wrapper.find('#admin-super-user').element.checked).toBe(false)
+ })
+
+ it('creates an administrator', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('#admin-login-name').setValue('admin')
+ await wrapper.find('#admin-email').setValue('admin@example.com')
+ await wrapper.find('#admin-password').setValue('password123')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAdministratorMock).toHaveBeenCalledTimes(1)
+
+ const request = createAdministratorMock.mock.calls[0][0]
+
+ expect(request.login_name).toBe('admin')
+ expect(request.email).toBe('admin@example.com')
+ expect(request.password).toBe('password123')
+ expect(request.super_user).toBe(false)
+ expect(request.privileges).toEqual({
+ subscribers: false,
+ campaigns: false,
+ statistics: false,
+ settings: false,
+ })
+
+ expect(wrapper.emitted('created')).toHaveLength(1)
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('creates a super user without privileges', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('#admin-login-name').setValue('admin')
+ await wrapper.find('#admin-email').setValue('admin@example.com')
+ await wrapper.find('#admin-password').setValue('password123')
+ await wrapper.find('#admin-super-user').setValue(true)
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ const request = createAdministratorMock.mock.calls[0][0]
+
+ expect(request.super_user).toBe(true)
+ expect(request.privileges).toBeUndefined()
+ })
+
+ it('does not submit when form is invalid', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('#admin-login-name').setValue('ab')
+ await wrapper.find('#admin-email').setValue('invalid')
+ await wrapper.find('#admin-password').setValue('123')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAdministratorMock).not.toHaveBeenCalled()
+ })
+
+ it('shows an error when creation fails', async () => {
+ createAdministratorMock.mockRejectedValueOnce(
+ new Error('Create failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.find('#admin-login-name').setValue('admin')
+ await wrapper.find('#admin-email').setValue('admin@example.com')
+ await wrapper.find('#admin-password').setValue('password123')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAdministratorMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Create failed')
+ expect(console.error).toHaveBeenCalled()
+ expect(wrapper.emitted('created')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the close button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('button[type="button"]').trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js b/tests/Unit/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js
new file mode 100644
index 0000000..09c0b12
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js
@@ -0,0 +1,189 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import CreateSubscriberAttributeModal from '../../../../../../assets/vue/components/settings/CreateSubscriberAttributeModal.vue'
+
+const {
+ createAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ createAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ subscriberAttributesClient: {
+ createAttributeDefinition: createAttributeDefinitionMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+const mountComponent = (props = {}) =>
+ mount(CreateSubscriberAttributeModal, {
+ props: {
+ isOpen: true,
+ ...props,
+ },
+ global: {
+ stubs: {
+ teleport: true,
+ },
+ },
+ })
+
+describe('CreateSubscriberAttributeModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ createAttributeDefinitionMock.mockResolvedValue({
+ id: 1,
+ })
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Create Subscriber Attribute')
+ expect(wrapper.findAll('input')[0].exists()).toBe(true)
+ expect(wrapper.find('select').exists()).toBe(true)
+ })
+
+ it('resets the form when reopened', async () => {
+ const wrapper = mountComponent({
+ isOpen: false,
+ })
+
+ await wrapper.setProps({
+ isOpen: true,
+ })
+
+ await flushPromises()
+
+ const inputs = wrapper.findAll('input')
+
+ expect(inputs[0].element.value).toBe('')
+ expect(wrapper.find('select').element.value).toBe('textline')
+ expect(inputs[2].element.checked).toBe(false)
+ })
+
+ it('creates a subscriber attribute', async () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ await inputs[0].setValue('Country')
+ await wrapper.find('select').setValue('textline')
+ await inputs[1].setValue('10')
+ await inputs[2].setValue('Default')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAttributeDefinitionMock).toHaveBeenCalledWith({
+ name: 'Country',
+ type: 'textline',
+ order: 10,
+ default_value: 'Default',
+ required: false,
+ options: [],
+ })
+
+ expect(wrapper.emitted('created')).toHaveLength(1)
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('adds and removes options', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('select').setValue('select')
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Add')
+
+ await addButton.trigger('click')
+
+ expect(wrapper.findAll('input').length).toBeGreaterThan(4)
+
+ const removeButton = wrapper
+ .findAll('button')
+ .find(button => !button.text().trim())
+
+ await removeButton.trigger('click')
+
+ expect(wrapper.findAll('input').length).toBe(4)
+ })
+
+ it('submits options for selectable types', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('input')[0].setValue('Status')
+ await wrapper.find('select').setValue('select')
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Add')
+
+ await addButton.trigger('click')
+
+ const inputs = wrapper.findAll('input')
+
+ await inputs[4].setValue('Active')
+ await inputs[5].setValue('1')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAttributeDefinitionMock).toHaveBeenCalledWith({
+ name: 'Status',
+ type: 'select',
+ order: null,
+ default_value: '',
+ required: false,
+ options: [
+ {
+ name: 'Active',
+ list_order: 1,
+ },
+ ],
+ })
+ })
+
+ it('shows an error when creation fails', async () => {
+ createAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Unable to save')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('input')[0].setValue('Country')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAttributeDefinitionMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Unable to save')
+ expect(wrapper.emitted('created')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the X button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('button')[0].trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/EditAdminAttributeModal.spec.js b/tests/Unit/assets/vue/components/settings/EditAdminAttributeModal.spec.js
new file mode 100644
index 0000000..f39bde3
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/EditAdminAttributeModal.spec.js
@@ -0,0 +1,140 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import EditAdminAttributeModal from '../../../../../../assets/vue/components/settings/EditAdminAttributeModal.vue'
+
+const {
+ updateAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ updateAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ adminAttributeClient: {
+ updateAttributeDefinition: updateAttributeDefinitionMock,
+ },
+}))
+
+const attribute = {
+ id: 1,
+ name: 'Email',
+ type: 'textline',
+ required: true,
+}
+
+const mountComponent = (props = {}) =>
+ mount(EditAdminAttributeModal, {
+ props: {
+ isOpen: true,
+ attribute,
+ ...props,
+ },
+ global: {
+ stubs: {
+ teleport: true,
+ },
+ },
+ })
+
+describe('EditAdminAttributeModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ updateAttributeDefinitionMock.mockResolvedValue({
+ id: 1,
+ })
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Edit Attribute')
+ expect(wrapper.find('input').exists()).toBe(true)
+ expect(wrapper.find('select').exists()).toBe(true)
+ })
+
+ it('populates the form from the attribute prop', () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ expect(inputs[0].element.value).toBe('Email')
+ expect(wrapper.find('select').element.value).toBe('textline')
+ expect(inputs[1].element.checked).toBe(true)
+ })
+
+ it('updates the form when the attribute prop changes', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.setProps({
+ attribute: {
+ id: 2,
+ name: 'Country',
+ type: 'hidden',
+ required: false,
+ },
+ })
+
+ await flushPromises()
+
+ const inputs = wrapper.findAll('input')
+
+ expect(inputs[0].element.value).toBe('Country')
+ expect(wrapper.find('select').element.value).toBe('hidden')
+ expect(inputs[1].element.checked).toBe(false)
+ })
+
+ it('updates an attribute and emits updated', async () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ await inputs[0].setValue('Full Name')
+ await wrapper.find('select').setValue('hidden')
+ await inputs[1].setValue(false)
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAttributeDefinitionMock).toHaveBeenCalledWith(1, {
+ name: 'Full Name',
+ type: 'hidden',
+ required: false,
+ })
+
+ expect(wrapper.emitted('updated')).toHaveLength(1)
+ })
+
+ it('shows an error when update fails', async () => {
+ updateAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Unable to update')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAttributeDefinitionMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Unable to update')
+ expect(wrapper.emitted('updated')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the X button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('button')[0].trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/EditAdminModal.spec.js b/tests/Unit/assets/vue/components/settings/EditAdminModal.spec.js
new file mode 100644
index 0000000..698ac08
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/EditAdminModal.spec.js
@@ -0,0 +1,196 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import EditAdminModal from '../../../../../../assets/vue/components/settings/EditAdminModal.vue'
+
+const {
+ updateAdministratorMock,
+} = vi.hoisted(() => ({
+ updateAdministratorMock: vi.fn(),
+}))
+
+vi.mock('@tatevikgr/rest-api-client', () => ({
+ Requests: {
+ UpdateAdministratorRequest: class {
+ constructor(loginName, password, email, superUser, privileges) {
+ this.loginName = loginName
+ this.password = password
+ this.email = email
+ this.superUser = superUser
+ this.privileges = privileges
+ }
+ },
+ },
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ adminClient: {
+ updateAdministrator: updateAdministratorMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+const admin = {
+ id: 7,
+ loginName: 'admin',
+ email: 'admin@example.com',
+ superUser: false,
+ privileges: {
+ subscribers: true,
+ campaigns: false,
+ statistics: true,
+ settings: false,
+ },
+}
+
+const mountComponent = (props = {}) =>
+ mount(EditAdminModal, {
+ props: {
+ isOpen: true,
+ admin,
+ ...props,
+ },
+ })
+
+describe('EditAdminModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ updateAdministratorMock.mockResolvedValue({
+ id: 7,
+ })
+
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Edit Administrator')
+ expect(wrapper.text()).toContain('7')
+ expect(wrapper.find('#edit-admin-login-name').exists()).toBe(true)
+ expect(wrapper.find('#edit-admin-email').exists()).toBe(true)
+ expect(wrapper.find('#edit-admin-password').exists()).toBe(true)
+ })
+
+ it('populates the form from the admin prop', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.find('#edit-admin-login-name').element.value).toBe('admin')
+ expect(wrapper.find('#edit-admin-email').element.value).toBe('admin@example.com')
+ expect(wrapper.find('#edit-admin-password').element.value).toBe('')
+ expect(wrapper.find('#edit-admin-super-user').element.checked).toBe(false)
+ expect(wrapper.find('#edit-priv-subscribers').element.checked).toBe(true)
+ expect(wrapper.find('#edit-priv-statistics').element.checked).toBe(true)
+ })
+
+ it('updates the form when the admin prop changes', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.setProps({
+ admin: {
+ id: 9,
+ loginName: 'root',
+ email: 'root@example.com',
+ superUser: true,
+ privileges: {
+ subscribers: false,
+ campaigns: true,
+ statistics: false,
+ settings: true,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(wrapper.find('#edit-admin-login-name').element.value).toBe('root')
+ expect(wrapper.find('#edit-admin-email').element.value).toBe('root@example.com')
+ expect(wrapper.find('#edit-admin-super-user').element.checked).toBe(true)
+ expect(wrapper.find('#edit-priv-campaigns').element.checked).toBe(true)
+ expect(wrapper.find('#edit-priv-settings').element.checked).toBe(true)
+ })
+
+ it('updates an administrator', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('#edit-admin-login-name').setValue('new-admin')
+ await wrapper.find('#edit-admin-email').setValue('new@example.com')
+ await wrapper.find('#edit-admin-password').setValue('password123')
+ await wrapper.find('#edit-admin-super-user').setValue(true)
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAdministratorMock).toHaveBeenCalledTimes(1)
+ expect(updateAdministratorMock.mock.calls[0][0]).toBe(7)
+
+ const request = updateAdministratorMock.mock.calls[0][1]
+
+ expect(request.loginName).toBe('new-admin')
+ expect(request.email).toBe('new@example.com')
+ expect(request.password).toBe('password123')
+ expect(request.superUser).toBe(true)
+ expect(request.privileges).toEqual({
+ subscribers: true,
+ campaigns: false,
+ statistics: true,
+ settings: false,
+ })
+
+ expect(wrapper.emitted('updated')).toHaveLength(1)
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('does not submit without an admin', async () => {
+ const wrapper = mountComponent({
+ admin: null,
+ })
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAdministratorMock).not.toHaveBeenCalled()
+ })
+
+ it('shows an error when update fails', async () => {
+ updateAdministratorMock.mockRejectedValueOnce(
+ new Error('Update failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAdministratorMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Update failed')
+ expect(console.error).toHaveBeenCalled()
+ expect(wrapper.emitted('updated')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the close button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.find('button[aria-label="Close edit administrator modal"]').trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js b/tests/Unit/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js
new file mode 100644
index 0000000..b53ca44
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js
@@ -0,0 +1,185 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import EditSubscriberAttributeModal from '../../../../../../assets/vue/components/settings/EditSubscriberAttributeModal.vue'
+
+const {
+ updateAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ updateAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ subscriberAttributesClient: {
+ updateAttributeDefinition: updateAttributeDefinitionMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+const attribute = {
+ id: 1,
+ name: 'Country',
+ type: 'select',
+ order: 5,
+ default_value: 'USA',
+ required: true,
+ options: [
+ {
+ name: 'USA',
+ list_order: 1,
+ },
+ {
+ name: 'Canada',
+ list_order: 2,
+ },
+ ],
+}
+
+const mountComponent = (props = {}) =>
+ mount(EditSubscriberAttributeModal, {
+ props: {
+ isOpen: true,
+ attribute,
+ ...props,
+ },
+ global: {
+ stubs: {
+ teleport: true,
+ },
+ },
+ })
+
+describe('EditSubscriberAttributeModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ updateAttributeDefinitionMock.mockResolvedValue({
+ id: 1,
+ })
+ })
+
+ it('renders when open', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Edit Subscriber Attribute')
+ expect(wrapper.find('select').exists()).toBe(true)
+ })
+
+ it('populates the form from the attribute prop', () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ expect(inputs[0].element.value).toBe('Country')
+ expect(wrapper.find('select').element.value).toBe('select')
+ expect(inputs[1].element.value).toBe('5')
+ expect(inputs[2].element.value).toBe('USA')
+ expect(inputs[3].element.checked).toBe(true)
+ })
+
+ it('updates the attribute', async () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ await inputs[0].setValue('Status')
+ await inputs[1].setValue('10')
+ await inputs[2].setValue('Active')
+ await inputs[3].setValue(false)
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAttributeDefinitionMock).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({
+ id: 1,
+ name: 'Status',
+ type: 'select',
+ order: 10,
+ default_value: 'Active',
+ required: false,
+ })
+ )
+
+ expect(wrapper.emitted('updated')).toHaveLength(1)
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('adds and removes options', async () => {
+ const wrapper = mountComponent()
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Add')
+
+ await addButton.trigger('click')
+
+ expect(wrapper.findAll('input').length).toBe(10)
+
+ const removeButtons = wrapper
+ .findAll('button')
+ .filter(button => button.text().trim() === '')
+
+ await removeButtons[0].trigger('click')
+
+ expect(wrapper.findAll('input').length).toBe(8)
+ })
+
+ it('updates option values', async () => {
+ const wrapper = mountComponent()
+
+ const inputs = wrapper.findAll('input')
+
+ await inputs[4].setValue('UK')
+ await inputs[5].setValue('3')
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ const payload = updateAttributeDefinitionMock.mock.calls[0][1]
+
+ expect(payload.options[0]).toEqual({
+ name: 'UK',
+ list_order: 3,
+ })
+ })
+
+ it('shows an error when update fails', async () => {
+ updateAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Update failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(updateAttributeDefinitionMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Update failed')
+ expect(wrapper.emitted('updated')).toBeUndefined()
+ })
+
+ it('emits close when cancel is clicked', async () => {
+ const wrapper = mountComponent()
+
+ const cancelButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Cancel')
+
+ await cancelButton.trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+
+ it('emits close when the X button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('button')[0].trigger('click')
+
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/SettingsActionsPanel.spec.js b/tests/Unit/assets/vue/components/settings/SettingsActionsPanel.spec.js
new file mode 100644
index 0000000..f82306d
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/SettingsActionsPanel.spec.js
@@ -0,0 +1,163 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import SettingsActionsPanel from '../../../../../../assets/vue/components/settings/SettingsActionsPanel.vue'
+
+const {
+ routeMock,
+ replaceMock,
+} = vi.hoisted(() => ({
+ routeMock: {
+ query: {},
+ },
+ replaceMock: vi.fn(() => Promise.resolve()),
+}))
+
+vi.mock('vue-router', () => ({
+ useRoute: () => routeMock,
+ useRouter: () => ({
+ replace: replaceMock,
+ }),
+}))
+
+vi.mock('../../../../../../assets/vue/components/settings/SettingsConfigs.vue', () => ({
+ default: {
+ template: 'Configs Panel
',
+ },
+}))
+
+vi.mock('../../../../../../assets/vue/components/settings/SettingsAdmins.vue', () => ({
+ default: {
+ template: 'Admins Panel
',
+ },
+}))
+
+vi.mock('../../../../../../assets/vue/components/settings/SettingsAdminAttributes.vue', () => ({
+ default: {
+ template: 'Admin Attributes Panel
',
+ },
+}))
+
+vi.mock('../../../../../../assets/vue/components/settings/SettingsSubscriberAttributes.vue', () => ({
+ default: {
+ template: 'Subscriber Attributes Panel
',
+ },
+}))
+
+const mountComponent = () => mount(SettingsActionsPanel)
+
+describe('SettingsActionsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ routeMock.query = {}
+ })
+
+ it('renders configs tab by default', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Settings')
+ expect(wrapper.text()).toContain('Configs Panel')
+
+ expect(replaceMock).toHaveBeenCalledWith({
+ query: {
+ tab: 'configs',
+ },
+ })
+ })
+
+ it('renders admins tab from route query', async () => {
+ routeMock.query = {
+ tab: 'admins',
+ }
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Admins Panel')
+ expect(replaceMock).not.toHaveBeenCalled()
+ })
+
+ it('falls back to configs for an invalid tab', async () => {
+ routeMock.query = {
+ tab: 'invalid',
+ }
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Configs Panel')
+
+ expect(replaceMock).toHaveBeenCalledWith({
+ query: {
+ tab: 'configs',
+ },
+ })
+ })
+
+ it('changes tabs when a tab button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const button = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Admins')
+
+ await button.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Admins Panel')
+
+ expect(replaceMock).toHaveBeenLastCalledWith({
+ query: {
+ tab: 'admins',
+ },
+ })
+ })
+
+ it('renders the admin attributes tab', async () => {
+ routeMock.query = {
+ tab: 'admin_attributes',
+ }
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Admin Attributes Panel')
+ })
+
+ it('renders the subscriber attributes tab', async () => {
+ routeMock.query = {
+ tab: 'subscriber_attributes',
+ }
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Subscriber Attributes Panel')
+ })
+
+ it('updates the displayed panel when the route query changes', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const adminsButton = wrapper
+ .findAll('button')
+ .find(b => b.text() === 'Admins')
+
+ await adminsButton.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Admins Panel')
+ expect(replaceMock).toHaveBeenLastCalledWith({
+ query: {
+ tab: 'admins',
+ },
+ })
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/SettingsAdminAttributes.spec.js b/tests/Unit/assets/vue/components/settings/SettingsAdminAttributes.spec.js
new file mode 100644
index 0000000..a094ecc
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/SettingsAdminAttributes.spec.js
@@ -0,0 +1,219 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import SettingsAdminAttributes from '../../../../../../assets/vue/components/settings/SettingsAdminAttributes.vue'
+import {defineComponent} from "vue";
+
+const {
+ getAttributeDefinitionsMock,
+ deleteAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ getAttributeDefinitionsMock: vi.fn(),
+ deleteAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ adminAttributeClient: {
+ getAttributeDefinitions: getAttributeDefinitionsMock,
+ deleteAttributeDefinition: deleteAttributeDefinitionMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+const CreateStub = defineComponent({
+ name: 'CreateAdminAttributeModal',
+ props: ['isOpen'],
+ emits: ['close', 'created'],
+ template: '',
+})
+
+const EditStub = defineComponent({
+ name: 'EditAdminAttributeModal',
+ props: ['isOpen', 'attribute'],
+ emits: ['close', 'updated'],
+ template: '',
+})
+
+const mountComponent = () =>
+ mount(SettingsAdminAttributes, {
+ global: {
+ stubs: {
+ BaseIcon: true,
+ CreateAdminAttributeModal: CreateStub,
+ EditAdminAttributeModal: EditStub,
+ },
+ },
+ })
+describe('SettingsAdminAttributes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ getAttributeDefinitionsMock.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ name: 'Email',
+ type: 'textline',
+ required: true,
+ },
+ {
+ id: 2,
+ name: 'Token',
+ type: 'hidden',
+ required: false,
+ },
+ ],
+ })
+
+ deleteAttributeDefinitionMock.mockResolvedValue()
+
+ vi.spyOn(window, 'confirm').mockImplementation(() => true)
+ vi.spyOn(window, 'alert').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('loads attributes on mount', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(getAttributeDefinitionsMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Email')
+ expect(wrapper.text()).toContain('Token')
+ })
+
+ it('shows empty state', async () => {
+ getAttributeDefinitionsMock.mockResolvedValueOnce({
+ items: [],
+ })
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('No attributes found.')
+ })
+
+ it('shows load error', async () => {
+ getAttributeDefinitionsMock.mockRejectedValueOnce(
+ new Error('Load failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Load failed')
+ })
+
+ it('opens create modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text().includes('Add Attribute'))
+
+ await addButton.trigger('click')
+
+ expect(wrapper.findComponent({ name: 'CreateAdminAttributeModal' }).props('isOpen')).toBe(true)
+ })
+
+ it('reloads attributes after create', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ getAttributeDefinitionsMock.mockClear()
+
+ const createModal = wrapper.findComponent(CreateStub)
+ expect(createModal.exists()).toBe(true)
+ await createModal.vm.$emit('created')
+ })
+
+ it('opens edit modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const editButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Edit')
+
+ await editButton.trigger('click')
+
+ const modal = wrapper.findComponent({ name: 'EditAdminAttributeModal' })
+
+ expect(modal.props('isOpen')).toBe(true)
+ expect(modal.props('attribute').name).toBe('Email')
+ })
+
+ it('reloads after update', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ getAttributeDefinitionsMock.mockClear()
+
+ wrapper.findComponent({ name: 'EditAdminAttributeModal' }).vm.$emit('updated')
+
+ await flushPromises()
+
+ expect(getAttributeDefinitionsMock).toHaveBeenCalled()
+ })
+
+ it('deletes an attribute after confirmation', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(window.confirm).toHaveBeenCalled()
+ expect(deleteAttributeDefinitionMock).toHaveBeenCalledWith(1)
+ expect(getAttributeDefinitionsMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not delete when confirmation is cancelled', async () => {
+ window.confirm.mockReturnValueOnce(false)
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(deleteAttributeDefinitionMock).not.toHaveBeenCalled()
+ })
+
+ it('shows alert when delete fails', async () => {
+ deleteAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Delete failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+ await flushPromises()
+
+ expect(window.alert).toHaveBeenCalledWith('Delete failed')
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/SettingsAdmins.spec.js b/tests/Unit/assets/vue/components/settings/SettingsAdmins.spec.js
new file mode 100644
index 0000000..90132cb
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/SettingsAdmins.spec.js
@@ -0,0 +1,198 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import SettingsAdmins from '../../../../../../assets/vue/components/settings/SettingsAdmins.vue'
+
+const {
+ fetchAllAdminsMock,
+ deleteAdministratorMock,
+} = vi.hoisted(() => ({
+ fetchAllAdminsMock: vi.fn(),
+ deleteAdministratorMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ fetchAllAdmins: fetchAllAdminsMock,
+ adminClient: {
+ deleteAdministrator: deleteAdministratorMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+vi.mock('./CreateAdminModal.vue', () => ({
+ default: defineComponent({
+ name: 'CreateAdminModal',
+ props: ['isOpen'],
+ emits: ['close', 'created'],
+ template: '',
+ }),
+}))
+
+vi.mock('./EditAdminModal.vue', () => ({
+ default: defineComponent({
+ name: 'EditAdminModal',
+ props: ['isOpen', 'admin'],
+ emits: ['close', 'updated'],
+ template: '',
+ }),
+}))
+
+const mountComponent = () => mount(SettingsAdmins)
+
+describe('SettingsAdmins', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ fetchAllAdminsMock.mockResolvedValue([
+ {
+ id: 1,
+ loginName: 'admin',
+ email: 'admin@example.com',
+ superUser: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: 2,
+ loginName: 'editor',
+ email: 'editor@example.com',
+ superUser: false,
+ createdAt: '2024-01-02T00:00:00Z',
+ },
+ ])
+
+ deleteAdministratorMock.mockResolvedValue()
+
+ vi.spyOn(window, 'confirm').mockImplementation(() => true)
+ vi.spyOn(window, 'alert').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('loads administrators on mount', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(fetchAllAdminsMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('admin')
+ expect(wrapper.text()).toContain('editor')
+ })
+
+ it('shows empty state', async () => {
+ fetchAllAdminsMock.mockResolvedValueOnce([])
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('No administrators found')
+ })
+
+ it('shows load error', async () => {
+ fetchAllAdminsMock.mockRejectedValueOnce(
+ new Error('Load failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Load failed')
+ })
+
+ it('opens the create modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text().includes('Add Admin'))
+
+ await addButton.trigger('click')
+
+ expect(wrapper.findComponent({ name: 'CreateAdminModal' }).props('isOpen')).toBe(true)
+ })
+
+ it('opens the edit modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const editButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Edit')
+
+ await editButton.trigger('click')
+
+ const modal = wrapper.findComponent({ name: 'EditAdminModal' })
+
+ expect(modal.props('isOpen')).toBe(true)
+ expect(modal.props('admin').loginName).toBe('admin')
+ })
+
+ it('deletes an administrator', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(window.confirm).toHaveBeenCalled()
+ expect(deleteAdministratorMock).toHaveBeenCalledWith(1)
+ expect(fetchAllAdminsMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not delete when confirmation is cancelled', async () => {
+ window.confirm.mockReturnValueOnce(false)
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(deleteAdministratorMock).not.toHaveBeenCalled()
+ })
+
+ it('shows alert when delete fails', async () => {
+ deleteAdministratorMock.mockRejectedValueOnce(
+ new Error('Delete failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+ await flushPromises()
+
+ expect(window.alert).toHaveBeenCalledWith(
+ 'Failed to delete administrator: Delete failed'
+ )
+ })
+
+ it('formats the created date', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Jan')
+ expect(wrapper.text()).toContain('2024')
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/SettingsConfigs.spec.js b/tests/Unit/assets/vue/components/settings/SettingsConfigs.spec.js
new file mode 100644
index 0000000..ba695ed
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/SettingsConfigs.spec.js
@@ -0,0 +1,194 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import SettingsConfigs from '../../../../../../assets/vue/components/settings/SettingsConfigs.vue'
+
+const {
+ getConfigsMock,
+ updateMock,
+} = vi.hoisted(() => ({
+ getConfigsMock: vi.fn(),
+ updateMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ default: {},
+ configClient: {
+ getConfigs: getConfigsMock,
+ update: updateMock,
+ },
+}))
+
+const mountComponent = () => mount(SettingsConfigs)
+
+describe('SettingsConfigs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ vi.useFakeTimers()
+
+ getConfigsMock.mockResolvedValue({
+ items: [
+ {
+ key: 'site_name',
+ value: 'Newsletter',
+ editable: true,
+ description: 'Site title',
+ },
+ {
+ key: 'version',
+ value: '1.0.0',
+ editable: false,
+ },
+ ],
+ })
+
+ updateMock.mockImplementation((key, value) =>
+ Promise.resolve({
+ key,
+ value,
+ editable: true,
+ })
+ )
+
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
+ })
+
+ it('loads configs on mount', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(getConfigsMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('site_name')
+ expect(wrapper.text()).toContain('version')
+ })
+
+ it('refreshes configs', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ getConfigsMock.mockClear()
+
+ const refresh = wrapper
+ .findAll('button')
+ .find(b => b.text() === 'Refresh')
+
+ await refresh.trigger('click')
+ await flushPromises()
+
+ expect(getConfigsMock).toHaveBeenCalled()
+ })
+
+ it('filters configs', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ await wrapper.find('input[type="search"]').setValue('site')
+
+ expect(wrapper.text()).toContain('site_name')
+ expect(wrapper.text()).not.toContain('version')
+ })
+
+ it('saves a config', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const valueInput = wrapper.find('input[aria-label="Value for site_name"]')
+
+ await valueInput.setValue('New Name')
+
+ const saveButton = wrapper
+ .findAll('button')
+ .find(b => b.text() === 'Save')
+
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(updateMock).toHaveBeenCalledWith(
+ 'site_name',
+ 'New Name'
+ )
+
+ expect(wrapper.text()).toContain('Saved')
+ })
+
+ it('shows save error', async () => {
+ updateMock.mockRejectedValueOnce(
+ new Error('Update failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const saveButton = wrapper
+ .findAll('button')
+ .find(b => b.text() === 'Save')
+
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Update failed')
+ expect(console.error).toHaveBeenCalled()
+ })
+
+ it('resets edited value', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const valueInput = wrapper.find('input[aria-label="Value for site_name"]')
+
+ await valueInput.setValue('Changed')
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find(b => b.text() === 'Reset')
+
+ await resetButton.trigger('click')
+
+ expect(valueInput.element.value).toBe('Newsletter')
+ })
+
+ it('shows empty state', async () => {
+ getConfigsMock.mockResolvedValueOnce({
+ items: [],
+ })
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('No configuration keys found.')
+ })
+
+ it('shows load error', async () => {
+ getConfigsMock.mockRejectedValueOnce(
+ new Error('Load failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Load failed')
+ expect(console.error).toHaveBeenCalled()
+ })
+
+ it('renders readonly configs as disabled', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const input = wrapper.find('input[aria-label="Value for version"]')
+
+ expect(input.attributes('readonly')).toBeDefined()
+ })
+})
diff --git a/tests/Unit/assets/vue/components/settings/SettingsSubscriberAttributes.spec.js b/tests/Unit/assets/vue/components/settings/SettingsSubscriberAttributes.spec.js
new file mode 100644
index 0000000..491210a
--- /dev/null
+++ b/tests/Unit/assets/vue/components/settings/SettingsSubscriberAttributes.spec.js
@@ -0,0 +1,199 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import SettingsSubscriberAttributes from '../../../../../../assets/vue/components/settings/SettingsSubscriberAttributes.vue'
+
+const {
+ getAttributeDefinitionsMock,
+ deleteAttributeDefinitionMock,
+} = vi.hoisted(() => ({
+ getAttributeDefinitionsMock: vi.fn(),
+ deleteAttributeDefinitionMock: vi.fn(),
+}))
+
+vi.mock('../../../../../../assets/vue/api', () => ({
+ subscriberAttributesClient: {
+ getAttributeDefinitions: getAttributeDefinitionsMock,
+ deleteAttributeDefinition: deleteAttributeDefinitionMock,
+ },
+}))
+
+vi.mock('../base/BaseIcon.vue', () => ({
+ default: {
+ template: '',
+ },
+}))
+
+vi.mock('./CreateSubscriberAttributeModal.vue', () => ({
+ default: defineComponent({
+ name: 'CreateSubscriberAttributeModal',
+ props: ['isOpen'],
+ emits: ['close', 'created'],
+ template: '',
+ }),
+}))
+
+vi.mock('./EditSubscriberAttributeModal.vue', () => ({
+ default: defineComponent({
+ name: 'EditSubscriberAttributeModal',
+ props: ['isOpen', 'attribute'],
+ emits: ['close', 'updated'],
+ template: '',
+ }),
+}))
+
+const mountComponent = () => mount(SettingsSubscriberAttributes)
+
+describe('SettingsSubscriberAttributes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ getAttributeDefinitionsMock.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ name: 'Email',
+ type: 'textline',
+ required: true,
+ },
+ {
+ id: 2,
+ name: 'Country',
+ type: 'select',
+ required: false,
+ },
+ ],
+ })
+
+ deleteAttributeDefinitionMock.mockResolvedValue()
+
+ vi.spyOn(window, 'confirm').mockImplementation(() => true)
+ vi.spyOn(window, 'alert').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('loads attributes on mount', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(getAttributeDefinitionsMock).toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Email')
+ expect(wrapper.text()).toContain('Country')
+ })
+
+ it('shows empty state', async () => {
+ getAttributeDefinitionsMock.mockResolvedValueOnce({
+ items: [],
+ })
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('No attributes found.')
+ })
+
+ it('shows load error', async () => {
+ getAttributeDefinitionsMock.mockRejectedValueOnce(
+ new Error('Load failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Load failed')
+ })
+
+ it('opens create modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const addButton = wrapper
+ .findAll('button')
+ .find(button => button.text().includes('Add Attribute'))
+
+ await addButton.trigger('click')
+
+ const modal = wrapper.findComponent({ name: 'CreateSubscriberAttributeModal' })
+
+ expect(modal.exists()).toBe(true)
+ expect(modal.props('isOpen')).toBe(true)
+ })
+
+ it('opens edit modal', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const editButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Edit')
+
+ await editButton.trigger('click')
+
+ const modal = wrapper.findComponent({ name: 'EditSubscriberAttributeModal' })
+
+ expect(modal.exists()).toBe(true)
+ expect(modal.props('isOpen')).toBe(true)
+ expect(modal.props('attribute')).toEqual({
+ id: 1,
+ name: 'Email',
+ type: 'textline',
+ required: true,
+ })
+ })
+
+ it('deletes an attribute after confirmation', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(window.confirm).toHaveBeenCalled()
+ expect(deleteAttributeDefinitionMock).toHaveBeenCalledWith(1)
+ expect(getAttributeDefinitionsMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not delete when confirmation is cancelled', async () => {
+ window.confirm.mockReturnValueOnce(false)
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+
+ expect(deleteAttributeDefinitionMock).not.toHaveBeenCalled()
+ })
+
+ it('shows alert when delete fails', async () => {
+ deleteAttributeDefinitionMock.mockRejectedValueOnce(
+ new Error('Delete failed')
+ )
+
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ const deleteButton = wrapper
+ .findAll('button')
+ .find(button => button.text() === 'Delete')
+
+ await deleteButton.trigger('click')
+ await flushPromises()
+
+ expect(window.alert).toHaveBeenCalledWith('Delete failed')
+ expect(console.error).toHaveBeenCalled()
+ })
+})
diff --git a/assets/vue/components/sidebar/AppSidebar.spec.js b/tests/Unit/assets/vue/components/sidebar/AppSidebar.spec.js
similarity index 96%
rename from assets/vue/components/sidebar/AppSidebar.spec.js
rename to tests/Unit/assets/vue/components/sidebar/AppSidebar.spec.js
index 9a07666..619065e 100644
--- a/assets/vue/components/sidebar/AppSidebar.spec.js
+++ b/tests/Unit/assets/vue/components/sidebar/AppSidebar.spec.js
@@ -3,12 +3,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
-import AppSidebar from './AppSidebar.vue'
+import AppSidebar from '../../../../../../assets/vue/components/sidebar/AppSidebar.vue'
const isSidebarOpen = ref(false)
const closeSidebar = vi.fn()
-vi.mock('../../composables/useSidebar', () => ({
+vi.mock('../../../../../../assets/vue/composables/useSidebar', () => ({
useSidebar: () => ({
isSidebarOpen,
closeSidebar,
diff --git a/assets/vue/components/sidebar/SidebarLogo.spec.js b/tests/Unit/assets/vue/components/sidebar/SidebarLogo.spec.js
similarity index 92%
rename from assets/vue/components/sidebar/SidebarLogo.spec.js
rename to tests/Unit/assets/vue/components/sidebar/SidebarLogo.spec.js
index dff246f..84270cd 100644
--- a/assets/vue/components/sidebar/SidebarLogo.spec.js
+++ b/tests/Unit/assets/vue/components/sidebar/SidebarLogo.spec.js
@@ -2,9 +2,9 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import SidebarLogo from './SidebarLogo.vue'
+import SidebarLogo from '../../../../../../assets/vue/components/sidebar/SidebarLogo.vue'
-vi.mock('../../../images/logo-48.png', () => ({
+vi.mock('../../../../../../assets/images/logo-48.png', () => ({
default: '/mock-logo.png',
}))
diff --git a/assets/vue/components/sidebar/SidebarNavItem.spec.js b/tests/Unit/assets/vue/components/sidebar/SidebarNavItem.spec.js
similarity index 96%
rename from assets/vue/components/sidebar/SidebarNavItem.spec.js
rename to tests/Unit/assets/vue/components/sidebar/SidebarNavItem.spec.js
index fe1b03e..2cc5fac 100644
--- a/assets/vue/components/sidebar/SidebarNavItem.spec.js
+++ b/tests/Unit/assets/vue/components/sidebar/SidebarNavItem.spec.js
@@ -2,11 +2,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import SidebarNavItem from './SidebarNavItem.vue'
+import SidebarNavItem from '../../../../../../assets/vue/components/sidebar/SidebarNavItem.vue'
const closeSidebar = vi.fn()
-vi.mock('../../composables/useSidebar', () => ({
+vi.mock('../../../../../../assets/vue/composables/useSidebar', () => ({
useSidebar: () => ({
closeSidebar,
}),
diff --git a/assets/vue/components/sidebar/SidebarNavSection.spec.js b/tests/Unit/assets/vue/components/sidebar/SidebarNavSection.spec.js
similarity index 95%
rename from assets/vue/components/sidebar/SidebarNavSection.spec.js
rename to tests/Unit/assets/vue/components/sidebar/SidebarNavSection.spec.js
index 6ed774c..ed8cfc1 100644
--- a/assets/vue/components/sidebar/SidebarNavSection.spec.js
+++ b/tests/Unit/assets/vue/components/sidebar/SidebarNavSection.spec.js
@@ -2,7 +2,7 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
-import SidebarNavSection from './SidebarNavSection.vue'
+import SidebarNavSection from '../../../../../../assets/vue/components/sidebar/SidebarNavSection.vue'
const SidebarNavItemStub = {
name: 'SidebarNavItem',
diff --git a/assets/vue/components/subscribers/ImportResult.spec.js b/tests/Unit/assets/vue/components/subscribers/ImportResult.spec.js
similarity index 97%
rename from assets/vue/components/subscribers/ImportResult.spec.js
rename to tests/Unit/assets/vue/components/subscribers/ImportResult.spec.js
index cdf98bf..e00f120 100644
--- a/assets/vue/components/subscribers/ImportResult.spec.js
+++ b/tests/Unit/assets/vue/components/subscribers/ImportResult.spec.js
@@ -2,7 +2,7 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
-import ImportResult from './ImportResult.vue'
+import ImportResult from '../../../../../../assets/vue/components/subscribers/ImportResult.vue'
const BaseIconStub = {
name: 'BaseIcon',
diff --git a/assets/vue/components/subscribers/SubscriberDirectory.spec.js b/tests/Unit/assets/vue/components/subscribers/SubscriberDirectory.spec.js
similarity index 96%
rename from assets/vue/components/subscribers/SubscriberDirectory.spec.js
rename to tests/Unit/assets/vue/components/subscribers/SubscriberDirectory.spec.js
index e0689a2..b1c99cc 100644
--- a/assets/vue/components/subscribers/SubscriberDirectory.spec.js
+++ b/tests/Unit/assets/vue/components/subscribers/SubscriberDirectory.spec.js
@@ -2,10 +2,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import SubscriberDirectory from './SubscriberDirectory.vue'
-import { backendFetch, subscribersClient } from '../../api'
+import SubscriberDirectory from '../../../../../../assets/vue/components/subscribers/SubscriberDirectory.vue'
+import { backendFetch, subscribersClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
backendFetch: vi.fn(),
subscribersClient: {
importSubscribers: vi.fn(),
diff --git a/assets/vue/components/subscribers/SubscriberFilters.spec.js b/tests/Unit/assets/vue/components/subscribers/SubscriberFilters.spec.js
similarity index 94%
rename from assets/vue/components/subscribers/SubscriberFilters.spec.js
rename to tests/Unit/assets/vue/components/subscribers/SubscriberFilters.spec.js
index 9cca47e..654e6dc 100644
--- a/assets/vue/components/subscribers/SubscriberFilters.spec.js
+++ b/tests/Unit/assets/vue/components/subscribers/SubscriberFilters.spec.js
@@ -1,8 +1,8 @@
// SubscriberFilters.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
-import SubscriberFilters from './SubscriberFilters.vue'
-import { subscriberFilters } from './subscriberFilters'
+import SubscriberFilters from '../../../../../../assets/vue/components/subscribers/SubscriberFilters.vue'
+import { subscriberFilters } from '../../../../../../assets/vue/components/subscribers/subscriberFilters'
describe('SubscriberFilters', () => {
const createWrapper = () =>
diff --git a/assets/vue/components/subscribers/SubscriberModal.spec.js b/tests/Unit/assets/vue/components/subscribers/SubscriberModal.spec.js
similarity index 97%
rename from assets/vue/components/subscribers/SubscriberModal.spec.js
rename to tests/Unit/assets/vue/components/subscribers/SubscriberModal.spec.js
index 8608e66..9c4c244 100644
--- a/assets/vue/components/subscribers/SubscriberModal.spec.js
+++ b/tests/Unit/assets/vue/components/subscribers/SubscriberModal.spec.js
@@ -2,10 +2,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import SubscriberModal from './SubscriberModal.vue'
-import { subscribersClient } from '../../api'
+import SubscriberModal from '../../../../../../assets/vue/components/subscribers/SubscriberModal.vue'
+import { subscribersClient } from '../../../../../../assets/vue/api'
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
subscribersClient: {
getSubscriber: vi.fn(),
updateSubscriber: vi.fn(),
diff --git a/assets/vue/components/subscribers/SubscriberTable.spec.js b/tests/Unit/assets/vue/components/subscribers/SubscriberTable.spec.js
similarity index 97%
rename from assets/vue/components/subscribers/SubscriberTable.spec.js
rename to tests/Unit/assets/vue/components/subscribers/SubscriberTable.spec.js
index 2203f71..dbf4451 100644
--- a/assets/vue/components/subscribers/SubscriberTable.spec.js
+++ b/tests/Unit/assets/vue/components/subscribers/SubscriberTable.spec.js
@@ -2,7 +2,7 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
-import SubscriberTable from './SubscriberTable.vue'
+import SubscriberTable from '../../../../../../assets/vue/components/subscribers/SubscriberTable.vue'
const BaseIconStub = {
name: 'BaseIcon',
diff --git a/assets/vue/components/templates/TemplateLibrary.spec.js b/tests/Unit/assets/vue/components/templates/TemplateLibrary.spec.js
similarity index 98%
rename from assets/vue/components/templates/TemplateLibrary.spec.js
rename to tests/Unit/assets/vue/components/templates/TemplateLibrary.spec.js
index 3ce49a7..a6adc8e 100644
--- a/assets/vue/components/templates/TemplateLibrary.spec.js
+++ b/tests/Unit/assets/vue/components/templates/TemplateLibrary.spec.js
@@ -2,8 +2,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import TemplateLibrary from './TemplateLibrary.vue'
-import { templateClient } from '../../api'
+import TemplateLibrary from '../../../../../../assets/vue/components/templates/TemplateLibrary.vue'
+import { templateClient } from '../../../../../../assets/vue/api'
const pushMock = vi.fn()
@@ -13,7 +13,7 @@ vi.mock('vue-router', () => ({
}),
}))
-vi.mock('../../api', () => ({
+vi.mock('../../../../../../assets/vue/api', () => ({
templateClient: {
getTemplates: vi.fn(),
deleteTemplate: vi.fn(),
diff --git a/assets/vue/layouts/AdminLayout.spec.js b/tests/Unit/assets/vue/layouts/AdminLayout.spec.js
similarity index 97%
rename from assets/vue/layouts/AdminLayout.spec.js
rename to tests/Unit/assets/vue/layouts/AdminLayout.spec.js
index ea0bd93..4f1806c 100644
--- a/assets/vue/layouts/AdminLayout.spec.js
+++ b/tests/Unit/assets/vue/layouts/AdminLayout.spec.js
@@ -2,18 +2,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import AdminLayout from './AdminLayout.vue'
-import { backendFetch, subscribersClient, campaignClient } from '../api'
+import AdminLayout from '../../../../../assets/vue/layouts/AdminLayout.vue'
+import { backendFetch, subscribersClient, campaignClient } from '../../../../../assets/vue/api'
const openSidebar = vi.fn()
-vi.mock('../composables/useSidebar', () => ({
+vi.mock('../../../../../assets/vue/composables/useSidebar', () => ({
useSidebar: () => ({
openSidebar,
}),
}))
-vi.mock('../api', () => ({
+vi.mock('../../../../../assets/vue/api', () => ({
backendFetch: vi.fn(),
subscribersClient: {
getSubscribers: vi.fn(),
diff --git a/tests/Unit/assets/vue/views/AnalyticsView.spec.js b/tests/Unit/assets/vue/views/AnalyticsView.spec.js
new file mode 100644
index 0000000..1734abe
--- /dev/null
+++ b/tests/Unit/assets/vue/views/AnalyticsView.spec.js
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+vi.mock('vue3-apexcharts', () => ({
+ default: {
+ name: 'VueApexCharts',
+ template: '',
+ },
+}))
+
+const statisticsClient = {
+ getCampaignStatistics: vi.fn(),
+ getStatisticsOfViewOpens: vi.fn(),
+ getTopDomains: vi.fn(),
+ getDomainConfirmationStatistics: vi.fn(),
+ getTopLocalParts: vi.fn(),
+}
+
+vi.mock('../../../../../assets/vue/api', () => ({
+ statisticsClient,
+}))
+
+const layoutStub = {
+ name: 'AdminLayout',
+ template: '
',
+}
+
+describe('AnalyticsView', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ vi.clearAllMocks()
+ })
+
+ it('loads analytics data from the statistics client and renders summary cards', async () => {
+ statisticsClient.getCampaignStatistics.mockResolvedValue({
+ items: [
+ {
+ campaignId: 7,
+ subject: 'Summer launch',
+ dateSent: '2026-06-01',
+ sent: 200,
+ bounces: 4,
+ uniqueViews: 120,
+ totalClicks: 33,
+ },
+ ],
+ })
+ statisticsClient.getStatisticsOfViewOpens.mockResolvedValue({
+ items: [
+ {
+ campaignId: 7,
+ subject: 'Summer launch',
+ sent: 200,
+ uniqueViews: 120,
+ rate: 60,
+ },
+ ],
+ })
+ statisticsClient.getTopDomains.mockResolvedValue({
+ items: [{ domain: 'example.com', subscribers: 42 }],
+ })
+ statisticsClient.getDomainConfirmationStatistics.mockResolvedValue({
+ domain: 'example.com',
+ total: 100,
+ confirmed: 80,
+ unconfirmed: 20,
+ confirmationRate: 80,
+ })
+ statisticsClient.getTopLocalParts.mockResolvedValue({
+ items: [{ localPart: 'alex', count: 12, percentage: 24 }],
+ })
+
+ vi.resetModules()
+
+ const { default: AnalyticsView } = await import('../../../../../assets/vue/views/AnalyticsView.vue')
+
+ const wrapper = mount(AnalyticsView, {
+ global: {
+ stubs: {
+ AdminLayout: layoutStub,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(statisticsClient.getCampaignStatistics).toHaveBeenCalledWith(null, 100)
+ expect(wrapper.text()).toContain('Campaigns tracked')
+ expect(wrapper.text()).toContain('Summer launch')
+ expect(wrapper.text()).toContain('example.com')
+ expect(wrapper.text()).toContain('alex')
+ expect(wrapper.text()).toContain('80.0%')
+ })
+})
diff --git a/tests/Unit/assets/vue/views/BouncesView.spec.js b/tests/Unit/assets/vue/views/BouncesView.spec.js
new file mode 100644
index 0000000..880732b
--- /dev/null
+++ b/tests/Unit/assets/vue/views/BouncesView.spec.js
@@ -0,0 +1,34 @@
+import { mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import BouncesView from '../../../../../assets/vue/views/BouncesView.vue'
+
+vi.mock('../../../../../assets/vue/layouts/AdminLayout.vue', () => ({
+ default: defineComponent({
+ name: 'AdminLayout',
+ template: '
',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/bounces/BouncesActionsPanel.vue', () => ({
+ default: defineComponent({
+ name: 'BouncesActionsPanel',
+ template: 'Bounces Actions Panel
',
+ }),
+}))
+
+const mountComponent = () => mount(BouncesView)
+
+describe('BouncesView', () => {
+ it('renders the admin layout', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.findComponent({ name: 'AdminLayout' }).exists()).toBe(true)
+ })
+
+ it('renders the bounces actions panel inside the layout', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.findComponent({ name: 'BouncesActionsPanel' }).exists()).toBe(true)
+ expect(wrapper.text()).toContain('Bounces Actions Panel')
+ })
+})
diff --git a/tests/Unit/assets/vue/views/CampaignEditView.spec.js b/tests/Unit/assets/vue/views/CampaignEditView.spec.js
new file mode 100644
index 0000000..14e2e8f
--- /dev/null
+++ b/tests/Unit/assets/vue/views/CampaignEditView.spec.js
@@ -0,0 +1,1069 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import CampaignEditView from '../../../../../assets/vue/views/CampaignEditView.vue'
+
+const {
+ routeMock,
+ pushMock,
+ replaceMock,
+ campaignClientMock,
+ listMessagesClientMock,
+ templateClientMock,
+ fetchAllListsMock,
+} = vi.hoisted(() => ({
+ routeMock: {
+ name: 'campaign-create',
+ params: { campaignId: '' },
+ query: {},
+ },
+ pushMock: vi.fn(() => Promise.resolve()),
+ replaceMock: vi.fn(() => Promise.resolve()),
+ campaignClientMock: {
+ getCampaign: vi.fn(),
+ createCampaign: vi.fn(),
+ updateCampaign: vi.fn(),
+ updateCampaignStatus: vi.fn(),
+ testSendCampaign: vi.fn(),
+ },
+ listMessagesClientMock: {
+ getListsByMessage: vi.fn(),
+ associateMessageWithList: vi.fn(),
+ dissociateMessageFromList: vi.fn(),
+ },
+ templateClientMock: {
+ getTemplates: vi.fn(),
+ },
+ fetchAllListsMock: vi.fn(),
+}))
+
+vi.mock('vue-router', () => ({
+ useRoute: () => routeMock,
+ useRouter: () => ({
+ push: pushMock,
+ replace: replaceMock,
+ }),
+ RouterLink: defineComponent({
+ name: 'RouterLink',
+ props: ['to'],
+ template: '',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/layouts/AdminLayout.vue', () => ({
+ default: defineComponent({
+ name: 'AdminLayout',
+ template: '
',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/base/CkEditorField.vue', () => ({
+ default: defineComponent({
+ name: 'CkEditorField',
+ emits: ['update:modelValue'],
+ props: ['id', 'label', 'modelValue'],
+ template: `
+
+
+
+ `,
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/api', () => ({
+ campaignClient: campaignClientMock,
+ listMessagesClient: listMessagesClientMock,
+ templateClient: templateClientMock,
+ fetchAllLists: fetchAllListsMock,
+}))
+
+const mountComponent = () => mount(CampaignEditView)
+
+describe('CampaignEditView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ document.body.innerHTML = ''
+
+ routeMock.name = 'campaign-create'
+ routeMock.params = { campaignId: '' }
+ routeMock.query = {}
+
+ fetchAllListsMock.mockResolvedValue([
+ { id: 1, name: 'Newsletter' },
+ { id: 2, name: 'Promotions' },
+ { id: 3, name: 'Updates' },
+ ])
+
+ templateClientMock.getTemplates.mockResolvedValue({
+ items: [
+ { id: 1, title: 'Template 1' },
+ { id: 2, title: 'Template 2' },
+ ],
+ })
+
+ campaignClientMock.getCampaign.mockResolvedValue({
+ id: 42,
+ messageContent: {
+ subject: 'Existing Campaign',
+ text: 'Hello world',
+ footer: 'Footer text',
+ },
+ messageOptions: {
+ fromField: 'sender@example.com',
+ toField: '[EMAIL]',
+ replyTo: 'reply@example.com',
+ userSelection: '',
+ },
+ messageFormat: {
+ htmlFormated: true,
+ sendFormat: 'html',
+ },
+ messageSchedule: {
+ embargo: null,
+ repeatInterval: null,
+ repeatUntil: null,
+ requeueInterval: null,
+ requeueUntil: null,
+ },
+ messageMetadata: {
+ status: 'draft',
+ },
+ template: { id: 1 },
+ })
+
+ listMessagesClientMock.getListsByMessage.mockResolvedValue({
+ items: [
+ { id: 1, name: 'Newsletter' },
+ { id: 2, name: 'Promotions' },
+ ],
+ })
+
+ campaignClientMock.createCampaign.mockResolvedValue({
+ id: 100,
+ messageContent: {},
+ messageOptions: {},
+ messageFormat: {},
+ messageSchedule: {},
+ messageMetadata: { status: 'draft' },
+ })
+
+ campaignClientMock.updateCampaign.mockResolvedValue({
+ id: 42,
+ messageContent: {},
+ messageOptions: {},
+ messageFormat: {},
+ messageSchedule: {},
+ messageMetadata: { status: 'draft' },
+ })
+
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ describe('Create Mode', () => {
+ it('renders create page title in create mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Create Campaign')
+ })
+
+ it('loads mailing lists in create mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(fetchAllListsMock).toHaveBeenCalled()
+
+ // Navigate to lists tab to see the lists
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Newsletter')
+ expect(wrapper.text()).toContain('Promotions')
+ expect(wrapper.text()).toContain('Updates')
+ })
+
+ it('initializes form with default values in create mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const subjectInput = wrapper.find('#campaign-subject')
+ expect(subjectInput.element.value).toBe('')
+ })
+ })
+
+ describe('Edit Mode', () => {
+ beforeEach(() => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+ })
+
+ it('renders edit page title in edit mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Edit Campaign #42')
+ })
+
+ it('loads campaign data in edit mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(campaignClientMock.getCampaign).toHaveBeenCalledWith(42)
+ // Check that the form was populated
+ expect(wrapper.vm.form.subject).toBe('Existing Campaign')
+ })
+
+ it('populates form with campaign data', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const subjectInput = wrapper.find('#campaign-subject')
+ expect(subjectInput.element.value).toBe('Existing Campaign')
+ })
+
+ it('loads associated lists for the campaign', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(listMessagesClientMock.getListsByMessage).toHaveBeenCalledWith(42)
+ })
+
+ it('displays error message when campaign loading fails', async () => {
+ campaignClientMock.getCampaign.mockRejectedValue(new Error('Failed to load'))
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Failed to load')
+ })
+ })
+
+ describe('Step Navigation', () => {
+ it('renders all step buttons', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('1')
+ expect(wrapper.text()).toContain('2')
+ expect(wrapper.text()).toContain('3')
+ expect(wrapper.text()).toContain('4')
+ expect(wrapper.text()).toContain('5')
+ expect(wrapper.text()).toContain('6')
+ })
+
+ it('starts on step 1', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Content')
+ })
+
+ it('navigates to next step when next button is clicked', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const nextButton = wrapper.findAll('button').find(
+ (btn) => btn.text().trim() === 'Next'
+ )
+ await nextButton.trigger('click')
+
+ expect(wrapper.text()).toContain('Format')
+ })
+
+ it('navigates to previous step when back button is clicked', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Go to step 2
+ let nextButton = wrapper.findAll('button').find(
+ (btn) => btn.text().trim() === 'Next'
+ )
+ await nextButton.trigger('click')
+
+ // Go back to step 1
+ let backButton = wrapper.findAll('button').find(
+ (btn) => btn.text().trim() === 'Back'
+ )
+ await backButton.trigger('click')
+
+ expect(wrapper.text()).toContain('Content')
+ })
+
+ it('navigates to specific step when step button is clicked', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+
+ expect(wrapper.text()).toContain('Lists')
+ })
+
+ it('disables back button on first step', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const backButton = wrapper.findAll('button').find(
+ (btn) => btn.text().trim() === 'Back'
+ )
+ expect(backButton.attributes('disabled')).toBeDefined()
+ })
+
+ it('updates route query when changing steps', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await flushPromises()
+
+ expect(replaceMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: expect.objectContaining({
+ section: 'format',
+ }),
+ })
+ )
+ })
+ })
+
+ describe('Content Tab (Step 1)', () => {
+ it('displays subject input field', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const subjectInput = wrapper.find('#campaign-subject')
+ expect(subjectInput.exists()).toBe(true)
+ })
+
+ it('displays compose mode selection radio buttons', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Send a webpage')
+ expect(wrapper.text()).toContain('Compose message')
+ })
+
+ it('shows webpage URL field when webpage mode is selected', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const webpageRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'webpage'
+ )
+ await webpageRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ const urlInput = wrapper.find('#campaign-webpage-url')
+ expect(urlInput.exists()).toBe(true)
+ })
+
+ it('shows compose HTML editor when compose mode is selected', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const composeRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'compose'
+ )
+ await composeRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findComponent({ name: 'CkEditorField' }).exists()).toBe(true)
+ })
+
+ it('displays footer textarea', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const footerInput = wrapper.find('#campaign-footer')
+ expect(footerInput.exists()).toBe(true)
+ })
+ })
+
+ describe('Format Tab (Step 2)', () => {
+ beforeEach(async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+ })
+
+ it('displays template dropdown', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const templateSelect = wrapper.find('#campaign-template')
+ expect(templateSelect.exists()).toBe(true)
+ })
+
+ it('displays send format radio buttons', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('HTML')
+ expect(wrapper.text()).toContain('Text')
+ expect(wrapper.text()).toContain('Invite')
+ })
+
+ it('displays HTML formatted checkbox', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('HTML formatted')
+ })
+ })
+
+ describe('Scheduling Tab (Step 3)', () => {
+ it('displays embargo date/time input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step3Button = stepButtons.find((btn) => btn.text().includes('3'))
+ await step3Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const embargoInput = wrapper.find('#campaign-embargo')
+ expect(embargoInput.exists()).toBe(true)
+ })
+
+ it('displays repeat interval input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step3Button = stepButtons.find((btn) => btn.text().includes('3'))
+ await step3Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const repeatInput = wrapper.find('#campaign-repeat-interval')
+ expect(repeatInput.exists()).toBe(true)
+ })
+
+ it('displays repeat until date/time input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step3Button = stepButtons.find((btn) => btn.text().includes('3'))
+ await step3Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const repeatUntilInput = wrapper.find('#campaign-repeat-until')
+ expect(repeatUntilInput.exists()).toBe(true)
+ })
+
+ it('displays requeue interval and requeue until inputs', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step3Button = stepButtons.find((btn) => btn.text().includes('3'))
+ await step3Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const requeueIntervalInput = wrapper.find('#campaign-requeue-interval')
+ const requeueUntilInput = wrapper.find('#campaign-requeue-until')
+
+ expect(requeueIntervalInput.exists()).toBe(true)
+ expect(requeueUntilInput.exists()).toBe(true)
+ })
+ })
+
+ describe('Lists Tab (Step 4)', () => {
+ it('displays mailing lists with checkboxes', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Newsletter')
+ expect(wrapper.text()).toContain('Promotions')
+ expect(wrapper.text()).toContain('Updates')
+ })
+
+ it('allows selecting and deselecting lists', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const checkboxes = wrapper.findAll('input[type="checkbox"]')
+ expect(checkboxes.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Test Send Tab (Step 5)', () => {
+ it('displays test recipients textarea', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step5Button = stepButtons.find((btn) => btn.text().includes('5'))
+ await step5Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const testRecipientsInput = wrapper.find('#campaign-test-recipients')
+ expect(testRecipientsInput.exists()).toBe(true)
+ })
+
+ it('displays send test button', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step5Button = stepButtons.find((btn) => btn.text().includes('5'))
+ await step5Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const sendTestButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Send test')
+ )
+ expect(sendTestButton.exists()).toBe(true)
+ })
+
+ it('sends test campaign when button is clicked', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step5Button = stepButtons.find((btn) => btn.text().includes('5'))
+ await step5Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Fill test recipients
+ const testRecipientsInput = wrapper.find('#campaign-test-recipients')
+ await testRecipientsInput.setValue('test@example.com')
+
+ // Click send test button
+ const sendTestButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Send test')
+ )
+ await sendTestButton.trigger('click')
+ await flushPromises()
+
+ expect(campaignClientMock.testSendCampaign).toHaveBeenCalledWith(42, ['test@example.com'])
+ })
+
+ it('shows error when no test recipients are provided', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step5Button = stepButtons.find((btn) => btn.text().includes('5'))
+ await step5Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Click send test button without filling recipients
+ const sendTestButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Send test')
+ )
+ await sendTestButton.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Please enter at least one test recipient email')
+ })
+ })
+
+ describe('Finish Tab (Step 6)', () => {
+ it('displays from field input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const fromInput = wrapper.find('#campaign-from')
+ expect(fromInput.exists()).toBe(true)
+ })
+
+ it('displays reply-to field input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const replyToInput = wrapper.find('#campaign-reply-to')
+ expect(replyToInput.exists()).toBe(true)
+ })
+
+ it('displays to field input', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const toFieldInput = wrapper.find('#campaign-to-field')
+ expect(toFieldInput.exists()).toBe(true)
+ })
+
+ it('displays campaign ready message when no warnings', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Select a list first to avoid warning
+ const stepButtons4 = wrapper.findAll('button')
+ const step4Button = stepButtons4.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const listCheckboxes = wrapper.findAll('input[type="checkbox"]')
+ if (listCheckboxes.length > 0) {
+ await listCheckboxes[0].setValue(true)
+ }
+
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Campaign is ready to save')
+ })
+ })
+
+ describe('Warnings', () => {
+ it('shows warning when no lists are selected', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Destination lists are missing')
+ })
+
+ it('shows warning when webpage URL is missing in webpage mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const webpageRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'webpage'
+ )
+ await webpageRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Webpage URL is required')
+ })
+
+ it('shows warning when compose HTML is empty in compose mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const composeRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'compose'
+ )
+ await composeRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Compose message mode requires HTML or text content')
+ })
+ })
+
+ describe('Save Campaign', () => {
+ it('creates new campaign when in create mode', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Fill in required fields
+ const subjectInput = wrapper.find('#campaign-subject')
+ await subjectInput.setValue('Test Campaign')
+
+ // Switch to compose mode
+ const composeRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'compose'
+ )
+ await composeRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ // Find and fill the CkEditorField (using the mock textarea)
+ const ckEditor = wrapper.findComponent({ name: 'CkEditorField' })
+ await ckEditor.vm.$emit('update:modelValue', 'Test content
')
+ await wrapper.vm.$nextTick()
+
+ // Go to lists step and select a list
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const listCheckboxes = wrapper.findAll('input[type="checkbox"]')
+ if (listCheckboxes.length > 0) {
+ await listCheckboxes[0].setValue(true)
+ }
+
+ // Save
+ const saveButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Save and continue editing')
+ )
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(campaignClientMock.createCampaign).toHaveBeenCalled()
+ })
+
+ it('updates existing campaign when in edit mode', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Modify subject
+ const subjectInput = wrapper.find('#campaign-subject')
+ await subjectInput.setValue('Updated Campaign')
+
+ // Save
+ const saveButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Save and continue editing')
+ )
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(campaignClientMock.updateCampaign).toHaveBeenCalledWith(42, expect.any(Object))
+ })
+
+ it('shows save error message when save fails', async () => {
+ campaignClientMock.createCampaign.mockRejectedValue(
+ new Error('Server error')
+ )
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Fill in minimal required fields
+ const subjectInput = wrapper.find('#campaign-subject')
+ await subjectInput.setValue('Test Campaign')
+
+ // Go to step 4 and select a list
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const listCheckboxes = wrapper.findAll('input[type="checkbox"]')
+ if (listCheckboxes.length > 0) {
+ await listCheckboxes[0].setValue(true)
+ }
+
+ // Try to save
+ const saveButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Save and continue editing')
+ )
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Server error')
+ })
+
+ it('shows success message after saving', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const saveButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Save and continue editing')
+ )
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Campaign saved successfully')
+ })
+ })
+
+ describe('Queue Campaign', () => {
+ it('queues campaign to send when button is clicked', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Navigate to finish step
+ const stepButtons = wrapper.findAll('button')
+ const step6Button = stepButtons.find((btn) => btn.text().includes('6'))
+ await step6Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Select lists
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const listCheckboxes = wrapper.findAll('input[type="checkbox"]')
+ if (listCheckboxes.length > 0) {
+ await listCheckboxes[0].setValue(true)
+ }
+
+ // Find queue button
+ const queueButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Place campaign in a queue to send')
+ )
+
+ if (queueButton) {
+ await queueButton.trigger('click')
+ await flushPromises()
+
+ expect(campaignClientMock.updateCampaignStatus).toHaveBeenCalledWith(42, 'submitted')
+ }
+ })
+
+ it('does not show queue button when campaign is already submitted', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ campaignClientMock.getCampaign.mockResolvedValueOnce({
+ id: 42,
+ messageContent: {},
+ messageOptions: {},
+ messageFormat: {},
+ messageSchedule: {},
+ messageMetadata: { status: 'submitted' },
+ })
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const queueButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Place campaign in a queue to send')
+ )
+
+ expect(queueButton).toBeUndefined()
+ })
+ })
+
+ describe('Form State Management', () => {
+ it('switches between webpage and compose modes', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Start in compose mode
+ expect(wrapper.findComponent({ name: 'CkEditorField' }).exists()).toBe(true)
+
+ // Switch to webpage mode
+ const webpageRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'webpage'
+ )
+ await webpageRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ const urlInput = wrapper.find('#campaign-webpage-url')
+ expect(urlInput.exists()).toBe(true)
+ })
+
+ it('caches HTML content when switching modes', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Add HTML content in compose mode
+ const ckEditor = wrapper.findComponent({ name: 'CkEditorField' })
+ await ckEditor.vm.$emit('update:modelValue', 'Cached content
')
+ await wrapper.vm.$nextTick()
+
+ // Switch to webpage mode
+ const webpageRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'webpage'
+ )
+ await webpageRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ // Switch back to compose mode
+ const composeRadio = wrapper.findAll('input[type="radio"]').find(
+ (input) => input.element.value === 'compose'
+ )
+ await composeRadio.setValue(true)
+ await wrapper.vm.$nextTick()
+
+ // Content should be restored (this tests the cache functionality)
+ expect(wrapper.vm.form.composeHtml).toBe('Cached content
')
+ })
+
+ it('extracts webpage URL from [URL:...] token', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ campaignClientMock.getCampaign.mockResolvedValue({
+ id: 42,
+ messageContent: {
+ subject: 'Campaign',
+ text: '[URL:https://example.com/page]',
+ footer: '',
+ },
+ messageOptions: {},
+ messageFormat: { htmlFormated: false, sendFormat: 'html' },
+ messageSchedule: {},
+ messageMetadata: { status: 'draft' },
+ })
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.vm.form.webpageUrl).toBe('https://example.com/page')
+ expect(wrapper.vm.form.composeMode).toBe('webpage')
+ })
+ })
+
+ describe('Template Loading', () => {
+ it('loads templates on mount', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(templateClientMock.getTemplates).toHaveBeenCalledWith(0, 1000)
+ })
+
+ it('displays loaded templates in dropdown', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Navigate to format step where template dropdown is shown
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ const templateSelect = wrapper.find('#campaign-template')
+ expect(templateSelect.exists()).toBe(true)
+
+ const options = templateSelect.findAll('option')
+ expect(options.some((opt) => opt.text() === 'Template 1')).toBe(true)
+ expect(options.some((opt) => opt.text() === 'Template 2')).toBe(true)
+ })
+
+ it('handles template loading error gracefully', async () => {
+ templateClientMock.getTemplates.mockRejectedValue(new Error('Failed to load'))
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Navigate to format step
+ const stepButtons = wrapper.findAll('button')
+ const step2Button = stepButtons.find((btn) => btn.text().includes('2'))
+ await step2Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Component should render without templates even if loading fails
+ const templateSelect = wrapper.find('#campaign-template')
+ expect(templateSelect.exists()).toBe(true)
+ })
+ })
+
+ describe('List Synchronization', () => {
+ it('associates selected lists with campaign on save', async () => {
+ routeMock.name = 'campaign-edit'
+ routeMock.params = { campaignId: '42' }
+
+ listMessagesClientMock.getListsByMessage.mockResolvedValue({
+ items: [],
+ })
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Go to lists step
+ const stepButtons = wrapper.findAll('button')
+ const step4Button = stepButtons.find((btn) => btn.text().includes('4'))
+ await step4Button.trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Select a list
+ const listCheckboxes = wrapper.findAll('input[type="checkbox"]')
+ if (listCheckboxes.length > 0) {
+ await listCheckboxes[0].setValue(true)
+ }
+
+ // Save campaign
+ const saveButton = wrapper.findAll('button').find(
+ (btn) => btn.text().includes('Save and continue editing')
+ )
+ await saveButton.trigger('click')
+ await flushPromises()
+
+ expect(listMessagesClientMock.associateMessageWithList).toHaveBeenCalled()
+ })
+ })
+
+ describe('Back to Campaigns Link', () => {
+ it('displays back to campaigns link', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const backLink = wrapper.findComponent({ name: 'RouterLink' })
+ expect(backLink.exists()).toBe(true)
+ expect(backLink.text()).toContain('Back to Campaigns')
+ })
+
+ it('links to campaigns route', async () => {
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const backLink = wrapper.findComponent({ name: 'RouterLink' })
+ expect(backLink.props('to')).toBe('/campaigns')
+ })
+ })
+})
+
diff --git a/tests/Unit/assets/vue/views/CampaignsView.spec.js b/tests/Unit/assets/vue/views/CampaignsView.spec.js
new file mode 100644
index 0000000..a0877bc
--- /dev/null
+++ b/tests/Unit/assets/vue/views/CampaignsView.spec.js
@@ -0,0 +1,48 @@
+// tests/views/CampaignsView.spec.js
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import CampaignsView from '../../../../../assets/vue/views/CampaignsView.vue'
+
+const AdminLayoutStub = {
+ template: `
+
+
+
+ `,
+}
+
+const CampaignDirectoryStub = {
+ template: '',
+}
+
+describe('CampaignsView', () => {
+ it('renders the admin layout', () => {
+ const wrapper = mount(CampaignsView, {
+ global: {
+ stubs: {
+ AdminLayout: AdminLayoutStub,
+ CampaignDirectory: CampaignDirectoryStub,
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ })
+
+ it('renders the campaign directory inside the layout', () => {
+ const wrapper = mount(CampaignsView, {
+ global: {
+ stubs: {
+ AdminLayout: AdminLayoutStub,
+ CampaignDirectory: CampaignDirectoryStub,
+ },
+ },
+ })
+
+ const layout = wrapper.find('[data-test="admin-layout"]')
+
+ expect(layout.exists()).toBe(true)
+ expect(layout.find('[data-test="campaign-directory"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/DashboardView.spec.js b/tests/Unit/assets/vue/views/DashboardView.spec.js
new file mode 100644
index 0000000..70e748f
--- /dev/null
+++ b/tests/Unit/assets/vue/views/DashboardView.spec.js
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+
+vi.mock('../../../../../assets/vue/layouts/AdminLayout.vue', () => ({
+ default: defineComponent({
+ name: 'AdminLayout',
+ template: '
',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/dashboard/KpiGrid.vue', () => ({
+ default: defineComponent({
+ name: 'KpiGrid',
+ template: '',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/dashboard/PerformanceChartCard.vue', () => ({
+ default: defineComponent({
+ name: 'PerformanceChartCard',
+ template: '',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/dashboard/QuickActionsCard.vue', () => ({
+ default: defineComponent({
+ name: 'QuickActionsCard',
+ template: '',
+ }),
+}))
+
+vi.mock('../../../../../assets/vue/components/dashboard/RecentCampaignsCard.vue', () => ({
+ default: defineComponent({
+ name: 'RecentCampaignsCard',
+ template: '',
+ }),
+}))
+
+describe('DashboardView', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('renders the dashboard error banner when provided by the server', async () => {
+ document.body.innerHTML = `
+
+ `
+
+ vi.resetModules()
+
+ const { default: DashboardView } = await import('../../../../../assets/vue/views/DashboardView.vue')
+
+ const wrapper = mount(DashboardView)
+
+ expect(wrapper.text()).toContain('Session expired')
+ expect(wrapper.find('[role="alert"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/ListSubscribersView.spec.js b/tests/Unit/assets/vue/views/ListSubscribersView.spec.js
new file mode 100644
index 0000000..a395630
--- /dev/null
+++ b/tests/Unit/assets/vue/views/ListSubscribersView.spec.js
@@ -0,0 +1,106 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import ListSubscribersView from '../../../../../assets/vue/views/ListSubscribersView.vue'
+import * as api from '../../../../../assets/vue/api'
+
+vi.mock('vue-router', () => ({
+ useRoute: () => ({
+ params: {
+ listId: '1',
+ },
+ query: {
+ listName: 'Newsletter',
+ },
+ }),
+}))
+
+describe('ListSubscribersView', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+
+ vi.spyOn(api.subscriptionClient, 'getSubscriberList').mockResolvedValue({
+ id: 1,
+ name: 'Newsletter',
+ })
+
+ vi.spyOn(api.subscriptionClient, 'getSubscriberLists').mockResolvedValue({
+ items: [],
+ pagination: {
+ hasMore: false,
+ nextCursor: null,
+ },
+ })
+
+ vi.spyOn(api.subscriptionClient, 'getSubscribersOfList').mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ email: 'john@example.com',
+ confirmed: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ ],
+ pagination: {
+ total: 1,
+ nextCursor: null,
+ },
+ })
+ })
+
+ const mountComponent = async () => {
+ const wrapper = mount(ListSubscribersView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: '
',
+ },
+ RouterLink: true,
+ BaseIcon: true,
+ ListSubscribersExportPanel: true,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ return wrapper
+ }
+
+ it('renders subscriber list', async () => {
+ const wrapper = await mountComponent()
+
+ expect(api.subscriptionClient.getSubscribersOfList).toHaveBeenCalled()
+ expect(api.subscriptionClient.getSubscriberList).toHaveBeenCalled()
+ expect(api.subscriptionClient.getSubscriberLists).toHaveBeenCalled()
+
+ expect(wrapper.text()).toContain('Newsletter')
+ expect(wrapper.text()).toContain('john@example.com')
+ expect(wrapper.text()).toContain('Confirmed')
+ })
+
+ it('shows an error when loading subscribers fails', async () => {
+ api.subscriptionClient.getSubscribersOfList.mockRejectedValueOnce(
+ new Error('API error')
+ )
+
+ const wrapper = await mountComponent()
+
+ expect(wrapper.text()).toContain(
+ 'Failed to load subscribers for this list.'
+ )
+ })
+
+ it('shows empty state when there are no subscribers', async () => {
+ api.subscriptionClient.getSubscribersOfList.mockResolvedValueOnce({
+ items: [],
+ pagination: {
+ total: 0,
+ nextCursor: null,
+ },
+ })
+
+ const wrapper = await mountComponent()
+
+ expect(wrapper.text()).toContain('No subscribers for this filter.')
+ })
+})
diff --git a/tests/Unit/assets/vue/views/ListsView.spec.js b/tests/Unit/assets/vue/views/ListsView.spec.js
new file mode 100644
index 0000000..71285ec
--- /dev/null
+++ b/tests/Unit/assets/vue/views/ListsView.spec.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ListView from '../../../../../assets/vue/views/ListsView.vue'
+
+describe('ListView', () => {
+ it('renders the AdminLayout and ListDirectory components', () => {
+ const wrapper = mount(ListView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ ListDirectory: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="list-directory"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/PublicPageEditView.spec.js b/tests/Unit/assets/vue/views/PublicPageEditView.spec.js
new file mode 100644
index 0000000..fb882a5
--- /dev/null
+++ b/tests/Unit/assets/vue/views/PublicPageEditView.spec.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PublicPageEditView from '../../../../../assets/vue/views/PublicPageEditView.vue'
+
+describe('PublicPageView', () => {
+ it('renders the AdminLayout and PublicPageEditor components', () => {
+ const wrapper = mount(PublicPageEditView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ PublicPageEditor: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="public-page-editor"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/PublicPagesView.spec.js b/tests/Unit/assets/vue/views/PublicPagesView.spec.js
new file mode 100644
index 0000000..9522f3b
--- /dev/null
+++ b/tests/Unit/assets/vue/views/PublicPagesView.spec.js
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PublicPagesView from '../../../../../assets/vue/views/PublicPagesView.vue'
+
+describe('PublicPagesView', () => {
+ it('renders the AdminLayout and PublicPagesDirectory components', () => {
+ const wrapper = mount(PublicPagesView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ PublicPagesDirectory: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(
+ wrapper.find('[data-test="public-pages-directory"]').exists()
+ ).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/SettingsView.spec.js b/tests/Unit/assets/vue/views/SettingsView.spec.js
new file mode 100644
index 0000000..0351665
--- /dev/null
+++ b/tests/Unit/assets/vue/views/SettingsView.spec.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import SettingsView from '../../../../../assets/vue/views/SettingsView.vue'
+
+describe('SettingsView', () => {
+ it('renders the AdminLayout and SettingsActionsPanel components', () => {
+ const wrapper = mount(SettingsView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ SettingsActionsPanel: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="settings-actions-panel"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/SubscribersView.spec.js b/tests/Unit/assets/vue/views/SubscribersView.spec.js
new file mode 100644
index 0000000..e04c7d7
--- /dev/null
+++ b/tests/Unit/assets/vue/views/SubscribersView.spec.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import SubscribersView from '../../../../../assets/vue/views/SubscribersView.vue'
+
+describe('SubscribersView', () => {
+ it('renders the AdminLayout and SubscriberDirectory components', () => {
+ const wrapper = mount(SubscribersView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ SubscriberDirectory: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="subscriber-directory"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/Unit/assets/vue/views/TemplateEditorView.spec.js b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js
new file mode 100644
index 0000000..4df6feb
--- /dev/null
+++ b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js
@@ -0,0 +1,113 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import TemplateEditView from '../../../../../assets/vue/views/TemplateEditView.vue'
+import * as api from '../../../../../assets/vue/api'
+
+const mockRoute = {
+ name: 'template-edit',
+ params: {
+ templateId: '1',
+ },
+ query: {},
+}
+
+const replace = vi.fn()
+
+vi.mock('vue-router', () => ({
+ RouterLink: {
+ template: '',
+ },
+ useRoute: () => mockRoute,
+ useRouter: () => ({
+ replace,
+ }),
+}))
+
+describe('TemplateEditView', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+
+ vi.spyOn(api.templateClient, 'getTemplate').mockResolvedValue({
+ id: 1,
+ title: 'Welcome Template',
+ listOrder: 1,
+ content: 'Hello World
',
+ text: 'Hello World',
+ })
+
+ vi.spyOn(api.templateClient, 'updateTemplate').mockResolvedValue({})
+ vi.spyOn(api.templateClient, 'createTemplate').mockResolvedValue({
+ id: 2,
+ })
+ })
+
+ const mountComponent = async () => {
+ const wrapper = mount(TemplateEditView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: '
',
+ },
+ CkEditorField: {
+ props: ['modelValue', 'label'],
+ emits: ['update:modelValue'],
+ template:
+ '',
+ },
+ },
+ },
+ })
+
+ await flushPromises()
+
+ return wrapper
+ }
+
+ it('loads the template in edit mode', async () => {
+ const wrapper = await mountComponent()
+ expect(api.templateClient.getTemplate).toHaveBeenCalledWith(1)
+
+ // The heading shows a generic "Edit Template #" title; the
+ // actual template title is rendered inside the title input below.
+ const title = wrapper.find('#template-title')
+
+ expect(title.element.value).toBe('Welcome Template')
+ })
+
+ it('shows validation when title is empty', async () => {
+ const wrapper = await mountComponent()
+
+ await wrapper.find('#template-title').setValue('')
+ await wrapper.find('form').trigger('submit.prevent')
+
+ expect(wrapper.text()).toContain('Title is required.')
+ })
+
+ it('updates the template', async () => {
+ const wrapper = await mountComponent()
+
+ await wrapper.find('#template-title').setValue('Updated Template')
+
+ await wrapper.find('form').trigger('submit.prevent')
+
+ expect(api.templateClient.updateTemplate).toHaveBeenCalledTimes(1)
+
+ expect(wrapper.text()).toContain(
+ 'Template updated successfully.'
+ )
+ })
+
+ it('creates a template in create mode', async () => {
+ mockRoute.name = 'template-create'
+ mockRoute.params.templateId = ''
+
+ const wrapper = await mountComponent()
+
+ await wrapper.find('#template-title').setValue('New Template')
+
+ await wrapper.find('form').trigger('submit.prevent')
+
+ expect(api.templateClient.createTemplate).toHaveBeenCalledTimes(1)
+ expect(replace).toHaveBeenCalledWith('/templates/2/edit')
+ })
+})
diff --git a/tests/Unit/assets/vue/views/TemplatesView.spec.js b/tests/Unit/assets/vue/views/TemplatesView.spec.js
new file mode 100644
index 0000000..95be121
--- /dev/null
+++ b/tests/Unit/assets/vue/views/TemplatesView.spec.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TemplatesView from '../../../../../assets/vue/views/TemplatesView.vue'
+
+describe('TemplatesView', () => {
+ it('renders the AdminLayout and TemplateLibrary components', () => {
+ const wrapper = mount(TemplatesView, {
+ global: {
+ stubs: {
+ AdminLayout: {
+ template: `
+
+
+
+ `,
+ },
+ TemplateLibrary: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="template-library"]').exists()).toBe(true)
+ })
+})
diff --git a/vitest.config.mjs b/vitest.config.mjs
index dca4365..df36cd0 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -6,6 +6,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
- include: ['assets/vue/**/*.spec.js'],
+ include: ['assets/vue/**/*.spec.js', 'tests/Unit/assets/vue/**/*.spec.js'],
},
})