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

Test content

') + await wrapper.vm.$nextTick() + + // Go to lists step and select a list + const stepButtons = wrapper.findAll('button') + const step4Button = stepButtons.find((btn) => btn.text().includes('4')) + await step4Button.trigger('click') + await wrapper.vm.$nextTick() + + const listCheckboxes = wrapper.findAll('input[type="checkbox"]') + if (listCheckboxes.length > 0) { + await listCheckboxes[0].setValue(true) + } + + // Save + const saveButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Save and continue editing') + ) + await saveButton.trigger('click') + await flushPromises() + + expect(campaignClientMock.createCampaign).toHaveBeenCalled() + }) + + it('updates existing campaign when in edit mode', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + const wrapper = mountComponent() + await flushPromises() + + // Modify subject + const subjectInput = wrapper.find('#campaign-subject') + await subjectInput.setValue('Updated Campaign') + + // Save + const saveButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Save and continue editing') + ) + await saveButton.trigger('click') + await flushPromises() + + expect(campaignClientMock.updateCampaign).toHaveBeenCalledWith(42, expect.any(Object)) + }) + + it('shows save error message when save fails', async () => { + campaignClientMock.createCampaign.mockRejectedValue( + new Error('Server error') + ) + + const wrapper = mountComponent() + await flushPromises() + + // Fill in minimal required fields + const subjectInput = wrapper.find('#campaign-subject') + await subjectInput.setValue('Test Campaign') + + // Go to step 4 and select a list + const stepButtons = wrapper.findAll('button') + const step4Button = stepButtons.find((btn) => btn.text().includes('4')) + await step4Button.trigger('click') + await wrapper.vm.$nextTick() + + const listCheckboxes = wrapper.findAll('input[type="checkbox"]') + if (listCheckboxes.length > 0) { + await listCheckboxes[0].setValue(true) + } + + // Try to save + const saveButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Save and continue editing') + ) + await saveButton.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Server error') + }) + + it('shows success message after saving', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + const wrapper = mountComponent() + await flushPromises() + + const saveButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Save and continue editing') + ) + await saveButton.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Campaign saved successfully') + }) + }) + + describe('Queue Campaign', () => { + it('queues campaign to send when button is clicked', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + const wrapper = mountComponent() + await flushPromises() + + // Navigate to finish step + const stepButtons = wrapper.findAll('button') + const step6Button = stepButtons.find((btn) => btn.text().includes('6')) + await step6Button.trigger('click') + await wrapper.vm.$nextTick() + + // Select lists + const step4Button = stepButtons.find((btn) => btn.text().includes('4')) + await step4Button.trigger('click') + await wrapper.vm.$nextTick() + + const listCheckboxes = wrapper.findAll('input[type="checkbox"]') + if (listCheckboxes.length > 0) { + await listCheckboxes[0].setValue(true) + } + + // Find queue button + const queueButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Place campaign in a queue to send') + ) + + if (queueButton) { + await queueButton.trigger('click') + await flushPromises() + + expect(campaignClientMock.updateCampaignStatus).toHaveBeenCalledWith(42, 'submitted') + } + }) + + it('does not show queue button when campaign is already submitted', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + campaignClientMock.getCampaign.mockResolvedValueOnce({ + id: 42, + messageContent: {}, + messageOptions: {}, + messageFormat: {}, + messageSchedule: {}, + messageMetadata: { status: 'submitted' }, + }) + + const wrapper = mountComponent() + await flushPromises() + + const queueButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Place campaign in a queue to send') + ) + + expect(queueButton).toBeUndefined() + }) + }) + + describe('Form State Management', () => { + it('switches between webpage and compose modes', async () => { + const wrapper = mountComponent() + await flushPromises() + + // Start in compose mode + expect(wrapper.findComponent({ name: 'CkEditorField' }).exists()).toBe(true) + + // Switch to webpage mode + const webpageRadio = wrapper.findAll('input[type="radio"]').find( + (input) => input.element.value === 'webpage' + ) + await webpageRadio.setValue(true) + await wrapper.vm.$nextTick() + + const urlInput = wrapper.find('#campaign-webpage-url') + expect(urlInput.exists()).toBe(true) + }) + + it('caches HTML content when switching modes', async () => { + const wrapper = mountComponent() + await flushPromises() + + // Add HTML content in compose mode + const ckEditor = wrapper.findComponent({ name: 'CkEditorField' }) + await ckEditor.vm.$emit('update:modelValue', '

Cached content

') + await wrapper.vm.$nextTick() + + // Switch to webpage mode + const webpageRadio = wrapper.findAll('input[type="radio"]').find( + (input) => input.element.value === 'webpage' + ) + await webpageRadio.setValue(true) + await wrapper.vm.$nextTick() + + // Switch back to compose mode + const composeRadio = wrapper.findAll('input[type="radio"]').find( + (input) => input.element.value === 'compose' + ) + await composeRadio.setValue(true) + await wrapper.vm.$nextTick() + + // Content should be restored (this tests the cache functionality) + expect(wrapper.vm.form.composeHtml).toBe('

Cached content

') + }) + + it('extracts webpage URL from [URL:...] token', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + campaignClientMock.getCampaign.mockResolvedValue({ + id: 42, + messageContent: { + subject: 'Campaign', + text: '[URL:https://example.com/page]', + footer: '', + }, + messageOptions: {}, + messageFormat: { htmlFormated: false, sendFormat: 'html' }, + messageSchedule: {}, + messageMetadata: { status: 'draft' }, + }) + + const wrapper = mountComponent() + await flushPromises() + + expect(wrapper.vm.form.webpageUrl).toBe('https://example.com/page') + expect(wrapper.vm.form.composeMode).toBe('webpage') + }) + }) + + describe('Template Loading', () => { + it('loads templates on mount', async () => { + const wrapper = mountComponent() + await flushPromises() + + expect(templateClientMock.getTemplates).toHaveBeenCalledWith(0, 1000) + }) + + it('displays loaded templates in dropdown', async () => { + const wrapper = mountComponent() + await flushPromises() + + // Navigate to format step where template dropdown is shown + const stepButtons = wrapper.findAll('button') + const step2Button = stepButtons.find((btn) => btn.text().includes('2')) + await step2Button.trigger('click') + await wrapper.vm.$nextTick() + + const templateSelect = wrapper.find('#campaign-template') + expect(templateSelect.exists()).toBe(true) + + const options = templateSelect.findAll('option') + expect(options.some((opt) => opt.text() === 'Template 1')).toBe(true) + expect(options.some((opt) => opt.text() === 'Template 2')).toBe(true) + }) + + it('handles template loading error gracefully', async () => { + templateClientMock.getTemplates.mockRejectedValue(new Error('Failed to load')) + + const wrapper = mountComponent() + await flushPromises() + + // Navigate to format step + const stepButtons = wrapper.findAll('button') + const step2Button = stepButtons.find((btn) => btn.text().includes('2')) + await step2Button.trigger('click') + await wrapper.vm.$nextTick() + + // Component should render without templates even if loading fails + const templateSelect = wrapper.find('#campaign-template') + expect(templateSelect.exists()).toBe(true) + }) + }) + + describe('List Synchronization', () => { + it('associates selected lists with campaign on save', async () => { + routeMock.name = 'campaign-edit' + routeMock.params = { campaignId: '42' } + + listMessagesClientMock.getListsByMessage.mockResolvedValue({ + items: [], + }) + + const wrapper = mountComponent() + await flushPromises() + + // Go to lists step + const stepButtons = wrapper.findAll('button') + const step4Button = stepButtons.find((btn) => btn.text().includes('4')) + await step4Button.trigger('click') + await wrapper.vm.$nextTick() + + // Select a list + const listCheckboxes = wrapper.findAll('input[type="checkbox"]') + if (listCheckboxes.length > 0) { + await listCheckboxes[0].setValue(true) + } + + // Save campaign + const saveButton = wrapper.findAll('button').find( + (btn) => btn.text().includes('Save and continue editing') + ) + await saveButton.trigger('click') + await flushPromises() + + expect(listMessagesClientMock.associateMessageWithList).toHaveBeenCalled() + }) + }) + + describe('Back to Campaigns Link', () => { + it('displays back to campaigns link', async () => { + const wrapper = mountComponent() + await flushPromises() + + const backLink = wrapper.findComponent({ name: 'RouterLink' }) + expect(backLink.exists()).toBe(true) + expect(backLink.text()).toContain('Back to Campaigns') + }) + + it('links to campaigns route', async () => { + const wrapper = mountComponent() + await flushPromises() + + const backLink = wrapper.findComponent({ name: 'RouterLink' }) + expect(backLink.props('to')).toBe('/campaigns') + }) + }) +}) + diff --git a/tests/Unit/assets/vue/views/CampaignsView.spec.js b/tests/Unit/assets/vue/views/CampaignsView.spec.js new file mode 100644 index 0000000..a0877bc --- /dev/null +++ b/tests/Unit/assets/vue/views/CampaignsView.spec.js @@ -0,0 +1,48 @@ +// tests/views/CampaignsView.spec.js + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import CampaignsView from '../../../../../assets/vue/views/CampaignsView.vue' + +const AdminLayoutStub = { + template: ` +
+ +
+ `, +} + +const CampaignDirectoryStub = { + template: '
', +} + +describe('CampaignsView', () => { + it('renders the admin layout', () => { + const wrapper = mount(CampaignsView, { + global: { + stubs: { + AdminLayout: AdminLayoutStub, + CampaignDirectory: CampaignDirectoryStub, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + }) + + it('renders the campaign directory inside the layout', () => { + const wrapper = mount(CampaignsView, { + global: { + stubs: { + AdminLayout: AdminLayoutStub, + CampaignDirectory: CampaignDirectoryStub, + }, + }, + }) + + const layout = wrapper.find('[data-test="admin-layout"]') + + expect(layout.exists()).toBe(true) + expect(layout.find('[data-test="campaign-directory"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/DashboardView.spec.js b/tests/Unit/assets/vue/views/DashboardView.spec.js new file mode 100644 index 0000000..70e748f --- /dev/null +++ b/tests/Unit/assets/vue/views/DashboardView.spec.js @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +vi.mock('../../../../../assets/vue/layouts/AdminLayout.vue', () => ({ + default: defineComponent({ + name: 'AdminLayout', + template: '
', + }), +})) + +vi.mock('../../../../../assets/vue/components/dashboard/KpiGrid.vue', () => ({ + default: defineComponent({ + name: 'KpiGrid', + template: '
', + }), +})) + +vi.mock('../../../../../assets/vue/components/dashboard/PerformanceChartCard.vue', () => ({ + default: defineComponent({ + name: 'PerformanceChartCard', + template: '
', + }), +})) + +vi.mock('../../../../../assets/vue/components/dashboard/QuickActionsCard.vue', () => ({ + default: defineComponent({ + name: 'QuickActionsCard', + template: '
', + }), +})) + +vi.mock('../../../../../assets/vue/components/dashboard/RecentCampaignsCard.vue', () => ({ + default: defineComponent({ + name: 'RecentCampaignsCard', + template: '
', + }), +})) + +describe('DashboardView', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('renders the dashboard error banner when provided by the server', async () => { + document.body.innerHTML = ` +
+ ` + + vi.resetModules() + + const { default: DashboardView } = await import('../../../../../assets/vue/views/DashboardView.vue') + + const wrapper = mount(DashboardView) + + expect(wrapper.text()).toContain('Session expired') + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/ListSubscribersView.spec.js b/tests/Unit/assets/vue/views/ListSubscribersView.spec.js new file mode 100644 index 0000000..a395630 --- /dev/null +++ b/tests/Unit/assets/vue/views/ListSubscribersView.spec.js @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ListSubscribersView from '../../../../../assets/vue/views/ListSubscribersView.vue' +import * as api from '../../../../../assets/vue/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + params: { + listId: '1', + }, + query: { + listName: 'Newsletter', + }, + }), +})) + +describe('ListSubscribersView', () => { + beforeEach(() => { + vi.restoreAllMocks() + + vi.spyOn(api.subscriptionClient, 'getSubscriberList').mockResolvedValue({ + id: 1, + name: 'Newsletter', + }) + + vi.spyOn(api.subscriptionClient, 'getSubscriberLists').mockResolvedValue({ + items: [], + pagination: { + hasMore: false, + nextCursor: null, + }, + }) + + vi.spyOn(api.subscriptionClient, 'getSubscribersOfList').mockResolvedValue({ + items: [ + { + id: 1, + email: 'john@example.com', + confirmed: true, + createdAt: '2024-01-01T00:00:00Z', + }, + ], + pagination: { + total: 1, + nextCursor: null, + }, + }) + }) + + const mountComponent = async () => { + const wrapper = mount(ListSubscribersView, { + global: { + stubs: { + AdminLayout: { + template: '
', + }, + RouterLink: true, + BaseIcon: true, + ListSubscribersExportPanel: true, + }, + }, + }) + + await flushPromises() + + return wrapper + } + + it('renders subscriber list', async () => { + const wrapper = await mountComponent() + + expect(api.subscriptionClient.getSubscribersOfList).toHaveBeenCalled() + expect(api.subscriptionClient.getSubscriberList).toHaveBeenCalled() + expect(api.subscriptionClient.getSubscriberLists).toHaveBeenCalled() + + expect(wrapper.text()).toContain('Newsletter') + expect(wrapper.text()).toContain('john@example.com') + expect(wrapper.text()).toContain('Confirmed') + }) + + it('shows an error when loading subscribers fails', async () => { + api.subscriptionClient.getSubscribersOfList.mockRejectedValueOnce( + new Error('API error') + ) + + const wrapper = await mountComponent() + + expect(wrapper.text()).toContain( + 'Failed to load subscribers for this list.' + ) + }) + + it('shows empty state when there are no subscribers', async () => { + api.subscriptionClient.getSubscribersOfList.mockResolvedValueOnce({ + items: [], + pagination: { + total: 0, + nextCursor: null, + }, + }) + + const wrapper = await mountComponent() + + expect(wrapper.text()).toContain('No subscribers for this filter.') + }) +}) diff --git a/tests/Unit/assets/vue/views/ListsView.spec.js b/tests/Unit/assets/vue/views/ListsView.spec.js new file mode 100644 index 0000000..71285ec --- /dev/null +++ b/tests/Unit/assets/vue/views/ListsView.spec.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ListView from '../../../../../assets/vue/views/ListsView.vue' + +describe('ListView', () => { + it('renders the AdminLayout and ListDirectory components', () => { + const wrapper = mount(ListView, { + global: { + stubs: { + AdminLayout: { + template: ` +
+ +
+ `, + }, + ListDirectory: { + template: '
', + }, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + expect(wrapper.find('[data-test="list-directory"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/PublicPageEditView.spec.js b/tests/Unit/assets/vue/views/PublicPageEditView.spec.js new file mode 100644 index 0000000..fb882a5 --- /dev/null +++ b/tests/Unit/assets/vue/views/PublicPageEditView.spec.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import PublicPageEditView from '../../../../../assets/vue/views/PublicPageEditView.vue' + +describe('PublicPageView', () => { + it('renders the AdminLayout and PublicPageEditor components', () => { + const wrapper = mount(PublicPageEditView, { + global: { + stubs: { + AdminLayout: { + template: ` +
+ +
+ `, + }, + PublicPageEditor: { + template: '
', + }, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + expect(wrapper.find('[data-test="public-page-editor"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/PublicPagesView.spec.js b/tests/Unit/assets/vue/views/PublicPagesView.spec.js new file mode 100644 index 0000000..9522f3b --- /dev/null +++ b/tests/Unit/assets/vue/views/PublicPagesView.spec.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import PublicPagesView from '../../../../../assets/vue/views/PublicPagesView.vue' + +describe('PublicPagesView', () => { + it('renders the AdminLayout and PublicPagesDirectory components', () => { + const wrapper = mount(PublicPagesView, { + global: { + stubs: { + AdminLayout: { + template: ` +
+ +
+ `, + }, + PublicPagesDirectory: { + template: '
', + }, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + expect( + wrapper.find('[data-test="public-pages-directory"]').exists() + ).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/SettingsView.spec.js b/tests/Unit/assets/vue/views/SettingsView.spec.js new file mode 100644 index 0000000..0351665 --- /dev/null +++ b/tests/Unit/assets/vue/views/SettingsView.spec.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import SettingsView from '../../../../../assets/vue/views/SettingsView.vue' + +describe('SettingsView', () => { + it('renders the AdminLayout and SettingsActionsPanel components', () => { + const wrapper = mount(SettingsView, { + global: { + stubs: { + AdminLayout: { + template: ` +
+ +
+ `, + }, + SettingsActionsPanel: { + template: '
', + }, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + expect(wrapper.find('[data-test="settings-actions-panel"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/SubscribersView.spec.js b/tests/Unit/assets/vue/views/SubscribersView.spec.js new file mode 100644 index 0000000..e04c7d7 --- /dev/null +++ b/tests/Unit/assets/vue/views/SubscribersView.spec.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import SubscribersView from '../../../../../assets/vue/views/SubscribersView.vue' + +describe('SubscribersView', () => { + it('renders the AdminLayout and SubscriberDirectory components', () => { + const wrapper = mount(SubscribersView, { + global: { + stubs: { + AdminLayout: { + template: ` +
+ +
+ `, + }, + SubscriberDirectory: { + template: '
', + }, + }, + }, + }) + + expect(wrapper.find('[data-test="admin-layout"]').exists()).toBe(true) + expect(wrapper.find('[data-test="subscriber-directory"]').exists()).toBe(true) + }) +}) diff --git a/tests/Unit/assets/vue/views/TemplateEditorView.spec.js b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js new file mode 100644 index 0000000..4df6feb --- /dev/null +++ b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import TemplateEditView from '../../../../../assets/vue/views/TemplateEditView.vue' +import * as api from '../../../../../assets/vue/api' + +const mockRoute = { + name: 'template-edit', + params: { + templateId: '1', + }, + query: {}, +} + +const replace = vi.fn() + +vi.mock('vue-router', () => ({ + RouterLink: { + template: '', + }, + useRoute: () => mockRoute, + useRouter: () => ({ + replace, + }), +})) + +describe('TemplateEditView', () => { + beforeEach(() => { + vi.restoreAllMocks() + + vi.spyOn(api.templateClient, 'getTemplate').mockResolvedValue({ + id: 1, + title: 'Welcome Template', + listOrder: 1, + content: '

Hello World

', + text: 'Hello World', + }) + + vi.spyOn(api.templateClient, 'updateTemplate').mockResolvedValue({}) + vi.spyOn(api.templateClient, 'createTemplate').mockResolvedValue({ + id: 2, + }) + }) + + const mountComponent = async () => { + const wrapper = mount(TemplateEditView, { + global: { + stubs: { + AdminLayout: { + template: '
', + }, + CkEditorField: { + props: ['modelValue', 'label'], + emits: ['update:modelValue'], + template: + '