From 3d63e1a733af08f4fd7977e0f5767f2639743b27 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 17 Mar 2026 19:01:43 +0100 Subject: [PATCH 1/3] fix(billing): correct plan field name, add enterprise badge, store cleanup - Use subscription.plan instead of subscription.planId in views (#3729) - Add enterprise to PlanBadge validator and color map (#3730) - Set loading=true at start of openPortal action (#3731) - Replace console.log with console.error in store catch blocks (#3732) - Update test spies to match console.error Closes #3729, closes #3730, closes #3731, closes #3732 --- .../components/billing.planBadge.component.vue | 3 ++- src/modules/billing/stores/billing.store.js | 9 +++++---- .../billing/tests/billing.store.unit.tests.js | 12 ++++++------ src/modules/billing/views/billing.billing.view.vue | 2 +- src/modules/billing/views/billing.pricing.view.vue | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) 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.store.unit.tests.js b/src/modules/billing/tests/billing.store.unit.tests.js index 2826de2b8..9b3f8deb2 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(); @@ -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(); @@ -148,7 +148,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 +158,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 +168,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; }, }, /** From 988015105724e8937ac96af105e04f6a4db13da0 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 17 Mar 2026 19:14:35 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(billing):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20add=20tests=20and=20fix=20mock=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add openPortal loading-state unit test (Copilot review) - Fix mock subscription to use plan instead of planId (Copilot review) - Add planBadge component unit tests for enterprise (CodeRabbit review) --- .../billing.planBadge.component.unit.tests.js | 49 +++++++++++++++++++ .../billing/tests/billing.store.unit.tests.js | 19 ++++++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/modules/billing/tests/billing.planBadge.component.unit.tests.js 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 9b3f8deb2..53f42764a 100644 --- a/src/modules/billing/tests/billing.store.unit.tests.js +++ b/src/modules/billing/tests/billing.store.unit.tests.js @@ -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); @@ -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'; From 9fbdfbc492cbf4ca0ce5d3b5e1298458253f07cc Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 17 Mar 2026 20:13:47 +0100 Subject: [PATCH 3/3] fix(billing): mock plans API in E2E tests so pricing page renders without backend E2E tests for the pricing page failed in CI because no backend was running, causing fetchPlans to error and the view to show an error state instead of plan cards. Added Playwright route interception to return mock plan data, making these tests backend-independent. --- .../billing/tests/billing.e2e.tests.js | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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');