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 @@ + + + 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'], }, })