test(platform-service): add route-level tests for Phase 1 migration behaviors

- Added auth register route tests for default provisioning and best-effort fallback
- Added license activate route tests for token issuance, product device limits, and lockout
- Added Stripe webhook route tests for any-product fallback and plan normalization
- Verified: tsc --noEmit clean, 23 test files / 189 tests passing
This commit is contained in:
saravanakumardb1 2026-02-15 15:09:23 -08:00
parent d236f19d42
commit 8a7a0495b0
3 changed files with 434 additions and 0 deletions

View File

@ -0,0 +1,139 @@
/**
* Route-level tests for auth register provisioning behavior.
*/
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const repoMock = {
getByEmail: vi.fn(),
create: vi.fn(),
hashPassword: vi.fn(),
updateLastLogin: vi.fn(),
getById: vi.fn(),
};
const jwtMock = {
createAccessToken: vi.fn(),
createRefreshToken: vi.fn(),
verifyToken: vi.fn(),
};
const productCacheMock = {
getProduct: vi.fn(),
};
const subscriptionRepoMock = {
createSubscription: vi.fn(),
};
const licenseRepoMock = {
create: vi.fn(),
generateKey: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
vi.mock('./jwt.js', () => jwtMock);
vi.mock('../products/cache.js', () => productCacheMock);
vi.mock('../subscriptions/repository.js', () => subscriptionRepoMock);
vi.mock('../licenses/repository.js', () => licenseRepoMock);
describe('authRoutes register provisioning', () => {
beforeEach(() => {
vi.clearAllMocks();
repoMock.getByEmail.mockResolvedValue(null);
repoMock.hashPassword.mockResolvedValue('hash');
repoMock.create.mockResolvedValue(undefined);
jwtMock.createAccessToken.mockResolvedValue('access-token');
jwtMock.createRefreshToken.mockResolvedValue('refresh-token');
productCacheMock.getProduct.mockReturnValue({
id: 'lysnrai',
productId: 'lysnrai',
displayName: 'LysnrAI',
licensePrefix: 'LYSNR',
packageName: '',
defaultPlan: 'free',
trialDays: 14,
deviceLimits: { free: 1, pro: 3, enterprise: 10 },
websiteUrl: '',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
subscriptionRepoMock.createSubscription.mockResolvedValue(undefined);
licenseRepoMock.create.mockResolvedValue(undefined);
licenseRepoMock.generateKey.mockReturnValue('LYSNR-AAAA-BBBB-CCCC');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('provisions subscription/license from product defaults during register', async () => {
const { authRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(authRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'new@lysnrai.com',
password: 'password123',
displayName: 'New User',
productId: 'lysnrai',
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.accessToken).toBe('access-token');
expect(body.refreshToken).toBe('refresh-token');
expect(body.user.plan).toBe('free');
expect(subscriptionRepoMock.createSubscription).toHaveBeenCalledTimes(1);
const createdSub = subscriptionRepoMock.createSubscription.mock.calls[0][0];
expect(createdSub.plan).toBe('free');
expect(createdSub.status).toBe('trialing');
expect(licenseRepoMock.create).toHaveBeenCalledTimes(1);
const createdLicense = licenseRepoMock.create.mock.calls[0][0];
expect(createdLicense.plan).toBe('free');
expect(createdLicense.maxDevices).toBe(1);
await app.close();
});
it('keeps register successful when provisioning fails (best-effort)', async () => {
subscriptionRepoMock.createSubscription.mockRejectedValueOnce(new Error('sub create failed'));
licenseRepoMock.create.mockRejectedValueOnce(new Error('license create failed'));
const { authRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(authRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'new2@lysnrai.com',
password: 'password123',
displayName: 'New User 2',
productId: 'lysnrai',
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.accessToken).toBe('access-token');
expect(body.refreshToken).toBe('refresh-token');
await app.close();
});
});

View File

@ -0,0 +1,138 @@
/**
* Route-level tests for license activation behavior.
*/
import Fastify from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const requestContextMock = {
getRequestProductId: vi.fn(),
getRequestProductConfig: vi.fn(),
};
const repoMock = {
generateKey: vi.fn(),
create: vi.fn(),
getByKey: vi.fn(),
update: vi.fn(),
getByUserId: vi.fn(),
};
const authRepoMock = {
getById: vi.fn(),
};
const jwtMock = {
createAccessToken: vi.fn(),
createRefreshToken: vi.fn(),
};
vi.mock('../../lib/request-context.js', () => requestContextMock);
vi.mock('./repository.js', () => repoMock);
vi.mock('../auth/repository.js', () => authRepoMock);
vi.mock('../auth/jwt.js', () => jwtMock);
describe('licenseRoutes activate', () => {
beforeEach(() => {
vi.clearAllMocks();
requestContextMock.getRequestProductId.mockReturnValue('lysnrai');
requestContextMock.getRequestProductConfig.mockReturnValue({
deviceLimits: { free: 1, pro: 3, enterprise: 10 },
});
authRepoMock.getById.mockResolvedValue({
id: 'usr_1',
email: 'u@test.com',
role: 'user',
status: 'active',
});
jwtMock.createAccessToken.mockResolvedValue('access-token');
jwtMock.createRefreshToken.mockResolvedValue('refresh-token');
});
it('issues tokens and enforces product-config device limit on activation', async () => {
repoMock.getByKey.mockResolvedValue({
id: 'lic_1',
productId: 'lysnrai',
key: 'LYSNR-ABCD-EFGH-IJKL',
userId: 'usr_1',
plan: 'pro',
status: 'active',
activatedAt: null,
expiresAt: null,
deviceIds: [],
maxDevices: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
repoMock.update.mockResolvedValue({
id: 'lic_1',
productId: 'lysnrai',
key: 'LYSNR-ABCD-EFGH-IJKL',
userId: 'usr_1',
plan: 'pro',
status: 'active',
activatedAt: new Date().toISOString(),
expiresAt: null,
deviceIds: ['mac-1'],
maxDevices: 3,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const { licenseRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(licenseRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/licenses/activate',
payload: { key: 'LYSNR-ABCD-EFGH-IJKL', deviceId: 'mac-1' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.accessToken).toBe('access-token');
expect(body.refreshToken).toBe('refresh-token');
expect(repoMock.update).toHaveBeenCalledTimes(1);
expect(repoMock.update.mock.calls[0][2]).toMatchObject({
maxDevices: 3,
});
await app.close();
});
it('locks out IP after repeated failed activation attempts', async () => {
repoMock.getByKey.mockResolvedValue(null);
const { licenseRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(licenseRoutes, { prefix: '/api' });
for (let i = 0; i < 5; i += 1) {
const res = await app.inject({
method: 'POST',
url: '/api/licenses/activate',
payload: { key: 'missing', deviceId: 'mac-1' },
});
expect(res.statusCode).toBe(404);
}
const locked = await app.inject({
method: 'POST',
url: '/api/licenses/activate',
payload: { key: 'missing', deviceId: 'mac-1' },
});
expect(locked.statusCode).toBe(400);
const body = locked.json() as { message?: string; error?: { message?: string } };
const msg = body.error?.message ?? body.message ?? '';
expect(msg).toContain('Too many failed activation attempts');
await app.close();
});
});

View File

@ -0,0 +1,157 @@
/**
* Route-level tests for stripe webhook migration behavior.
*/
import Fastify from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockStripeClient = {
webhooks: {
constructEvent: vi.fn(),
},
};
const stripeLibMock = {
getStripeForProduct: vi.fn(),
getPriceIds: vi.fn(),
};
const subRepoMock = {
getByUserId: vi.fn(),
createSubscription: vi.fn(),
updateSubscription: vi.fn(),
createPayment: vi.fn(),
getByStripeCustomerId: vi.fn(),
getByStripeCustomerIdAnyProduct: vi.fn(),
};
const authRepoMock = {
updatePlan: vi.fn(),
};
const requestContextMock = {
getRequestProductId: vi.fn(),
};
vi.mock('../../lib/stripe.js', () => stripeLibMock);
vi.mock('../subscriptions/repository.js', () => subRepoMock);
vi.mock('../auth/repository.js', () => authRepoMock);
vi.mock('../../lib/request-context.js', () => requestContextMock);
describe('stripeRoutes webhook', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
stripeLibMock.getStripeForProduct.mockReturnValue(mockStripeClient);
stripeLibMock.getPriceIds.mockReturnValue({
pro: 'price_pro',
enterprise: 'price_enterprise',
});
requestContextMock.getRequestProductId.mockReturnValue('lysnrai');
subRepoMock.getByUserId.mockResolvedValue(null);
subRepoMock.createSubscription.mockResolvedValue(undefined);
subRepoMock.updateSubscription.mockResolvedValue(undefined);
subRepoMock.createPayment.mockResolvedValue(undefined);
subRepoMock.getByStripeCustomerId.mockResolvedValue(null);
subRepoMock.getByStripeCustomerIdAnyProduct.mockResolvedValue(null);
authRepoMock.updatePlan.mockResolvedValue(undefined);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
it('falls back to any-product lookup when product metadata is missing (subscription.updated)', async () => {
mockStripeClient.webhooks.constructEvent.mockReturnValue({
type: 'customer.subscription.updated',
data: {
object: {
customer: 'cus_123',
cancel_at_period_end: false,
status: 'active',
current_period_end: 1_900_000_000,
items: { data: [{ price: { id: 'price_enterprise' } }] },
},
},
});
subRepoMock.getByStripeCustomerIdAnyProduct.mockResolvedValue({
id: 'sub_1',
productId: 'mindlyst',
userId: 'usr_1',
plan: 'pro',
status: 'active',
});
const { stripeRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(stripeRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/stripe/webhook',
headers: {
'stripe-signature': 'sig_test',
'content-type': 'application/json',
},
payload: '{"id":"evt_1"}',
});
expect(res.statusCode).toBe(200);
expect(subRepoMock.getByStripeCustomerIdAnyProduct).toHaveBeenCalledWith('cus_123');
expect(subRepoMock.updateSubscription).toHaveBeenCalledTimes(1);
expect(subRepoMock.updateSubscription.mock.calls[0][2]).toMatchObject({
plan: 'enterprise',
});
expect(authRepoMock.updatePlan).toHaveBeenCalledWith('usr_1', 'mindlyst', 'enterprise');
await app.close();
});
it('normalizes invalid checkout metadata plan to pro', async () => {
mockStripeClient.webhooks.constructEvent.mockReturnValue({
type: 'checkout.session.completed',
data: {
object: {
metadata: {
userId: 'usr_2',
productId: 'lysnrai',
plan: 'premium',
},
customer: 'cus_777',
subscription: 'sub_777',
amount_total: 0,
currency: 'usd',
},
},
});
const { stripeRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(stripeRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/stripe/webhook',
headers: {
'stripe-signature': 'sig_test',
'content-type': 'application/json',
},
payload: '{"id":"evt_2"}',
});
expect(res.statusCode).toBe(200);
expect(subRepoMock.createSubscription).toHaveBeenCalledTimes(1);
expect(subRepoMock.createSubscription.mock.calls[0][0]).toMatchObject({
plan: 'pro',
productId: 'lysnrai',
userId: 'usr_2',
});
expect(authRepoMock.updatePlan).toHaveBeenCalledWith('usr_2', 'lysnrai', 'pro');
await app.close();
});
});