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:
parent
d236f19d42
commit
8a7a0495b0
139
services/platform-service/src/modules/auth/auth.routes.test.ts
Normal file
139
services/platform-service/src/modules/auth/auth.routes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user