diff --git a/src/modules/billing/components/billing.planBadge.component.vue b/src/modules/billing/components/billing.planBadge.component.vue index b159c6178..8a95b07aa 100644 --- a/src/modules/billing/components/billing.planBadge.component.vue +++ b/src/modules/billing/components/billing.planBadge.component.vue @@ -15,7 +15,7 @@ * @param {string} value Plan identifier. * @returns {boolean} True when the identifier is allowed. */ -const isAllowedPlan = (value) => ['free', 'starter', 'pro'].includes(value); +const isAllowedPlan = (value) => ['free', 'starter', 'pro', 'enterprise'].includes(value); /** * Component definition. @@ -39,6 +39,7 @@ export default { free: 'grey', starter: 'primary', pro: 'secondary', + enterprise: 'warning', }; return colors[this.plan] || 'grey'; }, diff --git a/src/modules/billing/stores/billing.store.js b/src/modules/billing/stores/billing.store.js index 7ede92660..2875ce8b0 100644 --- a/src/modules/billing/stores/billing.store.js +++ b/src/modules/billing/stores/billing.store.js @@ -34,7 +34,7 @@ export const useBillingStore = defineStore('billing', { this.plans = res.data.data; return this.plans; } catch (err) { - console.log(err); + console.error(err); throw err; } finally { this.loading = false; @@ -53,7 +53,7 @@ export const useBillingStore = defineStore('billing', { this.subscription = res.data.data; return this.subscription; } catch (err) { - console.log(err); + console.error(err); throw err; } finally { this.loading = false; @@ -76,7 +76,7 @@ export const useBillingStore = defineStore('billing', { }); return res.data.data; } catch (err) { - console.log(err); + console.error(err); throw err; } finally { this.loading = false; @@ -88,6 +88,7 @@ export const useBillingStore = defineStore('billing', { * @returns {Promise} */ async openPortal() { + this.loading = true; try { const api = apiBase(); const res = await axios.post(`${api}/${config.api.endPoints.billing}/portal`); @@ -101,7 +102,7 @@ export const useBillingStore = defineStore('billing', { } window.location.href = parsed.toString(); } catch (err) { - console.log(err); + console.error(err); throw err; } finally { this.loading = false; diff --git a/src/modules/billing/tests/billing.e2e.tests.js b/src/modules/billing/tests/billing.e2e.tests.js index 21024d23d..b0014bb20 100644 --- a/src/modules/billing/tests/billing.e2e.tests.js +++ b/src/modules/billing/tests/billing.e2e.tests.js @@ -7,6 +7,27 @@ const testEmail = `e2e-billing-${timestamp}@billing${timestamp}.com`; const testPassword = 'E2eTestPass99xyz'; const API_URL = 'http://localhost:3000'; +/** + * @desc Mock plans returned by the billing API for E2E tests. + * @type {Array} + */ +const mockPlans = [ + { planId: 'free', name: 'Free', monthlyPrice: 0, annualPrice: 0, stripePriceMonthly: 'price_free_m', stripePriceAnnual: 'price_free_a' }, + { planId: 'starter', name: 'Starter', monthlyPrice: 19, annualPrice: 180, stripePriceMonthly: 'price_starter_m', stripePriceAnnual: 'price_starter_a' }, + { planId: 'pro', name: 'Pro', monthlyPrice: 49, annualPrice: 468, stripePriceMonthly: 'price_pro_m', stripePriceAnnual: 'price_pro_a' }, +]; + +/** + * @desc Intercept billing plans API and return mock data so tests work without a backend. + * @param {import('playwright').Page} page + * @returns {Promise} + */ +async function mockPlansAPI(page) { + await page.route('**/api/billing/plans', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: mockPlans }) }), + ); +} + /** * @desc Check whether the Node API backend is reachable. * @param {import('@playwright/test').APIRequestContext} request @@ -28,6 +49,7 @@ test.describe('Pricing Page E2E', () => { * @returns {Promise} */ test('displays pricing header and plan cards', async ({ page }) => { + await mockPlansAPI(page); await page.goto('/pricing'); await page.waitForLoadState('networkidle'); @@ -53,6 +75,7 @@ test.describe('Pricing Page E2E', () => { * @returns {Promise} */ test('billing toggle switches between monthly and annual', async ({ page }) => { + await mockPlansAPI(page); await page.goto('/pricing'); await page.waitForLoadState('networkidle'); @@ -83,6 +106,7 @@ test.describe('Pricing Page E2E', () => { * @returns {Promise} */ test('plan cards display feature lists', async ({ page }) => { + await mockPlansAPI(page); await page.goto('/pricing'); await page.waitForLoadState('networkidle'); @@ -111,6 +135,7 @@ test.describe('Pricing Page E2E', () => { * @returns {Promise} */ test('CTA buttons are present on plan cards', async ({ page }) => { + await mockPlansAPI(page); await page.goto('/pricing'); await page.waitForLoadState('networkidle'); @@ -128,10 +153,8 @@ test.describe('Pricing Page — Unauthenticated CTA', () => { * @param {{ page: import('playwright').Page, request: import('playwright').APIRequestContext }} fixtures * @returns {Promise} */ - test('CTA redirects unauthenticated user to signin', async ({ page, request }) => { - const apiUp = await isApiAvailable(request); - test.skip(!apiUp, 'Node API backend not running — CTA buttons are disabled without Stripe prices'); - + test('CTA redirects unauthenticated user to signin', async ({ page }) => { + await mockPlansAPI(page); await page.goto('/pricing'); await page.waitForLoadState('networkidle'); diff --git a/src/modules/billing/tests/billing.planBadge.component.unit.tests.js b/src/modules/billing/tests/billing.planBadge.component.unit.tests.js new file mode 100644 index 000000000..aedc4ba3b --- /dev/null +++ b/src/modules/billing/tests/billing.planBadge.component.unit.tests.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import BillingPlanBadgeComponent from '../components/billing.planBadge.component.vue'; + +const vuetify = createVuetify(); + +/** + * Mount the plan badge component with Vuetify installed. + * @param {Object} props Component props. + * @returns {import('@vue/test-utils').VueWrapper} Mounted wrapper + */ +const mountComponent = (props) => + mount(BillingPlanBadgeComponent, { + props, + global: { plugins: [vuetify] }, + }); + +describe('BillingPlanBadgeComponent', () => { + it('renders free plan with grey color', () => { + const wrapper = mountComponent({ plan: 'free' }); + expect(wrapper.text()).toContain('free'); + expect(wrapper.vm.color).toBe('grey'); + }); + + it('renders starter plan with primary color', () => { + const wrapper = mountComponent({ plan: 'starter' }); + expect(wrapper.text()).toContain('starter'); + expect(wrapper.vm.color).toBe('primary'); + }); + + it('renders pro plan with secondary color', () => { + const wrapper = mountComponent({ plan: 'pro' }); + expect(wrapper.text()).toContain('pro'); + expect(wrapper.vm.color).toBe('secondary'); + }); + + it('renders enterprise plan with warning color', () => { + const wrapper = mountComponent({ plan: 'enterprise' }); + expect(wrapper.text()).toContain('enterprise'); + expect(wrapper.vm.color).toBe('warning'); + }); + + it('falls back to grey for unknown plan', () => { + // Validator will warn, but computed still returns grey fallback + const wrapper = mountComponent({ plan: 'unknown' }); + expect(wrapper.vm.color).toBe('grey'); + }); +}); diff --git a/src/modules/billing/tests/billing.store.unit.tests.js b/src/modules/billing/tests/billing.store.unit.tests.js index 2826de2b8..53f42764a 100644 --- a/src/modules/billing/tests/billing.store.unit.tests.js +++ b/src/modules/billing/tests/billing.store.unit.tests.js @@ -59,7 +59,7 @@ describe('Billing Store', () => { it('should propagate fetchPlans error to caller', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.get.mockRejectedValueOnce(new Error('Network error')); await expect(store.fetchPlans()).rejects.toThrow('Network error'); expect(spy).toHaveBeenCalled(); @@ -71,7 +71,7 @@ describe('Billing Store', () => { describe('fetchSubscription', () => { it('should fetch and set subscription', async () => { const store = useBillingStore(); - const mockSub = { planId: 'pro', status: 'active' }; + const mockSub = { plan: 'pro', status: 'active' }; axios.get.mockResolvedValueOnce({ data: { data: mockSub } }); const result = await store.fetchSubscription(); expect(store.subscription).toEqual(mockSub); @@ -88,7 +88,7 @@ describe('Billing Store', () => { it('should propagate fetchSubscription error to caller', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.get.mockRejectedValueOnce(new Error('Failed')); await expect(store.fetchSubscription()).rejects.toThrow('Failed'); expect(spy).toHaveBeenCalled(); @@ -123,7 +123,7 @@ describe('Billing Store', () => { it('should propagate createCheckout error to caller', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.post.mockRejectedValueOnce(new Error('Checkout failed')); await expect(store.createCheckout('price_123')).rejects.toThrow('Checkout failed'); expect(spy).toHaveBeenCalled(); @@ -133,6 +133,23 @@ describe('Billing Store', () => { }); describe('openPortal', () => { + it('should set loading to true during openPortal', async () => { + const store = useBillingStore(); + let loadingDuringPortal = false; + const portalUrl = 'https://billing.stripe.com/session789'; + axios.post.mockImplementationOnce(() => { + loadingDuringPortal = store.loading; + return Promise.resolve({ data: { data: { url: portalUrl } } }); + }); + const originalLocation = window.location; + delete window.location; + window.location = { ...originalLocation, href: '' }; + await store.openPortal(); + expect(loadingDuringPortal).toBe(true); + expect(store.loading).toBe(false); + window.location = originalLocation; + }); + it('should call portal endpoint and redirect', async () => { const store = useBillingStore(); const portalUrl = 'https://billing.stripe.com/session456'; @@ -148,7 +165,7 @@ describe('Billing Store', () => { it('should throw when portal URL is missing from API response', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.post.mockResolvedValueOnce({ data: { data: {} } }); await expect(store.openPortal()).rejects.toThrow('Billing portal URL is missing from the API response'); expect(spy).toHaveBeenCalled(); @@ -158,7 +175,7 @@ describe('Billing Store', () => { it('should reject non-HTTPS portal URLs', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.post.mockResolvedValueOnce({ data: { data: { url: 'http://evil.example.com/portal' } } }); await expect(store.openPortal()).rejects.toThrow('Rejected non-HTTPS portal URL'); expect(spy).toHaveBeenCalled(); @@ -168,7 +185,7 @@ describe('Billing Store', () => { it('should propagate openPortal error to caller', async () => { const store = useBillingStore(); - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); axios.post.mockRejectedValueOnce(new Error('Portal failed')); await expect(store.openPortal()).rejects.toThrow('Portal failed'); expect(spy).toHaveBeenCalled(); diff --git a/src/modules/billing/views/billing.billing.view.vue b/src/modules/billing/views/billing.billing.view.vue index 91e32d514..27b89b211 100644 --- a/src/modules/billing/views/billing.billing.view.vue +++ b/src/modules/billing/views/billing.billing.view.vue @@ -115,7 +115,7 @@ export default { * @returns {string} Plan identifier */ currentPlan() { - return this.subscription?.planId || 'free'; + return this.subscription?.plan || 'free'; }, /** * @desc Format the next billing date for display. diff --git a/src/modules/billing/views/billing.pricing.view.vue b/src/modules/billing/views/billing.pricing.view.vue index 50b8b80ad..749c2a166 100644 --- a/src/modules/billing/views/billing.pricing.view.vue +++ b/src/modules/billing/views/billing.pricing.view.vue @@ -149,7 +149,7 @@ export default { */ currentPlanId() { const billingStore = useBillingStore(); - return billingStore.subscription?.planId || null; + return billingStore.subscription?.plan || null; }, }, /**