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