diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0e19725..b70a07f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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/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/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'],
},
})