From 8a7a0495b016ccc5b1f16305ca4794dc43c5b1bd Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 15 Feb 2026 15:09:23 -0800 Subject: [PATCH] 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 --- .../src/modules/auth/auth.routes.test.ts | 139 ++++++++++++++++ .../modules/licenses/licenses.routes.test.ts | 138 +++++++++++++++ .../src/modules/stripe/stripe.routes.test.ts | 157 ++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 services/platform-service/src/modules/auth/auth.routes.test.ts create mode 100644 services/platform-service/src/modules/licenses/licenses.routes.test.ts create mode 100644 services/platform-service/src/modules/stripe/stripe.routes.test.ts diff --git a/services/platform-service/src/modules/auth/auth.routes.test.ts b/services/platform-service/src/modules/auth/auth.routes.test.ts new file mode 100644 index 00000000..2b75a77e --- /dev/null +++ b/services/platform-service/src/modules/auth/auth.routes.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/licenses/licenses.routes.test.ts b/services/platform-service/src/modules/licenses/licenses.routes.test.ts new file mode 100644 index 00000000..b5403afb --- /dev/null +++ b/services/platform-service/src/modules/licenses/licenses.routes.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/stripe/stripe.routes.test.ts b/services/platform-service/src/modules/stripe/stripe.routes.test.ts new file mode 100644 index 00000000..64f3d38d --- /dev/null +++ b/services/platform-service/src/modules/stripe/stripe.routes.test.ts @@ -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(); + }); +});