Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Component definition.
Expand All @@ -39,6 +39,7 @@ export default {
free: 'grey',
starter: 'primary',
pro: 'secondary',
enterprise: 'warning',
};
return colors[this.plan] || 'grey';
},
Expand Down
9 changes: 5 additions & 4 deletions src/modules/billing/stores/billing.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -88,6 +88,7 @@ export const useBillingStore = defineStore('billing', {
* @returns {Promise<void>}
*/
async openPortal() {
this.loading = true;
try {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
const api = apiBase();
const res = await axios.post(`${api}/${config.api.endPoints.billing}/portal`);
Expand All @@ -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;
Expand Down
31 changes: 27 additions & 4 deletions src/modules/billing/tests/billing.e2e.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>}
*/
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<void>}
*/
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
Expand All @@ -28,6 +49,7 @@ test.describe('Pricing Page E2E', () => {
* @returns {Promise<void>}
*/
test('displays pricing header and plan cards', async ({ page }) => {
await mockPlansAPI(page);
await page.goto('/pricing');
await page.waitForLoadState('networkidle');

Expand All @@ -53,6 +75,7 @@ test.describe('Pricing Page E2E', () => {
* @returns {Promise<void>}
*/
test('billing toggle switches between monthly and annual', async ({ page }) => {
await mockPlansAPI(page);
await page.goto('/pricing');
await page.waitForLoadState('networkidle');

Expand Down Expand Up @@ -83,6 +106,7 @@ test.describe('Pricing Page E2E', () => {
* @returns {Promise<void>}
*/
test('plan cards display feature lists', async ({ page }) => {
await mockPlansAPI(page);
await page.goto('/pricing');
await page.waitForLoadState('networkidle');

Expand Down Expand Up @@ -111,6 +135,7 @@ test.describe('Pricing Page E2E', () => {
* @returns {Promise<void>}
*/
test('CTA buttons are present on plan cards', async ({ page }) => {
await mockPlansAPI(page);
await page.goto('/pricing');
await page.waitForLoadState('networkidle');

Expand All @@ -128,10 +153,8 @@ test.describe('Pricing Page — Unauthenticated CTA', () => {
* @param {{ page: import('playwright').Page, request: import('playwright').APIRequestContext }} fixtures
* @returns {Promise<void>}
*/
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');

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
31 changes: 24 additions & 7 deletions src/modules/billing/tests/billing.store.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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'));
Comment thread
PierreBrisorgueil marked this conversation as resolved.
await expect(store.fetchSubscription()).rejects.toThrow('Failed');
expect(spy).toHaveBeenCalled();
Expand Down Expand Up @@ -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();
Expand All @@ -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';
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/modules/billing/views/billing.billing.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/modules/billing/views/billing.pricing.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default {
*/
currentPlanId() {
const billingStore = useBillingStore();
return billingStore.subscription?.planId || null;
return billingStore.subscription?.plan || null;
},
},
/**
Expand Down
Loading