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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,22 @@ CLOUDINARY_API_SECRET=your_api_secret

# OpenAI
OPENAI_API_KEY=sk-...
# Stripe (Payment Integration)
# Secret key from Stripe Dashboard → Developers → API keys
STRIPE_SECRET_KEY=sk_test_...
# Webhook signing secret from Stripe Dashboard → Developers → Webhooks
STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID for the expert analysis product (from Stripe Dashboard → Products)
# If not set, an ad-hoc price of ₪149 is used
STRIPE_PRICE_ID=price_...

# CORS – set to your frontend origin
CORS_ORIGIN=http://localhost:3000

# Bank of Israel API (optional – defaults to official BOI SDMX endpoint)
# Override only for testing or if the BOI URL changes
BOI_API_BASE_URL=https://edge.boi.gov.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI

# Email (optional)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
Expand Down
167 changes: 167 additions & 0 deletions __tests__/analysisEnhanced.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Enhanced Analysis Endpoint Tests
*
* Integration tests for POST /api/v1/analysis/enhanced/:offerId
* Tests authentication, paid access, validation, and report generation.
*/

'use strict';

// Mock dependencies
jest.mock('../src/config/firestore', () => {
const mockDoc = {
get: jest.fn(),
set: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const mockCollection = jest.fn(() => ({
doc: jest.fn(() => mockDoc),
add: jest.fn().mockResolvedValue({ id: 'mock-id' }),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
get: jest.fn().mockResolvedValue({ empty: true, docs: [], size: 0 }),
}));
const mock = {
collection: mockCollection,
batch: jest.fn(() => ({
set: jest.fn(),
commit: jest.fn().mockResolvedValue(undefined),
})),
_mockDoc: mockDoc,
};
return mock;
});

jest.mock('../src/config/cloudinary', () => ({
uploader: {
upload_stream: jest.fn(),
destroy: jest.fn(),
},
}));

jest.mock('../src/utils/jwt', () => ({
verifyAccessToken: jest.fn(),
generateAccessToken: jest.fn(),
generateRefreshToken: jest.fn(),
}));

jest.mock('../src/services/ratesService', () => ({
getCurrentAverages: jest.fn().mockResolvedValue({
fixed: 4.65,
cpi: 3.15,
prime: 6.05,
variable: 4.95,
}),
getLatestRates: jest.fn().mockResolvedValue(null),
fetchAndStoreLatestRates: jest.fn().mockResolvedValue(null),
clearCache: jest.fn(),
}));

jest.mock('../src/cron/ratesCron', () => ({
startRatesCron: jest.fn(),
}));

jest.mock('../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));

const request = require('supertest');
const { verifyAccessToken } = require('../src/utils/jwt');
const db = require('../src/config/firestore');

// We need to require the app after mocks are set up
let app;

beforeAll(() => {
// Suppress startup logs
app = require('../src/index');
});

const mockPortfolio = {
id: 'market_standard',
name: 'Market Standard',
nameHe: 'תיק שוק סטנדרטי',
termYears: 30,
tracks: [
{ type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%' },
{ type: 'prime', percentage: 33, rate: 5.9, rateDisplay: 'P-0.15%' },
{ type: 'cpi', percentage: 33, rate: 3.2, rateDisplay: '3.20% + מדד' },
],
monthlyRepayment: 5200,
totalCost: 1872000,
totalInterest: 672000,
};

describe('POST /api/v1/analysis/enhanced/:offerId', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return 401 without authentication', async () => {
const res = await request(app)
.post('/api/v1/analysis/enhanced/offer-123')
.send({ portfolio: mockPortfolio });

expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
});

it('should return 403 when user has not paid', async () => {
// Mock auth
verifyAccessToken.mockReturnValue({ id: 'user-456' });

// Mock user lookup (auth middleware)
const mockUserDoc = {
exists: true,
id: 'user-456',
data: () => ({
id: 'user-456',
email: 'test@example.com',
verified: true,
paidAnalyses: false,
}),
};

// The auth middleware and paidAccess middleware both call db.collection('users').doc(id).get()
db._mockDoc.get.mockResolvedValue(mockUserDoc);

const res = await request(app)
.post('/api/v1/analysis/enhanced/offer-123')
.set('Authorization', 'Bearer valid-token')
.send({ portfolio: mockPortfolio });

expect(res.status).toBe(403);
expect(res.body.success).toBe(false);
expect(res.body.errorCode).toBe('PAYMENT_REQUIRED');
});

it('should return 400 when portfolio is missing', async () => {
// Mock auth + paid user
verifyAccessToken.mockReturnValue({ id: 'user-456' });

const mockUserDoc = {
exists: true,
id: 'user-456',
data: () => ({
id: 'user-456',
email: 'test@example.com',
verified: true,
paidAnalyses: true,
}),
};

db._mockDoc.get.mockResolvedValue(mockUserDoc);

const res = await request(app)
.post('/api/v1/analysis/enhanced/offer-123')
.set('Authorization', 'Bearer valid-token')
.send({});

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
151 changes: 151 additions & 0 deletions __tests__/analysisValidator.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Analysis Validator Tests
*
* Tests for the Joi validation schemas used by the enhanced analysis endpoint.
*/

'use strict';

const { enhancedAnalysisSchema } = require('../src/validators/analysisValidator');

const validBody = {
portfolio: {
id: 'market_standard',
name: 'Market Standard',
nameHe: 'תיק שוק סטנדרטי',
termYears: 30,
tracks: [
{ type: 'fixed', percentage: 34, rate: 4.75 },
{ type: 'prime', percentage: 33, rate: 5.9 },
{ type: 'cpi', percentage: 33, rate: 3.2 },
],
monthlyRepayment: 5200,
totalCost: 1872000,
totalInterest: 672000,
},
};

describe('enhancedAnalysisSchema', () => {
it('should accept a valid request body', () => {
const { error } = enhancedAnalysisSchema.validate(validBody);
expect(error).toBeUndefined();
});

it('should reject missing portfolio', () => {
const { error } = enhancedAnalysisSchema.validate({});
expect(error).toBeDefined();
expect(error.details[0].path).toContain('portfolio');
});

it('should reject portfolio without id', () => {
const body = {
portfolio: { ...validBody.portfolio, id: '' },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio without name', () => {
const body = {
portfolio: { ...validBody.portfolio, name: '' },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio with invalid termYears', () => {
const body = {
portfolio: { ...validBody.portfolio, termYears: 0 },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio with termYears > 40', () => {
const body = {
portfolio: { ...validBody.portfolio, termYears: 50 },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio without tracks', () => {
const body = {
portfolio: { ...validBody.portfolio, tracks: [] },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject track with invalid type', () => {
const body = {
portfolio: {
...validBody.portfolio,
tracks: [{ type: 'invalid', percentage: 100, rate: 4.5 }],
},
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject track with percentage > 100', () => {
const body = {
portfolio: {
...validBody.portfolio,
tracks: [{ type: 'fixed', percentage: 150, rate: 4.5 }],
},
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject track with negative rate', () => {
const body = {
portfolio: {
...validBody.portfolio,
tracks: [{ type: 'fixed', percentage: 100, rate: -1 }],
},
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio with negative monthlyRepayment', () => {
const body = {
portfolio: { ...validBody.portfolio, monthlyRepayment: -100 },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should reject portfolio with negative totalInterest', () => {
const body = {
portfolio: { ...validBody.portfolio, totalInterest: -1 },
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeDefined();
});

it('should accept portfolio with optional fields', () => {
const body = {
portfolio: {
...validBody.portfolio,
description: 'A test portfolio',
interestSavings: 50000,
fitnessScore: 85,
recommended: true,
},
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeUndefined();
});

it('should accept optional portfolioId field', () => {
const body = {
...validBody,
portfolioId: 'market_standard',
};
const { error } = enhancedAnalysisSchema.validate(body);
expect(error).toBeUndefined();
});
});
Loading
Loading