backend unit tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ backend/.env.qa
|
|||||||
backend/.env.prod
|
backend/.env.prod
|
||||||
backend/dist
|
backend/dist
|
||||||
backend/logs
|
backend/logs
|
||||||
|
backend/coverage
|
||||||
|
|
||||||
# Frontend specific
|
# Frontend specific
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
|||||||
22
backend/jest.config.js
Normal file
22
backend/jest.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'**/*.js',
|
||||||
|
'!**/node_modules/**',
|
||||||
|
'!**/coverage/**',
|
||||||
|
'!**/tests/**',
|
||||||
|
'!jest.config.js'
|
||||||
|
],
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
testMatch: ['**/tests/**/*.test.js'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||||
|
forceExit: true,
|
||||||
|
testTimeout: 10000,
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
lines: 80,
|
||||||
|
statements: 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
const verifyBetaPassword = (req, res, next) => {
|
|
||||||
const betaPassword = req.headers['x-beta-password'];
|
|
||||||
const configuredPassword = process.env.BETA_PASSWORD;
|
|
||||||
|
|
||||||
if (!configuredPassword) {
|
|
||||||
console.error('BETA_PASSWORD environment variable is not set');
|
|
||||||
return res.status(500).json({ error: 'Beta password not configured on server' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!betaPassword) {
|
|
||||||
return res.status(401).json({ error: 'Beta password required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (betaPassword !== configuredPassword) {
|
|
||||||
return res.status(403).json({ error: 'Invalid beta password' });
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { verifyBetaPassword };
|
|
||||||
@@ -19,7 +19,7 @@ const csrfProtection = (req, res, next) => {
|
|||||||
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
|
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
|
||||||
|
|
||||||
// Get token from cookie
|
// Get token from cookie
|
||||||
const cookieToken = req.cookies["csrf-token"];
|
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
||||||
|
|
||||||
// Verify both tokens exist and match
|
// Verify both tokens exist and match
|
||||||
if (!token || !cookieToken || token !== cookieToken) {
|
if (!token || !cookieToken || token !== cookieToken) {
|
||||||
|
|||||||
3561
backend/package-lock.json
generated
3561
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,12 @@
|
|||||||
"start:prod": "NODE_ENV=prod node -r dotenv/config server.js dotenv_config_path=.env.prod",
|
"start:prod": "NODE_ENV=prod node -r dotenv/config server.js dotenv_config_path=.env.prod",
|
||||||
"dev": "NODE_ENV=dev nodemon -r dotenv/config server.js dotenv_config_path=.env.dev",
|
"dev": "NODE_ENV=dev nodemon -r dotenv/config server.js dotenv_config_path=.env.dev",
|
||||||
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
|
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "NODE_ENV=test jest",
|
||||||
|
"test:watch": "NODE_ENV=test jest --watch",
|
||||||
|
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
|
||||||
|
"test:unit": "NODE_ENV=test jest tests/unit",
|
||||||
|
"test:integration": "NODE_ENV=test jest tests/integration",
|
||||||
|
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -40,6 +45,11 @@
|
|||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.10"
|
"@types/jest": "^30.0.0",
|
||||||
|
"jest": "^30.1.3",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"sequelize-mock": "^0.10.2",
|
||||||
|
"sinon": "^21.0.0",
|
||||||
|
"supertest": "^7.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { verifyBetaPassword } = require('../middleware/betaAuth');
|
|
||||||
|
|
||||||
// Beta verification endpoint
|
|
||||||
router.get('/verify', verifyBetaPassword, (req, res) => {
|
|
||||||
res.json({ success: true, message: 'Beta access granted' });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -68,7 +68,7 @@ router.get("/my-rentals", authenticateToken, async (req, res) => {
|
|||||||
res.json(rentals);
|
res.json(rentals);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in my-rentals route:", error);
|
console.error("Error in my-rentals route:", error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to fetch rentals" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +91,42 @@ router.get("/my-listings", authenticateToken, async (req, res) => {
|
|||||||
res.json(rentals);
|
res.json(rentals);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in my-listings route:", error);
|
console.error("Error in my-listings route:", error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to fetch listings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get rental by ID
|
||||||
|
router.get("/:id", authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rental = await Rental.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: "item" },
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "renter",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: "Rental not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authorized to view this rental
|
||||||
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: "Unauthorized to view this rental" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rental);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching rental:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch rental" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,7 +256,7 @@ router.post("/", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json(rentalWithDetails);
|
res.status(201).json(rentalWithDetails);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to create rental" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,7 +290,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
||||||
return res.status(403).json({ error: "Unauthorized" });
|
return res.status(403).json({ error: "Unauthorized to update this rental" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If owner is approving a pending rental, charge the stored payment method
|
// If owner is approving a pending rental, charge the stored payment method
|
||||||
@@ -349,7 +384,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
res.json(updatedRental);
|
res.json(updatedRental);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to update rental status" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -393,7 +428,7 @@ router.post("/:id/review-renter", authenticateToken, async (req, res) => {
|
|||||||
reviewVisible: updatedRental.renterReviewVisible,
|
reviewVisible: updatedRental.renterReviewVisible,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to submit review" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,7 +472,7 @@ router.post("/:id/review-item", authenticateToken, async (req, res) => {
|
|||||||
reviewVisible: updatedRental.itemReviewVisible,
|
reviewVisible: updatedRental.itemReviewVisible,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to submit review" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -482,7 +517,7 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
res.json(updatedRental);
|
res.json(updatedRental);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to update rental" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -504,7 +539,7 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error calculating fees:", error);
|
console.error("Error calculating fees:", error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: "Failed to calculate fees" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const userRoutes = require("./routes/users");
|
|||||||
const itemRoutes = require("./routes/items");
|
const itemRoutes = require("./routes/items");
|
||||||
const rentalRoutes = require("./routes/rentals");
|
const rentalRoutes = require("./routes/rentals");
|
||||||
const messageRoutes = require("./routes/messages");
|
const messageRoutes = require("./routes/messages");
|
||||||
const betaRoutes = require("./routes/beta");
|
|
||||||
const itemRequestRoutes = require("./routes/itemRequests");
|
const itemRequestRoutes = require("./routes/itemRequests");
|
||||||
const stripeRoutes = require("./routes/stripe");
|
const stripeRoutes = require("./routes/stripe");
|
||||||
const mapsRoutes = require("./routes/maps");
|
const mapsRoutes = require("./routes/maps");
|
||||||
@@ -94,9 +93,6 @@ app.use(
|
|||||||
// Serve static files from uploads directory
|
// Serve static files from uploads directory
|
||||||
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
||||||
|
|
||||||
// Beta verification route (doesn't require auth)
|
|
||||||
app.use("/api/beta", betaRoutes);
|
|
||||||
|
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
app.use("/api/users", userRoutes);
|
app.use("/api/users", userRoutes);
|
||||||
app.use("/api/items", itemRoutes);
|
app.use("/api/items", itemRoutes);
|
||||||
|
|||||||
13
backend/tests/setup.js
Normal file
13
backend/tests/setup.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://test';
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
|
||||||
|
process.env.STRIPE_SECRET_KEY = 'sk_test_key';
|
||||||
|
|
||||||
|
// Silence console
|
||||||
|
global.console = {
|
||||||
|
...console,
|
||||||
|
log: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn()
|
||||||
|
};
|
||||||
194
backend/tests/unit/middleware/auth.test.js
Normal file
194
backend/tests/unit/middleware/auth.test.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
const { authenticateToken } = require('../../../middleware/auth');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
jest.mock('jsonwebtoken');
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { User } = require('../../../models');
|
||||||
|
|
||||||
|
describe('Auth Middleware', () => {
|
||||||
|
let req, res, next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
cookies: {}
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn()
|
||||||
|
};
|
||||||
|
next = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Valid token', () => {
|
||||||
|
it('should verify valid token from cookie and call next', async () => {
|
||||||
|
const mockUser = { id: 1, email: 'test@test.com' };
|
||||||
|
req.cookies.accessToken = 'validtoken';
|
||||||
|
jwt.verify.mockReturnValue({ id: 1 });
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith('validtoken', process.env.JWT_SECRET);
|
||||||
|
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||||
|
expect(req.user).toEqual(mockUser);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle token with valid user', async () => {
|
||||||
|
const mockUser = { id: 2, email: 'user@test.com', firstName: 'Test' };
|
||||||
|
req.cookies.accessToken = 'validtoken2';
|
||||||
|
jwt.verify.mockReturnValue({ id: 2 });
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith('validtoken2', process.env.JWT_SECRET);
|
||||||
|
expect(User.findByPk).toHaveBeenCalledWith(2);
|
||||||
|
expect(req.user).toEqual(mockUser);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid token', () => {
|
||||||
|
it('should return 401 for missing token', async () => {
|
||||||
|
req.cookies = {};
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Access token required',
|
||||||
|
code: 'NO_TOKEN'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for invalid token', async () => {
|
||||||
|
req.cookies.accessToken = 'invalidtoken';
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid token',
|
||||||
|
code: 'INVALID_TOKEN'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for expired token', async () => {
|
||||||
|
req.cookies.accessToken = 'expiredtoken';
|
||||||
|
const error = new Error('jwt expired');
|
||||||
|
error.name = 'TokenExpiredError';
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Token expired',
|
||||||
|
code: 'TOKEN_EXPIRED'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for invalid token format (missing user id)', async () => {
|
||||||
|
req.cookies.accessToken = 'tokenwithnoid';
|
||||||
|
jwt.verify.mockReturnValue({ email: 'test@test.com' }); // Missing id
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid token format',
|
||||||
|
code: 'INVALID_TOKEN_FORMAT'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when user not found', async () => {
|
||||||
|
req.cookies.accessToken = 'validtoken';
|
||||||
|
jwt.verify.mockReturnValue({ id: 999 });
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'User not found',
|
||||||
|
code: 'USER_NOT_FOUND'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle empty string token', async () => {
|
||||||
|
req.cookies.accessToken = '';
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Access token required',
|
||||||
|
code: 'NO_TOKEN'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT malformed error', async () => {
|
||||||
|
req.cookies.accessToken = 'malformed.token';
|
||||||
|
const error = new Error('jwt malformed');
|
||||||
|
error.name = 'JsonWebTokenError';
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid token',
|
||||||
|
code: 'INVALID_TOKEN'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database error when finding user', async () => {
|
||||||
|
req.cookies.accessToken = 'validtoken';
|
||||||
|
jwt.verify.mockReturnValue({ id: 1 });
|
||||||
|
User.findByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid token',
|
||||||
|
code: 'INVALID_TOKEN'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined cookies', async () => {
|
||||||
|
req.cookies = undefined;
|
||||||
|
|
||||||
|
await authenticateToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Access token required',
|
||||||
|
code: 'NO_TOKEN'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
506
backend/tests/unit/middleware/csrf.test.js
Normal file
506
backend/tests/unit/middleware/csrf.test.js
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
const mockTokensInstance = {
|
||||||
|
secretSync: jest.fn().mockReturnValue('mock-secret'),
|
||||||
|
create: jest.fn().mockReturnValue('mock-token-123'),
|
||||||
|
verify: jest.fn().mockReturnValue(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('csrf', () => {
|
||||||
|
return jest.fn().mockImplementation(() => mockTokensInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('cookie-parser', () => {
|
||||||
|
return jest.fn().mockReturnValue((req, res, next) => next());
|
||||||
|
});
|
||||||
|
|
||||||
|
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf');
|
||||||
|
|
||||||
|
describe('CSRF Middleware', () => {
|
||||||
|
let req, res, next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {},
|
||||||
|
body: {},
|
||||||
|
query: {},
|
||||||
|
cookies: {}
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn(),
|
||||||
|
cookie: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
locals: {}
|
||||||
|
};
|
||||||
|
next = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('csrfProtection', () => {
|
||||||
|
describe('Safe methods', () => {
|
||||||
|
it('should skip CSRF protection for GET requests', () => {
|
||||||
|
req.method = 'GET';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip CSRF protection for HEAD requests', () => {
|
||||||
|
req.method = 'HEAD';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip CSRF protection for OPTIONS requests', () => {
|
||||||
|
req.method = 'OPTIONS';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Token validation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate token from x-csrf-token header', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate token from request body', () => {
|
||||||
|
req.body.csrfToken = 'mock-token-123';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate token from query parameters', () => {
|
||||||
|
req.query.csrfToken = 'mock-token-123';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer header token over body token', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.body.csrfToken = 'different-token';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer header token over query token', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.query.csrfToken = 'different-token';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer body token over query token', () => {
|
||||||
|
req.body.csrfToken = 'mock-token-123';
|
||||||
|
req.query.csrfToken = 'different-token';
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Missing tokens', () => {
|
||||||
|
it('should return 403 when no token provided', () => {
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when no cookie token provided', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = {};
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when cookies object is missing', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = undefined;
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when both tokens are missing', () => {
|
||||||
|
req.cookies = {};
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Token mismatch', () => {
|
||||||
|
it('should return 403 when tokens do not match', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'token-from-header';
|
||||||
|
req.cookies = { 'csrf-token': 'token-from-cookie' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when header token is empty but cookie exists', () => {
|
||||||
|
req.headers['x-csrf-token'] = '';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when cookie token is empty but header exists', () => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': '' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_MISMATCH'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Token verification', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 when token verification fails', () => {
|
||||||
|
mockTokensInstance.verify.mockReturnValue(false);
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid CSRF token',
|
||||||
|
code: 'CSRF_TOKEN_INVALID'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next when token verification succeeds', () => {
|
||||||
|
mockTokensInstance.verify.mockReturnValue(true);
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle case-insensitive HTTP methods', () => {
|
||||||
|
req.method = 'post';
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle PUT requests', () => {
|
||||||
|
req.method = 'PUT';
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DELETE requests', () => {
|
||||||
|
req.method = 'DELETE';
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle PATCH requests', () => {
|
||||||
|
req.method = 'PATCH';
|
||||||
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||||
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCSRFToken', () => {
|
||||||
|
it('should generate token and set cookie with proper options', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set secure flag to false in dev environment', () => {
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set secure flag to true in non-dev environment', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set token in response header', () => {
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make token available in res.locals', () => {
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.locals.csrfToken).toBe('mock-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next after setting up token', () => {
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle test environment', () => {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined NODE_ENV', () => {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCSRFToken', () => {
|
||||||
|
it('should generate token and return it in response', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'mock-token-123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set token in cookie with proper options', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set secure flag to false in dev environment', () => {
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set secure flag to true in production environment', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle test environment', () => {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate new token each time', () => {
|
||||||
|
mockTokensInstance.create
|
||||||
|
.mockReturnValueOnce('token-1')
|
||||||
|
.mockReturnValueOnce('token-2');
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-1' });
|
||||||
|
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
it('should handle complete CSRF flow', () => {
|
||||||
|
// First, generate a token
|
||||||
|
generateCSRFToken(req, res, next);
|
||||||
|
const generatedToken = res.locals.csrfToken;
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Now test protection with the generated token
|
||||||
|
req.method = 'POST';
|
||||||
|
req.headers['x-csrf-token'] = generatedToken;
|
||||||
|
req.cookies = { 'csrf-token': generatedToken };
|
||||||
|
|
||||||
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle token generation endpoint flow', () => {
|
||||||
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
|
const tokenFromResponse = res.json.mock.calls[0][0].csrfToken;
|
||||||
|
const cookieCall = res.cookie.mock.calls[0];
|
||||||
|
|
||||||
|
expect(cookieCall[0]).toBe('csrf-token');
|
||||||
|
expect(cookieCall[1]).toBe(tokenFromResponse);
|
||||||
|
expect(tokenFromResponse).toBe('mock-token-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
// Mock express-rate-limit
|
||||||
|
const mockRateLimitInstance = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('express-rate-limit', () => {
|
||||||
|
const rateLimitFn = jest.fn((config) => {
|
||||||
|
// Store the config for inspection in tests
|
||||||
|
rateLimitFn.lastConfig = config;
|
||||||
|
return mockRateLimitInstance;
|
||||||
|
});
|
||||||
|
rateLimitFn.defaultKeyGenerator = jest.fn().mockReturnValue('127.0.0.1');
|
||||||
|
return rateLimitFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
const {
|
||||||
|
placesAutocomplete,
|
||||||
|
placeDetails,
|
||||||
|
geocoding,
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
passwordResetLimiter,
|
||||||
|
generalLimiter,
|
||||||
|
burstProtection,
|
||||||
|
createMapsRateLimiter,
|
||||||
|
createUserBasedRateLimiter
|
||||||
|
} = require('../../../middleware/rateLimiter');
|
||||||
|
|
||||||
|
describe('Rate Limiter Middleware', () => {
|
||||||
|
let req, res, next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
user: null
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn(),
|
||||||
|
set: jest.fn()
|
||||||
|
};
|
||||||
|
next = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMapsRateLimiter', () => {
|
||||||
|
it('should create rate limiter with correct configuration', () => {
|
||||||
|
const windowMs = 60000;
|
||||||
|
const max = 30;
|
||||||
|
const message = 'Test message';
|
||||||
|
|
||||||
|
createMapsRateLimiter(windowMs, max, message);
|
||||||
|
|
||||||
|
expect(rateLimit).toHaveBeenCalledWith({
|
||||||
|
windowMs,
|
||||||
|
max,
|
||||||
|
message: {
|
||||||
|
error: message,
|
||||||
|
retryAfter: Math.ceil(windowMs / 1000)
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: expect.any(Function)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyGenerator', () => {
|
||||||
|
it('should use user ID when user is authenticated', () => {
|
||||||
|
const windowMs = 60000;
|
||||||
|
const max = 30;
|
||||||
|
const message = 'Test message';
|
||||||
|
|
||||||
|
createMapsRateLimiter(windowMs, max, message);
|
||||||
|
const config = rateLimit.lastConfig;
|
||||||
|
|
||||||
|
const reqWithUser = { user: { id: 123 } };
|
||||||
|
const key = config.keyGenerator(reqWithUser);
|
||||||
|
|
||||||
|
expect(key).toBe('user:123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default IP generator when user is not authenticated', () => {
|
||||||
|
const windowMs = 60000;
|
||||||
|
const max = 30;
|
||||||
|
const message = 'Test message';
|
||||||
|
|
||||||
|
createMapsRateLimiter(windowMs, max, message);
|
||||||
|
const config = rateLimit.lastConfig;
|
||||||
|
|
||||||
|
const reqWithoutUser = { user: null };
|
||||||
|
config.keyGenerator(reqWithoutUser);
|
||||||
|
|
||||||
|
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithoutUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default IP generator when user has no ID', () => {
|
||||||
|
const windowMs = 60000;
|
||||||
|
const max = 30;
|
||||||
|
const message = 'Test message';
|
||||||
|
|
||||||
|
createMapsRateLimiter(windowMs, max, message);
|
||||||
|
const config = rateLimit.lastConfig;
|
||||||
|
|
||||||
|
const reqWithUserNoId = { user: {} };
|
||||||
|
config.keyGenerator(reqWithUserNoId);
|
||||||
|
|
||||||
|
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithUserNoId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate retryAfter correctly', () => {
|
||||||
|
const windowMs = 90000; // 90 seconds
|
||||||
|
const max = 10;
|
||||||
|
const message = 'Test message';
|
||||||
|
|
||||||
|
createMapsRateLimiter(windowMs, max, message);
|
||||||
|
|
||||||
|
expect(rateLimit).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
message: {
|
||||||
|
error: message,
|
||||||
|
retryAfter: 90 // Math.ceil(90000 / 1000)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pre-configured rate limiters', () => {
|
||||||
|
describe('placesAutocomplete', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof placesAutocomplete).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeDetails', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof placeDetails).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geocoding', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof geocoding).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginLimiter', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof loginLimiter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerLimiter', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof registerLimiter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('passwordResetLimiter', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof passwordResetLimiter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generalLimiter', () => {
|
||||||
|
it('should be a function (rate limiter middleware)', () => {
|
||||||
|
expect(typeof generalLimiter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createUserBasedRateLimiter', () => {
|
||||||
|
let userBasedLimiter;
|
||||||
|
const windowMs = 10000; // 10 seconds
|
||||||
|
const max = 5;
|
||||||
|
const message = 'Too many requests';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userBasedLimiter = createUserBasedRateLimiter(windowMs, max, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Key generation', () => {
|
||||||
|
it('should use user ID when user is authenticated', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use IP when user is not authenticated', () => {
|
||||||
|
req.user = null;
|
||||||
|
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate limiting logic', () => {
|
||||||
|
it('should allow requests within limit', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// Make requests within limit
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block requests when limit exceeded', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// Exhaust the limit
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next request should be blocked
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: message,
|
||||||
|
retryAfter: expect.any(Number)
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct rate limit headers', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(res.set).toHaveBeenCalledWith({
|
||||||
|
'RateLimit-Limit': max,
|
||||||
|
'RateLimit-Remaining': max - 1,
|
||||||
|
'RateLimit-Reset': expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update remaining count correctly', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// First request
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
'RateLimit-Remaining': 4
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Second request
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
'RateLimit-Remaining': 3
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not go below 0 for remaining count', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// Exhaust the limit
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that remaining doesn't go negative
|
||||||
|
const lastCall = res.set.mock.calls[res.set.mock.calls.length - 1][0];
|
||||||
|
expect(lastCall['RateLimit-Remaining']).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Window management', () => {
|
||||||
|
it('should reset count after window expires', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
|
||||||
|
// Mock time to start of window
|
||||||
|
let currentTime = 1000000000;
|
||||||
|
Date.now = jest.fn(() => currentTime);
|
||||||
|
|
||||||
|
// Exhaust the limit
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify limit is reached
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
|
||||||
|
// Move time forward past the window
|
||||||
|
currentTime += windowMs + 1000;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Should allow requests again
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Restore original Date.now
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up old entries from store', () => {
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
let currentTime = 1000000000;
|
||||||
|
Date.now = jest.fn(() => currentTime);
|
||||||
|
|
||||||
|
// Create entries for different users
|
||||||
|
req.user = { id: 1 };
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
req.user = { id: 2 };
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
// Move time forward to expire first entries
|
||||||
|
currentTime += windowMs + 1000;
|
||||||
|
|
||||||
|
req.user = { id: 3 };
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
// The cleanup should have occurred when processing user 3's request
|
||||||
|
// We can't directly test the internal store, but we can verify the behavior
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Restore original Date.now
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Different users/IPs', () => {
|
||||||
|
it('should maintain separate counts for different users', () => {
|
||||||
|
// User 1 makes max requests
|
||||||
|
req.user = { id: 1 };
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 1 should be blocked
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
|
||||||
|
// User 2 should still be allowed
|
||||||
|
jest.clearAllMocks();
|
||||||
|
req.user = { id: 2 };
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain separate counts for different IPs', () => {
|
||||||
|
req.user = null;
|
||||||
|
|
||||||
|
// IP 1 makes max requests
|
||||||
|
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP 1 should be blocked
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
|
||||||
|
// IP 2 should still be allowed
|
||||||
|
jest.clearAllMocks();
|
||||||
|
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.2');
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle undefined user gracefully', () => {
|
||||||
|
req.user = undefined;
|
||||||
|
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user object without id', () => {
|
||||||
|
req.user = { email: 'test@test.com' };
|
||||||
|
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct reset time in ISO format', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
const setCall = res.set.mock.calls[0][0];
|
||||||
|
const resetTime = setCall['RateLimit-Reset'];
|
||||||
|
|
||||||
|
// Should be a valid ISO string
|
||||||
|
expect(() => new Date(resetTime)).not.toThrow();
|
||||||
|
expect(resetTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate retry after correctly when limit exceeded', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
const currentTime = 1000000000;
|
||||||
|
Date.now = jest.fn(() => currentTime);
|
||||||
|
|
||||||
|
// Exhaust the limit
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
userBasedLimiter(req, res, next);
|
||||||
|
|
||||||
|
const jsonCall = res.json.mock.calls[0][0];
|
||||||
|
expect(jsonCall.retryAfter).toBe(Math.ceil(windowMs / 1000));
|
||||||
|
|
||||||
|
// Restore original Date.now
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('burstProtection', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof burstProtection).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow requests within burst limit', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// Should allow up to 5 requests in 10 seconds
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
burstProtection(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block requests when burst limit exceeded', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
|
||||||
|
// Exhaust burst limit
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
burstProtection(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next request should be blocked
|
||||||
|
jest.clearAllMocks();
|
||||||
|
burstProtection(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Too many requests in a short period. Please slow down.',
|
||||||
|
retryAfter: expect.any(Number)
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Module exports', () => {
|
||||||
|
it('should export all required rate limiters', () => {
|
||||||
|
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||||
|
|
||||||
|
expect(rateLimiterModule).toHaveProperty('placesAutocomplete');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('placeDetails');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('geocoding');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('loginLimiter');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('registerLimiter');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('passwordResetLimiter');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('generalLimiter');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('burstProtection');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('createMapsRateLimiter');
|
||||||
|
expect(rateLimiterModule).toHaveProperty('createUserBasedRateLimiter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export functions for utility methods', () => {
|
||||||
|
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||||
|
|
||||||
|
expect(typeof rateLimiterModule.createMapsRateLimiter).toBe('function');
|
||||||
|
expect(typeof rateLimiterModule.createUserBasedRateLimiter).toBe('function');
|
||||||
|
expect(typeof rateLimiterModule.burstProtection).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
723
backend/tests/unit/middleware/security.test.js
Normal file
723
backend/tests/unit/middleware/security.test.js
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
const {
|
||||||
|
enforceHTTPS,
|
||||||
|
securityHeaders,
|
||||||
|
addRequestId,
|
||||||
|
logSecurityEvent,
|
||||||
|
sanitizeError
|
||||||
|
} = require('../../../middleware/security');
|
||||||
|
|
||||||
|
// Mock crypto module
|
||||||
|
jest.mock('crypto', () => ({
|
||||||
|
randomBytes: jest.fn(() => ({
|
||||||
|
toString: jest.fn(() => 'mocked-hex-string-1234567890abcdef')
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Security Middleware', () => {
|
||||||
|
let req, res, next, consoleSpy, consoleWarnSpy, consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
secure: false,
|
||||||
|
headers: {},
|
||||||
|
protocol: 'http',
|
||||||
|
url: '/test-path',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
connection: { remoteAddress: '127.0.0.1' },
|
||||||
|
get: jest.fn(),
|
||||||
|
user: null
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
redirect: jest.fn(),
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn()
|
||||||
|
};
|
||||||
|
next = jest.fn();
|
||||||
|
|
||||||
|
// Mock console methods
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enforceHTTPS', () => {
|
||||||
|
describe('Development environment', () => {
|
||||||
|
it('should skip HTTPS enforcement in dev environment', () => {
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip HTTPS enforcement in development environment', () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Production environment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.FRONTEND_URL = 'example.com';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTPS detection', () => {
|
||||||
|
it('should detect HTTPS from req.secure', () => {
|
||||||
|
req.secure = true;
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Strict-Transport-Security',
|
||||||
|
'max-age=31536000; includeSubDomains; preload'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect HTTPS from x-forwarded-proto header', () => {
|
||||||
|
req.headers['x-forwarded-proto'] = 'https';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Strict-Transport-Security',
|
||||||
|
'max-age=31536000; includeSubDomains; preload'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect HTTPS from req.protocol', () => {
|
||||||
|
req.protocol = 'https';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Strict-Transport-Security',
|
||||||
|
'max-age=31536000; includeSubDomains; preload'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTP to HTTPS redirect', () => {
|
||||||
|
it('should redirect HTTP requests to HTTPS', () => {
|
||||||
|
req.headers.host = 'example.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle requests with query parameters', () => {
|
||||||
|
req.url = '/test-path?param=value';
|
||||||
|
req.headers.host = 'example.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path?param=value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning for host header mismatch', () => {
|
||||||
|
req.headers.host = 'malicious.com';
|
||||||
|
req.ip = '192.168.1.1';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
'[SECURITY] Host header mismatch during HTTPS redirect:',
|
||||||
|
{
|
||||||
|
requestHost: 'malicious.com',
|
||||||
|
allowedHost: 'example.com',
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
url: '/test-path'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log warning when host matches allowed host', () => {
|
||||||
|
req.headers.host = 'example.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use FRONTEND_URL as allowed host', () => {
|
||||||
|
process.env.FRONTEND_URL = 'secure-site.com';
|
||||||
|
req.headers.host = 'different.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://secure-site.com/test-path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle missing host header', () => {
|
||||||
|
delete req.headers.host;
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty URL', () => {
|
||||||
|
req.url = '';
|
||||||
|
req.headers.host = 'example.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle root path', () => {
|
||||||
|
req.url = '/';
|
||||||
|
req.headers.host = 'example.com';
|
||||||
|
|
||||||
|
enforceHTTPS(req, res, next);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('securityHeaders', () => {
|
||||||
|
it('should set X-Content-Type-Options header', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Frame-Options header', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set Referrer-Policy header', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set Permissions-Policy header', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Permissions-Policy',
|
||||||
|
'camera=(), microphone=(), geolocation=(self)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next after setting headers', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set all security headers in one call', () => {
|
||||||
|
securityHeaders(req, res, next);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledTimes(4);
|
||||||
|
expect(res.setHeader).toHaveBeenNthCalledWith(1, 'X-Content-Type-Options', 'nosniff');
|
||||||
|
expect(res.setHeader).toHaveBeenNthCalledWith(2, 'X-Frame-Options', 'DENY');
|
||||||
|
expect(res.setHeader).toHaveBeenNthCalledWith(3, 'Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
expect(res.setHeader).toHaveBeenNthCalledWith(4, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addRequestId', () => {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
it('should generate and set request ID', () => {
|
||||||
|
addRequestId(req, res, next);
|
||||||
|
|
||||||
|
expect(crypto.randomBytes).toHaveBeenCalledWith(16);
|
||||||
|
expect(req.id).toBe('mocked-hex-string-1234567890abcdef');
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'mocked-hex-string-1234567890abcdef');
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique IDs for different requests', () => {
|
||||||
|
const mockRandomBytes = require('crypto').randomBytes;
|
||||||
|
|
||||||
|
// First call
|
||||||
|
mockRandomBytes.mockReturnValueOnce({
|
||||||
|
toString: jest.fn(() => 'first-request-id')
|
||||||
|
});
|
||||||
|
|
||||||
|
addRequestId(req, res, next);
|
||||||
|
expect(req.id).toBe('first-request-id');
|
||||||
|
|
||||||
|
// Reset for second call
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const req2 = { ...req };
|
||||||
|
const res2 = { ...res, setHeader: jest.fn() };
|
||||||
|
const next2 = jest.fn();
|
||||||
|
|
||||||
|
// Second call
|
||||||
|
mockRandomBytes.mockReturnValueOnce({
|
||||||
|
toString: jest.fn(() => 'second-request-id')
|
||||||
|
});
|
||||||
|
|
||||||
|
addRequestId(req2, res2, next2);
|
||||||
|
expect(req2.id).toBe('second-request-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call toString with hex parameter', () => {
|
||||||
|
const mockToString = jest.fn(() => 'hex-string');
|
||||||
|
require('crypto').randomBytes.mockReturnValueOnce({
|
||||||
|
toString: mockToString
|
||||||
|
});
|
||||||
|
|
||||||
|
addRequestId(req, res, next);
|
||||||
|
|
||||||
|
expect(mockToString).toHaveBeenCalledWith('hex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logSecurityEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
req.id = 'test-request-id';
|
||||||
|
req.ip = '192.168.1.1';
|
||||||
|
req.get = jest.fn((header) => {
|
||||||
|
if (header === 'user-agent') return 'Mozilla/5.0 Test Browser';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Production environment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log security event with JSON format', () => {
|
||||||
|
const eventType = 'LOGIN_ATTEMPT';
|
||||||
|
const details = { username: 'testuser', success: false };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', expect.any(String));
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData).toEqual({
|
||||||
|
timestamp: expect.any(String),
|
||||||
|
eventType: 'LOGIN_ATTEMPT',
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
userAgent: 'Mozilla/5.0 Test Browser',
|
||||||
|
userId: 'anonymous',
|
||||||
|
username: 'testuser',
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include user ID when user is authenticated', () => {
|
||||||
|
req.user = { id: 123 };
|
||||||
|
const eventType = 'DATA_ACCESS';
|
||||||
|
const details = { resource: '/api/users' };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.userId).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing request ID', () => {
|
||||||
|
delete req.id;
|
||||||
|
const eventType = 'SUSPICIOUS_ACTIVITY';
|
||||||
|
const details = { reason: 'Multiple failed attempts' };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.requestId).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing IP address', () => {
|
||||||
|
delete req.ip;
|
||||||
|
req.connection.remoteAddress = '10.0.0.1';
|
||||||
|
const eventType = 'IP_CHECK';
|
||||||
|
const details = { status: 'blocked' };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.ip).toBe('10.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include ISO timestamp', () => {
|
||||||
|
const eventType = 'TEST_EVENT';
|
||||||
|
const details = {};
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Non-production environment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log security event with simple format', () => {
|
||||||
|
const eventType = 'LOGIN_ATTEMPT';
|
||||||
|
const details = { username: 'testuser', success: false };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'[SECURITY]',
|
||||||
|
'LOGIN_ATTEMPT',
|
||||||
|
{ username: 'testuser', success: false }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log JSON in development', () => {
|
||||||
|
const eventType = 'TEST_EVENT';
|
||||||
|
const details = { test: true };
|
||||||
|
|
||||||
|
logSecurityEvent(eventType, details, req);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', 'TEST_EVENT', { test: true });
|
||||||
|
// Ensure it's not JSON.stringify format
|
||||||
|
expect(consoleSpy).not.toHaveBeenCalledWith('[SECURITY]', expect.stringMatching(/^{.*}$/));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle missing user-agent header', () => {
|
||||||
|
req.get.mockReturnValue(null);
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
logSecurityEvent('TEST', {}, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.userAgent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty details object', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
logSecurityEvent('EMPTY_DETAILS', {}, req);
|
||||||
|
|
||||||
|
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||||
|
expect(loggedData.eventType).toBe('EMPTY_DETAILS');
|
||||||
|
expect(Object.keys(loggedData)).toContain('timestamp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeError', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
req.id = 'test-request-id';
|
||||||
|
req.user = { id: 123 };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error logging', () => {
|
||||||
|
it('should log full error details internally', () => {
|
||||||
|
const error = new Error('Database connection failed');
|
||||||
|
error.stack = 'Error: Database connection failed\n at /app/db.js:10:5';
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
error: 'Database connection failed',
|
||||||
|
stack: 'Error: Database connection failed\n at /app/db.js:10:5',
|
||||||
|
userId: 123
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing user in logging', () => {
|
||||||
|
req.user = null;
|
||||||
|
const error = new Error('Test error');
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
error: 'Test error',
|
||||||
|
stack: error.stack,
|
||||||
|
userId: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client error responses (4xx)', () => {
|
||||||
|
it('should handle 400 Bad Request errors', () => {
|
||||||
|
const error = new Error('Invalid input data');
|
||||||
|
error.status = 400;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid input data',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 400 errors with default message', () => {
|
||||||
|
const error = new Error();
|
||||||
|
error.status = 400;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Bad Request',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 401 Unauthorized errors', () => {
|
||||||
|
const error = new Error('Token expired');
|
||||||
|
error.status = 401;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 403 Forbidden errors', () => {
|
||||||
|
const error = new Error('Access denied');
|
||||||
|
error.status = 403;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Forbidden',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 404 Not Found errors', () => {
|
||||||
|
const error = new Error('User not found');
|
||||||
|
error.status = 404;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Not Found',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Server error responses (5xx)', () => {
|
||||||
|
describe('Development environment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include detailed error message and stack trace', () => {
|
||||||
|
const error = new Error('Database connection failed');
|
||||||
|
error.status = 500;
|
||||||
|
error.stack = 'Error: Database connection failed\n at /app/db.js:10:5';
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Database connection failed',
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
stack: 'Error: Database connection failed\n at /app/db.js:10:5'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dev environment check', () => {
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
const error = new Error('Test error');
|
||||||
|
error.status = 500;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Test error',
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default status 500 when not specified', () => {
|
||||||
|
const error = new Error('Unhandled error');
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Unhandled error',
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom error status codes', () => {
|
||||||
|
const error = new Error('Service unavailable');
|
||||||
|
error.status = 503;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(503);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Service unavailable',
|
||||||
|
requestId: 'test-request-id',
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Production environment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return generic error message', () => {
|
||||||
|
const error = new Error('Database connection failed');
|
||||||
|
error.status = 500;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include stack trace in production', () => {
|
||||||
|
const error = new Error('Database error');
|
||||||
|
error.status = 500;
|
||||||
|
error.stack = 'Error: Database error\n at /app/db.js:10:5';
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
const response = res.json.mock.calls[0][0];
|
||||||
|
expect(response).not.toHaveProperty('stack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom status codes in production', () => {
|
||||||
|
const error = new Error('Service down');
|
||||||
|
error.status = 502;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(502);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default status 500 in production', () => {
|
||||||
|
const error = new Error('Unknown error');
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle error without message', () => {
|
||||||
|
const error = new Error();
|
||||||
|
error.status = 400;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Bad Request',
|
||||||
|
requestId: 'test-request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing request ID', () => {
|
||||||
|
delete req.id;
|
||||||
|
const error = new Error('Test error');
|
||||||
|
error.status = 400;
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Test error',
|
||||||
|
requestId: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error without status property', () => {
|
||||||
|
const error = new Error('No status error');
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call next() - error handling middleware', () => {
|
||||||
|
const error = new Error('Test error');
|
||||||
|
|
||||||
|
sanitizeError(error, req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Module exports', () => {
|
||||||
|
it('should export all required functions', () => {
|
||||||
|
const securityModule = require('../../../middleware/security');
|
||||||
|
|
||||||
|
expect(securityModule).toHaveProperty('enforceHTTPS');
|
||||||
|
expect(securityModule).toHaveProperty('securityHeaders');
|
||||||
|
expect(securityModule).toHaveProperty('addRequestId');
|
||||||
|
expect(securityModule).toHaveProperty('logSecurityEvent');
|
||||||
|
expect(securityModule).toHaveProperty('sanitizeError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export functions with correct types', () => {
|
||||||
|
const securityModule = require('../../../middleware/security');
|
||||||
|
|
||||||
|
expect(typeof securityModule.enforceHTTPS).toBe('function');
|
||||||
|
expect(typeof securityModule.securityHeaders).toBe('function');
|
||||||
|
expect(typeof securityModule.addRequestId).toBe('function');
|
||||||
|
expect(typeof securityModule.logSecurityEvent).toBe('function');
|
||||||
|
expect(typeof securityModule.sanitizeError).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
2061
backend/tests/unit/middleware/validation.test.js
Normal file
2061
backend/tests/unit/middleware/validation.test.js
Normal file
File diff suppressed because it is too large
Load Diff
682
backend/tests/unit/routes/auth.test.js
Normal file
682
backend/tests/unit/routes/auth.test.js
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { OAuth2Client } = require('google-auth-library');
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('jsonwebtoken');
|
||||||
|
jest.mock('google-auth-library');
|
||||||
|
jest.mock('sequelize', () => ({
|
||||||
|
Op: {
|
||||||
|
or: 'or'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
User: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findByPk: jest.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock middleware
|
||||||
|
jest.mock('../../../middleware/validation', () => ({
|
||||||
|
sanitizeInput: (req, res, next) => next(),
|
||||||
|
validateRegistration: (req, res, next) => next(),
|
||||||
|
validateLogin: (req, res, next) => next(),
|
||||||
|
validateGoogleAuth: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/csrf', () => ({
|
||||||
|
csrfProtection: (req, res, next) => next(),
|
||||||
|
getCSRFToken: (req, res) => res.json({ csrfToken: 'test-csrf-token' })
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||||
|
loginLimiter: (req, res, next) => next(),
|
||||||
|
registerLimiter: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { User } = require('../../../models');
|
||||||
|
|
||||||
|
// Set up OAuth2Client mock before requiring authRoutes
|
||||||
|
const mockGoogleClient = {
|
||||||
|
verifyIdToken: jest.fn()
|
||||||
|
};
|
||||||
|
OAuth2Client.mockImplementation(() => mockGoogleClient);
|
||||||
|
|
||||||
|
const authRoutes = require('../../../routes/auth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
describe('Auth Routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset environment
|
||||||
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
process.env.GOOGLE_CLIENT_ID = 'test-google-client-id';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
// Reset JWT mock to return different tokens for each call
|
||||||
|
let tokenCallCount = 0;
|
||||||
|
jwt.sign.mockImplementation(() => {
|
||||||
|
tokenCallCount++;
|
||||||
|
return tokenCallCount === 1 ? 'access-token' : 'refresh-token';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /auth/csrf-token', () => {
|
||||||
|
it('should return CSRF token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/auth/csrf-token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveProperty('csrfToken');
|
||||||
|
expect(response.body.csrfToken).toBe('test-csrf-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/register', () => {
|
||||||
|
it('should register a new user successfully', async () => {
|
||||||
|
User.findOne.mockResolvedValue(null); // No existing user
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.create.mockResolvedValue(newUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
phone: '1234567890'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.user).toEqual({
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that cookies are set
|
||||||
|
expect(response.headers['set-cookie']).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('accessToken'),
|
||||||
|
expect.stringContaining('refreshToken')
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with existing email', async () => {
|
||||||
|
User.findOne.mockResolvedValue({ id: 1, email: 'test@example.com' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Registration failed');
|
||||||
|
expect(response.body.details[0].message).toBe('An account with this email already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject registration with existing username', async () => {
|
||||||
|
User.findOne.mockResolvedValue({ id: 1, username: 'testuser' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Registration failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle registration errors', async () => {
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
User.create.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.error).toBe('Registration failed. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/login', () => {
|
||||||
|
it('should login user with valid credentials', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
isLocked: jest.fn().mockReturnValue(false),
|
||||||
|
comparePassword: jest.fn().mockResolvedValue(true),
|
||||||
|
resetLoginAttempts: jest.fn().mockResolvedValue()
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user).toEqual({
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
expect(mockUser.resetLoginAttempts).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with invalid email', async () => {
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'nonexistent@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with invalid password', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
isLocked: jest.fn().mockReturnValue(false),
|
||||||
|
comparePassword: jest.fn().mockResolvedValue(false),
|
||||||
|
incLoginAttempts: jest.fn().mockResolvedValue()
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Invalid credentials');
|
||||||
|
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login for locked account', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
isLocked: jest.fn().mockReturnValue(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(423);
|
||||||
|
expect(response.body.error).toContain('Account is temporarily locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle login errors', async () => {
|
||||||
|
User.findOne.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.error).toBe('Login failed. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/google', () => {
|
||||||
|
it('should handle Google OAuth login for new user', async () => {
|
||||||
|
const mockPayload = {
|
||||||
|
sub: 'google123',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User',
|
||||||
|
picture: 'profile.jpg'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => mockPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
User.findOne
|
||||||
|
.mockResolvedValueOnce(null) // No existing Google user
|
||||||
|
.mockResolvedValueOnce(null); // No existing email user
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'test_gle123',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
profileImage: 'profile.jpg'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.create.mockResolvedValue(newUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'valid-google-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user).toEqual(newUser);
|
||||||
|
expect(User.create).toHaveBeenCalledWith({
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
authProvider: 'google',
|
||||||
|
providerId: 'google123',
|
||||||
|
profileImage: 'profile.jpg',
|
||||||
|
username: 'test_gle123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Google OAuth login for existing user', async () => {
|
||||||
|
const mockPayload = {
|
||||||
|
sub: 'google123',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => mockPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(existingUser);
|
||||||
|
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'valid-google-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user).toEqual(existingUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when email exists with different auth provider', async () => {
|
||||||
|
const mockPayload = {
|
||||||
|
sub: 'google123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => mockPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
User.findOne
|
||||||
|
.mockResolvedValueOnce(null) // No Google user
|
||||||
|
.mockResolvedValueOnce({ id: 1, email: 'test@example.com' }); // Existing email user
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'valid-google-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(response.body.error).toContain('An account with this email already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject missing ID token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('ID token is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle expired Google token', async () => {
|
||||||
|
const error = new Error('Token used too late');
|
||||||
|
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'expired-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Google token has expired. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid Google token', async () => {
|
||||||
|
const error = new Error('Invalid token signature');
|
||||||
|
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'invalid-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Invalid Google token. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed Google token', async () => {
|
||||||
|
const error = new Error('Wrong number of segments in token');
|
||||||
|
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'malformed.token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Malformed Google token. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing required user information', async () => {
|
||||||
|
const mockPayload = {
|
||||||
|
sub: 'google123',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
// Missing given_name and family_name
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => mockPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'valid-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Required user information not provided by Google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unexpected Google auth errors', async () => {
|
||||||
|
const unexpectedError = new Error('Unexpected Google error');
|
||||||
|
mockGoogleClient.verifyIdToken.mockRejectedValue(unexpectedError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({
|
||||||
|
idToken: 'error-token'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.error).toBe('Google authentication failed. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/refresh', () => {
|
||||||
|
it('should refresh access token with valid refresh token', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
jwt.verify.mockReturnValue({ id: 1, type: 'refresh' });
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
jwt.sign.mockReturnValue('new-access-token');
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/refresh')
|
||||||
|
.set('Cookie', ['refreshToken=valid-refresh-token']);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user).toEqual(mockUser);
|
||||||
|
expect(response.headers['set-cookie']).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('accessToken=new-access-token')
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject missing refresh token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/refresh');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Refresh token required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid refresh token', async () => {
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/refresh')
|
||||||
|
.set('Cookie', ['refreshToken=invalid-token']);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Invalid or expired refresh token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-refresh token type', async () => {
|
||||||
|
jwt.verify.mockReturnValue({ id: 1, type: 'access' });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/refresh')
|
||||||
|
.set('Cookie', ['refreshToken=access-token']);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('Invalid refresh token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject refresh token for non-existent user', async () => {
|
||||||
|
jwt.verify.mockReturnValue({ id: 999, type: 'refresh' });
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/refresh')
|
||||||
|
.set('Cookie', ['refreshToken=valid-token']);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body.error).toBe('User not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/logout', () => {
|
||||||
|
it('should logout user and clear cookies', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/logout');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Logged out successfully');
|
||||||
|
|
||||||
|
// Check that cookies are cleared
|
||||||
|
expect(response.headers['set-cookie']).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('accessToken=;'),
|
||||||
|
expect.stringContaining('refreshToken=;')
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Security features', () => {
|
||||||
|
it('should set secure cookies in production', async () => {
|
||||||
|
process.env.NODE_ENV = 'prod';
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
const newUser = { id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' };
|
||||||
|
User.create.mockResolvedValue(newUser);
|
||||||
|
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'test',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
// In production, cookies should have secure flag
|
||||||
|
expect(response.headers['set-cookie'][0]).toContain('Secure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique username for Google users', async () => {
|
||||||
|
const mockPayload = {
|
||||||
|
sub: 'google123456',
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => mockPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
User.findOne
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
User.create.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: 'test_123456',
|
||||||
|
email: 'test@gmail.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
jwt.sign.mockReturnValueOnce('token').mockReturnValueOnce('refresh');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/auth/google')
|
||||||
|
.send({ idToken: 'valid-token' });
|
||||||
|
|
||||||
|
expect(User.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'test_123456' // email prefix + last 6 chars of Google ID
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Token management', () => {
|
||||||
|
it('should generate both access and refresh tokens on registration', async () => {
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||||
|
|
||||||
|
jwt.sign
|
||||||
|
.mockReturnValueOnce('access-token')
|
||||||
|
.mockReturnValueOnce('refresh-token');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'test',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
{ id: 1 },
|
||||||
|
'test-secret',
|
||||||
|
{ expiresIn: '15m' }
|
||||||
|
);
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
{ id: 1, type: 'refresh' },
|
||||||
|
'test-secret',
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct cookie options', async () => {
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||||
|
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({
|
||||||
|
username: 'test',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User'
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = response.headers['set-cookie'];
|
||||||
|
expect(cookies[0]).toContain('HttpOnly');
|
||||||
|
expect(cookies[0]).toContain('SameSite=Strict');
|
||||||
|
expect(cookies[1]).toContain('HttpOnly');
|
||||||
|
expect(cookies[1]).toContain('SameSite=Strict');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const itemRequestsRouter = require('../../../routes/itemRequests');
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
ItemRequest: {
|
||||||
|
findAndCountAll: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
ItemRequestResponse: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
User: jest.fn(),
|
||||||
|
Item: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: jest.fn((req, res, next) => {
|
||||||
|
req.user = { id: 1 };
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('sequelize', () => ({
|
||||||
|
Op: {
|
||||||
|
or: Symbol('or'),
|
||||||
|
iLike: Symbol('iLike'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ItemRequest, ItemRequestResponse, User, Item } = require('../../../models');
|
||||||
|
|
||||||
|
// Create express app with the router
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/item-requests', itemRequestsRouter);
|
||||||
|
|
||||||
|
// Mock models
|
||||||
|
const mockItemRequestFindAndCountAll = ItemRequest.findAndCountAll;
|
||||||
|
const mockItemRequestFindAll = ItemRequest.findAll;
|
||||||
|
const mockItemRequestFindByPk = ItemRequest.findByPk;
|
||||||
|
const mockItemRequestCreate = ItemRequest.create;
|
||||||
|
const mockItemRequestResponseFindByPk = ItemRequestResponse.findByPk;
|
||||||
|
const mockItemRequestResponseCreate = ItemRequestResponse.create;
|
||||||
|
|
||||||
|
describe('ItemRequests Routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', () => {
|
||||||
|
it('should get item requests with default pagination and status', async () => {
|
||||||
|
const mockRequestsData = {
|
||||||
|
count: 25,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Need a Camera',
|
||||||
|
description: 'Looking for a DSLR camera for weekend photography',
|
||||||
|
status: 'open',
|
||||||
|
requesterId: 2,
|
||||||
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
requester: {
|
||||||
|
id: 2,
|
||||||
|
username: 'jane_doe',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Power Drill Needed',
|
||||||
|
description: 'Need a drill for home improvement project',
|
||||||
|
status: 'open',
|
||||||
|
requesterId: 3,
|
||||||
|
createdAt: '2024-01-14T10:00:00.000Z',
|
||||||
|
requester: {
|
||||||
|
id: 3,
|
||||||
|
username: 'bob_smith',
|
||||||
|
firstName: 'Bob',
|
||||||
|
lastName: 'Smith'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue(mockRequestsData);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
requests: mockRequestsData.rows,
|
||||||
|
totalPages: 2,
|
||||||
|
currentPage: 1,
|
||||||
|
totalRequests: 25
|
||||||
|
});
|
||||||
|
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'open' },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter requests with search query', async () => {
|
||||||
|
const mockSearchResults = {
|
||||||
|
count: 5,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Need a Camera',
|
||||||
|
description: 'Looking for a DSLR camera',
|
||||||
|
status: 'open'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue(mockSearchResults);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests?search=camera&page=1&limit=10');
|
||||||
|
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
status: 'open',
|
||||||
|
[Op.or]: [
|
||||||
|
{ title: { [Op.iLike]: '%camera%' } },
|
||||||
|
{ description: { [Op.iLike]: '%camera%' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: expect.any(Array),
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom pagination', async () => {
|
||||||
|
const mockData = { count: 50, rows: [] };
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests?page=3&limit=5');
|
||||||
|
|
||||||
|
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'open' },
|
||||||
|
include: expect.any(Array),
|
||||||
|
limit: 5,
|
||||||
|
offset: 10, // (3-1) * 5
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by custom status', async () => {
|
||||||
|
const mockData = { count: 10, rows: [] };
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.get('/item-requests?status=fulfilled');
|
||||||
|
|
||||||
|
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'fulfilled' },
|
||||||
|
include: expect.any(Array),
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestFindAndCountAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /my-requests', () => {
|
||||||
|
it('should get user\'s own requests with responses', async () => {
|
||||||
|
const mockRequests = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'My Camera Request',
|
||||||
|
description: 'Need a camera',
|
||||||
|
status: 'open',
|
||||||
|
requesterId: 1,
|
||||||
|
requester: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
message: 'I have a Canon DSLR available',
|
||||||
|
responder: {
|
||||||
|
id: 2,
|
||||||
|
username: 'jane_doe',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
existingItem: {
|
||||||
|
id: 5,
|
||||||
|
name: 'Canon EOS 5D',
|
||||||
|
description: 'Professional DSLR camera'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockItemRequestFindAll.mockResolvedValue(mockRequests);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests/my-requests');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockRequests);
|
||||||
|
expect(mockItemRequestFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { requesterId: 1 },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ItemRequestResponse,
|
||||||
|
as: 'responses',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests/my-requests');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id', () => {
|
||||||
|
it('should get specific request with responses', async () => {
|
||||||
|
const mockRequest = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Camera Request',
|
||||||
|
description: 'Need a DSLR camera',
|
||||||
|
status: 'open',
|
||||||
|
requesterId: 2,
|
||||||
|
requester: {
|
||||||
|
id: 2,
|
||||||
|
username: 'jane_doe',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
message: 'I have a Canon DSLR',
|
||||||
|
responder: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
existingItem: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockRequest);
|
||||||
|
expect(mockItemRequestFindByPk).toHaveBeenCalledWith('1', {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ItemRequestResponse,
|
||||||
|
as: 'responses',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent request', async () => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', () => {
|
||||||
|
it('should create a new item request', async () => {
|
||||||
|
const requestData = {
|
||||||
|
title: 'Need a Drill',
|
||||||
|
description: 'Looking for a power drill for weekend project',
|
||||||
|
category: 'tools',
|
||||||
|
budget: 50,
|
||||||
|
location: 'New York'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedRequest = {
|
||||||
|
id: 3,
|
||||||
|
...requestData,
|
||||||
|
requesterId: 1,
|
||||||
|
status: 'open'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRequestWithRequester = {
|
||||||
|
...mockCreatedRequest,
|
||||||
|
requester: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockItemRequestCreate.mockResolvedValue(mockCreatedRequest);
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockRequestWithRequester);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests')
|
||||||
|
.send(requestData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(mockRequestWithRequester);
|
||||||
|
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||||
|
...requestData,
|
||||||
|
requesterId: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors during creation', async () => {
|
||||||
|
mockItemRequestCreate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests')
|
||||||
|
.send({
|
||||||
|
title: 'Test Request',
|
||||||
|
description: 'Test description'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /:id', () => {
|
||||||
|
const mockRequest = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Original Title',
|
||||||
|
requesterId: 1,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update item request for owner', async () => {
|
||||||
|
const updateData = {
|
||||||
|
title: 'Updated Title',
|
||||||
|
description: 'Updated description'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUpdatedRequest = {
|
||||||
|
...mockRequest,
|
||||||
|
...updateData,
|
||||||
|
requester: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.update.mockResolvedValue();
|
||||||
|
mockItemRequestFindByPk
|
||||||
|
.mockResolvedValueOnce(mockRequest)
|
||||||
|
.mockResolvedValueOnce(mockUpdatedRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/1')
|
||||||
|
.send(updateData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
title: 'Updated Title',
|
||||||
|
description: 'Updated description',
|
||||||
|
requesterId: 1,
|
||||||
|
requester: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(mockRequest.update).toHaveBeenCalledWith(updateData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent request', async () => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/999')
|
||||||
|
.send({ title: 'Updated' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/1')
|
||||||
|
.send({ title: 'Updated' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/1')
|
||||||
|
.send({ title: 'Updated' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /:id', () => {
|
||||||
|
const mockRequest = {
|
||||||
|
id: 1,
|
||||||
|
requesterId: 1,
|
||||||
|
destroy: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete item request for owner', async () => {
|
||||||
|
mockRequest.destroy.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/item-requests/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(mockRequest.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent request', async () => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/item-requests/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/item-requests/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/item-requests/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /:id/responses', () => {
|
||||||
|
const mockRequest = {
|
||||||
|
id: 1,
|
||||||
|
requesterId: 2,
|
||||||
|
status: 'open',
|
||||||
|
increment: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponseData = {
|
||||||
|
message: 'I have a drill you can borrow',
|
||||||
|
price: 25,
|
||||||
|
existingItemId: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedResponse = {
|
||||||
|
id: 1,
|
||||||
|
...mockResponseData,
|
||||||
|
itemRequestId: 1,
|
||||||
|
responderId: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponseWithDetails = {
|
||||||
|
...mockCreatedResponse,
|
||||||
|
responder: {
|
||||||
|
id: 1,
|
||||||
|
username: 'john_doe',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
existingItem: {
|
||||||
|
id: 5,
|
||||||
|
name: 'Power Drill',
|
||||||
|
description: 'Cordless power drill'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||||
|
mockItemRequestResponseCreate.mockResolvedValue(mockCreatedResponse);
|
||||||
|
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponseWithDetails);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a response to item request', async () => {
|
||||||
|
mockRequest.increment.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests/1/responses')
|
||||||
|
.send(mockResponseData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(mockResponseWithDetails);
|
||||||
|
expect(mockItemRequestResponseCreate).toHaveBeenCalledWith({
|
||||||
|
...mockResponseData,
|
||||||
|
itemRequestId: '1',
|
||||||
|
responderId: 1
|
||||||
|
});
|
||||||
|
expect(mockRequest.increment).toHaveBeenCalledWith('responseCount');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent request', async () => {
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests/999/responses')
|
||||||
|
.send(mockResponseData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent responding to own request', async () => {
|
||||||
|
const ownRequest = { ...mockRequest, requesterId: 1 };
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(ownRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests/1/responses')
|
||||||
|
.send(mockResponseData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Cannot respond to your own request' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent responding to closed request', async () => {
|
||||||
|
const closedRequest = { ...mockRequest, status: 'fulfilled' };
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(closedRequest);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests/1/responses')
|
||||||
|
.send(mockResponseData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Cannot respond to closed request' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestResponseCreate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests/1/responses')
|
||||||
|
.send(mockResponseData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /responses/:responseId/status', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
id: 1,
|
||||||
|
status: 'pending',
|
||||||
|
itemRequest: {
|
||||||
|
id: 1,
|
||||||
|
requesterId: 1
|
||||||
|
},
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update response status to accepted and fulfill request', async () => {
|
||||||
|
const updatedResponse = {
|
||||||
|
...mockResponse,
|
||||||
|
status: 'accepted',
|
||||||
|
responder: {
|
||||||
|
id: 2,
|
||||||
|
username: 'jane_doe',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
existingItem: null
|
||||||
|
};
|
||||||
|
|
||||||
|
mockResponse.update.mockResolvedValue();
|
||||||
|
mockResponse.itemRequest.update = jest.fn().mockResolvedValue();
|
||||||
|
mockItemRequestResponseFindByPk
|
||||||
|
.mockResolvedValueOnce(mockResponse)
|
||||||
|
.mockResolvedValueOnce(updatedResponse);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/responses/1/status')
|
||||||
|
.send({ status: 'accepted' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
status: 'accepted',
|
||||||
|
itemRequest: {
|
||||||
|
id: 1,
|
||||||
|
requesterId: 1
|
||||||
|
},
|
||||||
|
responder: {
|
||||||
|
id: 2,
|
||||||
|
username: 'jane_doe',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
},
|
||||||
|
existingItem: null
|
||||||
|
});
|
||||||
|
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'accepted' });
|
||||||
|
expect(mockResponse.itemRequest.update).toHaveBeenCalledWith({ status: 'fulfilled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update response status without fulfilling request', async () => {
|
||||||
|
const updatedResponse = { ...mockResponse, status: 'declined' };
|
||||||
|
mockResponse.update.mockResolvedValue();
|
||||||
|
mockItemRequestResponseFindByPk
|
||||||
|
.mockResolvedValueOnce(mockResponse)
|
||||||
|
.mockResolvedValueOnce(updatedResponse);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/responses/1/status')
|
||||||
|
.send({ status: 'declined' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'declined' });
|
||||||
|
expect(mockResponse.itemRequest.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent response', async () => {
|
||||||
|
mockItemRequestResponseFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/responses/999/status')
|
||||||
|
.send({ status: 'accepted' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Response not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedResponse = {
|
||||||
|
...mockResponse,
|
||||||
|
itemRequest: { ...mockResponse.itemRequest, requesterId: 2 }
|
||||||
|
};
|
||||||
|
mockItemRequestResponseFindByPk.mockResolvedValue(unauthorizedResponse);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/responses/1/status')
|
||||||
|
.send({ status: 'accepted' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Only the requester can update response status' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockItemRequestResponseFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/item-requests/responses/1/status')
|
||||||
|
.send({ status: 'accepted' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle empty search results', async () => {
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests?search=nonexistent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.requests).toEqual([]);
|
||||||
|
expect(response.body.totalRequests).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero page calculation', async () => {
|
||||||
|
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/item-requests');
|
||||||
|
|
||||||
|
expect(response.body.totalPages).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle request without optional fields', async () => {
|
||||||
|
const minimalRequest = {
|
||||||
|
title: 'Basic Request',
|
||||||
|
description: 'Simple description'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreated = { id: 1, ...minimalRequest, requesterId: 1 };
|
||||||
|
const mockWithRequester = {
|
||||||
|
...mockCreated,
|
||||||
|
requester: { id: 1, username: 'test' }
|
||||||
|
};
|
||||||
|
|
||||||
|
mockItemRequestCreate.mockResolvedValue(mockCreated);
|
||||||
|
mockItemRequestFindByPk.mockResolvedValue(mockWithRequester);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/item-requests')
|
||||||
|
.send(minimalRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||||
|
...minimalRequest,
|
||||||
|
requesterId: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1026
backend/tests/unit/routes/items.test.js
Normal file
1026
backend/tests/unit/routes/items.test.js
Normal file
File diff suppressed because it is too large
Load Diff
726
backend/tests/unit/routes/maps.test.js
Normal file
726
backend/tests/unit/routes/maps.test.js
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('../../../services/googleMapsService', () => ({
|
||||||
|
getPlacesAutocomplete: jest.fn(),
|
||||||
|
getPlaceDetails: jest.fn(),
|
||||||
|
geocodeAddress: jest.fn(),
|
||||||
|
isConfigured: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth middleware
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: (req, res, next) => {
|
||||||
|
if (req.headers.authorization) {
|
||||||
|
req.user = { id: 1 };
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock rate limiter middleware
|
||||||
|
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||||
|
burstProtection: (req, res, next) => next(),
|
||||||
|
placesAutocomplete: (req, res, next) => next(),
|
||||||
|
placeDetails: (req, res, next) => next(),
|
||||||
|
geocoding: (req, res, next) => next()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const googleMapsService = require('../../../services/googleMapsService');
|
||||||
|
const mapsRoutes = require('../../../routes/maps');
|
||||||
|
|
||||||
|
// Set up Express app for testing
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/maps', mapsRoutes);
|
||||||
|
|
||||||
|
describe('Maps Routes', () => {
|
||||||
|
let consoleSpy, consoleErrorSpy, consoleLogSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input Validation Middleware', () => {
|
||||||
|
it('should trim and validate input length', async () => {
|
||||||
|
const longInput = 'a'.repeat(501);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: longInput });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Input too long' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate place ID format', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: 'invalid@place#id!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Invalid place ID format' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate address length', async () => {
|
||||||
|
const longAddress = 'a'.repeat(501);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: longAddress });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Address too long' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow valid place ID format', async () => {
|
||||||
|
googleMapsService.getPlaceDetails.mockResolvedValue({
|
||||||
|
result: { name: 'Test Place' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: 'ChIJ123abc_DEF' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from inputs', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({
|
||||||
|
predictions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: ' test input ' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||||
|
'test input',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling Middleware', () => {
|
||||||
|
it('should handle API key configuration errors', async () => {
|
||||||
|
const configError = new Error('API key not configured');
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockRejectedValue(configError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Maps service temporarily unavailable',
|
||||||
|
details: 'Configuration issue'
|
||||||
|
});
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Maps service error:',
|
||||||
|
'API key not configured'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quota exceeded errors', async () => {
|
||||||
|
const quotaError = new Error('quota exceeded');
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockRejectedValue(quotaError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Service temporarily unavailable due to high demand',
|
||||||
|
details: 'Please try again later'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle generic service errors', async () => {
|
||||||
|
const serviceError = new Error('Network timeout');
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockRejectedValue(serviceError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Failed to process request',
|
||||||
|
details: 'Network timeout'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /places/autocomplete', () => {
|
||||||
|
const mockPredictions = {
|
||||||
|
predictions: [
|
||||||
|
{
|
||||||
|
description: '123 Main St, New York, NY, USA',
|
||||||
|
place_id: 'ChIJ123abc',
|
||||||
|
types: ['street_address']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '456 Oak Ave, New York, NY, USA',
|
||||||
|
place_id: 'ChIJ456def',
|
||||||
|
types: ['street_address']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return autocomplete predictions successfully', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
input: '123 Main',
|
||||||
|
types: ['address'],
|
||||||
|
componentRestrictions: { country: 'us' },
|
||||||
|
sessionToken: 'session123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockPredictions);
|
||||||
|
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||||
|
'123 Main',
|
||||||
|
{
|
||||||
|
types: ['address'],
|
||||||
|
componentRestrictions: { country: 'us' },
|
||||||
|
sessionToken: 'session123'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Places Autocomplete: user=1, query_length=8, results=2'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default types when not provided', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||||
|
'test',
|
||||||
|
{
|
||||||
|
types: ['address'],
|
||||||
|
componentRestrictions: undefined,
|
||||||
|
sessionToken: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty predictions for short input', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'a' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ predictions: [] });
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty predictions for missing input', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ predictions: [] });
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body).toEqual({ error: 'No token provided' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log request with user ID from authenticated user', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Should log with user ID
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('user=1')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty predictions from service', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'nonexistent place' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ predictions: [] });
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Places Autocomplete: user=1, query_length=17, results=0'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service response without predictions array', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({});
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Places Autocomplete: user=1, query_length=4, results=0'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /places/details', () => {
|
||||||
|
const mockPlaceDetails = {
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123abc',
|
||||||
|
name: 'Central Park',
|
||||||
|
formatted_address: 'New York, NY 10024, USA',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.785091, lng: -73.968285 }
|
||||||
|
},
|
||||||
|
types: ['park', 'point_of_interest']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return place details successfully', async () => {
|
||||||
|
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
placeId: 'ChIJ123abc',
|
||||||
|
sessionToken: 'session123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockPlaceDetails);
|
||||||
|
|
||||||
|
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||||
|
'ChIJ123abc',
|
||||||
|
{ sessionToken: 'session123' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Place Details: user=1, placeId=ChIJ123abc...'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle place details without session token', async () => {
|
||||||
|
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: 'ChIJ123abc' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||||
|
'ChIJ123abc',
|
||||||
|
{ sessionToken: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for missing place ID', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||||
|
expect(googleMapsService.getPlaceDetails).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for empty place ID', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: '' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.send({ placeId: 'ChIJ123abc' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long place IDs in logging', async () => {
|
||||||
|
const longPlaceId = 'ChIJ' + 'a'.repeat(100);
|
||||||
|
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: longPlaceId });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
`Place Details: user=1, placeId=${longPlaceId.substring(0, 10)}...`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service errors', async () => {
|
||||||
|
const serviceError = new Error('Place not found');
|
||||||
|
googleMapsService.getPlaceDetails.mockRejectedValue(serviceError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: 'ChIJ123abc' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Failed to process request',
|
||||||
|
details: 'Place not found'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /geocode', () => {
|
||||||
|
const mockGeocodeResults = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
formatted_address: '123 Main St, New York, NY 10001, USA',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7484405, lng: -73.9856644 }
|
||||||
|
},
|
||||||
|
place_id: 'ChIJ123abc',
|
||||||
|
types: ['street_address']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return geocoding results successfully', async () => {
|
||||||
|
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
address: '123 Main St, New York, NY',
|
||||||
|
componentRestrictions: { country: 'US' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockGeocodeResults);
|
||||||
|
|
||||||
|
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||||
|
'123 Main St, New York, NY',
|
||||||
|
{ componentRestrictions: { country: 'US' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Geocoding: user=1, address_length=25'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle geocoding without component restrictions', async () => {
|
||||||
|
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: '123 Main St' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||||
|
'123 Main St',
|
||||||
|
{ componentRestrictions: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for missing address', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Address is required' });
|
||||||
|
expect(googleMapsService.geocodeAddress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for empty address', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: '' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Address is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.send({ address: '123 Main St' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle addresses with special characters', async () => {
|
||||||
|
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: '123 Main St, Apt #4B' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||||
|
'123 Main St, Apt #4B',
|
||||||
|
{ componentRestrictions: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service errors', async () => {
|
||||||
|
const serviceError = new Error('Invalid address');
|
||||||
|
googleMapsService.geocodeAddress.mockRejectedValue(serviceError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: 'invalid address' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Failed to process request',
|
||||||
|
details: 'Invalid address'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty geocoding results', async () => {
|
||||||
|
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: 'nonexistent address' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ results: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health', () => {
|
||||||
|
it('should return healthy status when service is configured', async () => {
|
||||||
|
googleMapsService.isConfigured.mockReturnValue(true);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/maps/health');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
status: 'healthy',
|
||||||
|
service: 'Google Maps API Proxy',
|
||||||
|
timestamp: expect.any(String),
|
||||||
|
configuration: {
|
||||||
|
apiKeyConfigured: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify timestamp is a valid ISO string
|
||||||
|
expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unavailable status when service is not configured', async () => {
|
||||||
|
googleMapsService.isConfigured.mockReturnValue(false);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/maps/health');
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
status: 'unavailable',
|
||||||
|
service: 'Google Maps API Proxy',
|
||||||
|
timestamp: expect.any(String),
|
||||||
|
configuration: {
|
||||||
|
apiKeyConfigured: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not require authentication', async () => {
|
||||||
|
googleMapsService.isConfigured.mockReturnValue(true);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/maps/health');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Should work without authorization header
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always return current timestamp', async () => {
|
||||||
|
googleMapsService.isConfigured.mockReturnValue(true);
|
||||||
|
|
||||||
|
const beforeTime = new Date().toISOString();
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/maps/health');
|
||||||
|
const afterTime = new Date().toISOString();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(new Date(response.body.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(beforeTime).getTime());
|
||||||
|
expect(new Date(response.body.timestamp).getTime()).toBeLessThanOrEqual(new Date(afterTime).getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration', () => {
|
||||||
|
it('should apply burst protection to all endpoints', async () => {
|
||||||
|
// This test verifies that rate limiting middleware is applied
|
||||||
|
// In a real scenario, we'd test actual rate limiting behavior
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The fact that the request succeeded means rate limiting middleware was applied without blocking
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases and Security', () => {
|
||||||
|
it('should handle null input gracefully', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: null });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ predictions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined values in request body', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
input: 'test',
|
||||||
|
types: undefined,
|
||||||
|
componentRestrictions: undefined,
|
||||||
|
sessionToken: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||||
|
'test',
|
||||||
|
{
|
||||||
|
types: ['address'], // Should use default
|
||||||
|
componentRestrictions: undefined,
|
||||||
|
sessionToken: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed JSON gracefully', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send('invalid json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400); // Express will handle malformed JSON
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize input to prevent injection attacks', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||||
|
|
||||||
|
const maliciousInput = '<script>alert("xss")</script>';
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: maliciousInput });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Input should be treated as string and passed through
|
||||||
|
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||||
|
maliciousInput,
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent requests to different endpoints', async () => {
|
||||||
|
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||||
|
googleMapsService.getPlaceDetails.mockResolvedValue({ result: {} });
|
||||||
|
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||||
|
|
||||||
|
const [response1, response2, response3] = await Promise.all([
|
||||||
|
request(app)
|
||||||
|
.post('/maps/places/autocomplete')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ input: 'test1' }),
|
||||||
|
request(app)
|
||||||
|
.post('/maps/places/details')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ placeId: 'ChIJ123abc' }),
|
||||||
|
request(app)
|
||||||
|
.post('/maps/geocode')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ address: 'test address' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response1.status).toBe(200);
|
||||||
|
expect(response2.status).toBe(200);
|
||||||
|
expect(response3.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
657
backend/tests/unit/routes/messages.test.js
Normal file
657
backend/tests/unit/routes/messages.test.js
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const messagesRouter = require('../../../routes/messages');
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
Message: {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: jest.fn((req, res, next) => {
|
||||||
|
req.user = { id: 1 };
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('sequelize', () => ({
|
||||||
|
Op: {
|
||||||
|
or: Symbol('or'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Message, User } = require('../../../models');
|
||||||
|
|
||||||
|
// Create express app with the router
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/messages', messagesRouter);
|
||||||
|
|
||||||
|
// Mock models
|
||||||
|
const mockMessageFindAll = Message.findAll;
|
||||||
|
const mockMessageFindOne = Message.findOne;
|
||||||
|
const mockMessageFindByPk = Message.findByPk;
|
||||||
|
const mockMessageCreate = Message.create;
|
||||||
|
const mockMessageCount = Message.count;
|
||||||
|
const mockUserFindByPk = User.findByPk;
|
||||||
|
|
||||||
|
describe('Messages Routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', () => {
|
||||||
|
it('should get inbox messages for authenticated user', async () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
subject: 'Test Message',
|
||||||
|
content: 'Hello there!',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
profileImage: 'jane.jpg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
senderId: 3,
|
||||||
|
receiverId: 1,
|
||||||
|
subject: 'Another Message',
|
||||||
|
content: 'Hi!',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: '2024-01-14T10:00:00.000Z',
|
||||||
|
sender: {
|
||||||
|
id: 3,
|
||||||
|
firstName: 'Bob',
|
||||||
|
lastName: 'Johnson',
|
||||||
|
profileImage: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockMessageFindAll.mockResolvedValue(mockMessages);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockMessages);
|
||||||
|
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { receiverId: 1 },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'sender',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /sent', () => {
|
||||||
|
it('should get sent messages for authenticated user', async () => {
|
||||||
|
const mockSentMessages = [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'My Message',
|
||||||
|
content: 'Hello Jane!',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-01-15T12:00:00.000Z',
|
||||||
|
receiver: {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
profileImage: 'jane.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockMessageFindAll.mockResolvedValue(mockSentMessages);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/sent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockSentMessages);
|
||||||
|
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { senderId: 1 },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'receiver',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/sent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id', () => {
|
||||||
|
const mockMessage = {
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
subject: 'Test Message',
|
||||||
|
content: 'Hello there!',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
profileImage: 'jane.jpg'
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
},
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
senderId: 1,
|
||||||
|
content: 'Reply message',
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get message with replies for receiver', async () => {
|
||||||
|
mockMessage.update.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
subject: 'Test Message',
|
||||||
|
content: 'Hello there!',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
profileImage: 'jane.jpg'
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
},
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
senderId: 1,
|
||||||
|
content: 'Reply message',
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get message without marking as read for sender', async () => {
|
||||||
|
const senderMessage = { ...mockMessage, senderId: 1, receiverId: 2 };
|
||||||
|
mockMessageFindOne.mockResolvedValue(senderMessage);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'Test Message',
|
||||||
|
content: 'Hello there!',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
profileImage: 'jane.jpg'
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
},
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
senderId: 1,
|
||||||
|
content: 'Reply message',
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mark already read message as read', async () => {
|
||||||
|
const readMessage = { ...mockMessage, isRead: true };
|
||||||
|
mockMessageFindOne.mockResolvedValue(readMessage);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent message', async () => {
|
||||||
|
mockMessageFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Message not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', () => {
|
||||||
|
const mockReceiver = {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
email: 'jane@example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedMessage = {
|
||||||
|
id: 5,
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'New Message',
|
||||||
|
content: 'Hello Jane!',
|
||||||
|
parentMessageId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMessageWithSender = {
|
||||||
|
...mockCreatedMessage,
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
profileImage: 'john.jpg'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||||
|
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||||
|
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new message', async () => {
|
||||||
|
const messageData = {
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'New Message',
|
||||||
|
content: 'Hello Jane!',
|
||||||
|
parentMessageId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send(messageData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(mockMessageWithSender);
|
||||||
|
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'New Message',
|
||||||
|
content: 'Hello Jane!',
|
||||||
|
parentMessageId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a reply message with parentMessageId', async () => {
|
||||||
|
const replyData = {
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'Re: Original Message',
|
||||||
|
content: 'This is a reply',
|
||||||
|
parentMessageId: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send(replyData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'Re: Original Message',
|
||||||
|
content: 'This is a reply',
|
||||||
|
parentMessageId: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent receiver', async () => {
|
||||||
|
mockUserFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send({
|
||||||
|
receiverId: 999,
|
||||||
|
subject: 'Test',
|
||||||
|
content: 'Test message'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Receiver not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent sending messages to self', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send({
|
||||||
|
receiverId: 1, // Same as sender ID
|
||||||
|
subject: 'Self Message',
|
||||||
|
content: 'Hello self!'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Cannot send messages to yourself' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors during creation', async () => {
|
||||||
|
mockMessageCreate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send({
|
||||||
|
receiverId: 2,
|
||||||
|
subject: 'Test',
|
||||||
|
content: 'Test message'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /:id/read', () => {
|
||||||
|
const mockMessage = {
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
isRead: false,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark message as read', async () => {
|
||||||
|
const updatedMessage = { ...mockMessage, isRead: true };
|
||||||
|
mockMessage.update.mockResolvedValue(updatedMessage);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/messages/1/read');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
isRead: false
|
||||||
|
});
|
||||||
|
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||||
|
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: '1',
|
||||||
|
receiverId: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent message', async () => {
|
||||||
|
mockMessageFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/messages/999/read');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Message not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when user is not the receiver', async () => {
|
||||||
|
// Message exists but user is not the receiver (query will return null)
|
||||||
|
mockMessageFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/messages/1/read');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Message not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/messages/1/read');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /unread/count', () => {
|
||||||
|
it('should get unread message count for authenticated user', async () => {
|
||||||
|
mockMessageCount.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/unread/count');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ count: 5 });
|
||||||
|
expect(mockMessageCount).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
receiverId: 1,
|
||||||
|
isRead: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return count of 0 when no unread messages', async () => {
|
||||||
|
mockMessageCount.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/unread/count');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockMessageCount.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/unread/count');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message authorization', () => {
|
||||||
|
it('should only find messages where user is sender or receiver', async () => {
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: '1',
|
||||||
|
[Op.or]: [
|
||||||
|
{ senderId: 1 },
|
||||||
|
{ receiverId: 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: expect.any(Array)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle empty inbox', async () => {
|
||||||
|
mockMessageFindAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty sent messages', async () => {
|
||||||
|
mockMessageFindAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/sent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle message with no replies', async () => {
|
||||||
|
const messageWithoutReplies = {
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
receiverId: 1,
|
||||||
|
subject: 'Test Message',
|
||||||
|
content: 'Hello there!',
|
||||||
|
isRead: false,
|
||||||
|
replies: [],
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
mockMessageFindOne.mockResolvedValue(messageWithoutReplies);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/messages/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.replies).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing optional fields in message creation', async () => {
|
||||||
|
const mockReceiver = { id: 2, firstName: 'Jane', lastName: 'Smith' };
|
||||||
|
const mockCreatedMessage = {
|
||||||
|
id: 6,
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: undefined,
|
||||||
|
content: 'Just content',
|
||||||
|
parentMessageId: undefined
|
||||||
|
};
|
||||||
|
const mockMessageWithSender = {
|
||||||
|
...mockCreatedMessage,
|
||||||
|
sender: { id: 1, firstName: 'John', lastName: 'Doe' }
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||||
|
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||||
|
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/messages')
|
||||||
|
.send({
|
||||||
|
receiverId: 2,
|
||||||
|
content: 'Just content'
|
||||||
|
// subject and parentMessageId omitted
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||||
|
senderId: 1,
|
||||||
|
receiverId: 2,
|
||||||
|
subject: undefined,
|
||||||
|
content: 'Just content',
|
||||||
|
parentMessageId: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
896
backend/tests/unit/routes/rentals.test.js
Normal file
896
backend/tests/unit/routes/rentals.test.js
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const rentalsRouter = require('../../../routes/rentals');
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
Rental: {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
Item: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
},
|
||||||
|
User: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: jest.fn((req, res, next) => {
|
||||||
|
req.user = { id: 1 };
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../utils/feeCalculator', () => ({
|
||||||
|
calculateRentalFees: jest.fn(() => ({
|
||||||
|
totalChargedAmount: 120,
|
||||||
|
platformFee: 20,
|
||||||
|
payoutAmount: 100,
|
||||||
|
})),
|
||||||
|
formatFeesForDisplay: jest.fn(() => ({
|
||||||
|
baseAmount: '$100.00',
|
||||||
|
platformFee: '$20.00',
|
||||||
|
totalAmount: '$120.00',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/refundService', () => ({
|
||||||
|
getRefundPreview: jest.fn(),
|
||||||
|
processCancellation: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/stripeService', () => ({
|
||||||
|
chargePaymentMethod: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Rental, Item, User } = require('../../../models');
|
||||||
|
const FeeCalculator = require('../../../utils/feeCalculator');
|
||||||
|
const RefundService = require('../../../services/refundService');
|
||||||
|
const StripeService = require('../../../services/stripeService');
|
||||||
|
|
||||||
|
// Create express app with the router
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/rentals', rentalsRouter);
|
||||||
|
|
||||||
|
// Mock models
|
||||||
|
const mockRentalFindAll = Rental.findAll;
|
||||||
|
const mockRentalFindByPk = Rental.findByPk;
|
||||||
|
const mockRentalFindOne = Rental.findOne;
|
||||||
|
const mockRentalCreate = Rental.create;
|
||||||
|
|
||||||
|
describe('Rentals Routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /my-rentals', () => {
|
||||||
|
it('should get rentals for authenticated user', async () => {
|
||||||
|
const mockRentals = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
renterId: 1,
|
||||||
|
item: { id: 1, name: 'Test Item' },
|
||||||
|
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
renterId: 1,
|
||||||
|
item: { id: 2, name: 'Another Item' },
|
||||||
|
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockRentals);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/my-rentals');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockRentals);
|
||||||
|
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { renterId: 1 },
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'owner',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/my-rentals');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to fetch rentals' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /my-listings', () => {
|
||||||
|
it('should get listings for authenticated user', async () => {
|
||||||
|
const mockListings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
item: { id: 1, name: 'My Item' },
|
||||||
|
renter: { id: 2, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockListings);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/my-listings');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockListings);
|
||||||
|
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { ownerId: 1 },
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'renter',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/my-listings');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to fetch listings' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id', () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
renterId: 1,
|
||||||
|
item: { id: 1, name: 'Test Item' },
|
||||||
|
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||||
|
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should get rental by ID for authorized user (renter)', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get rental by ID for authorized user (owner)', async () => {
|
||||||
|
const ownerRental = { ...mockRental, ownerId: 1, renterId: 2 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(ownerRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(ownerRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent rental', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized to view this rental' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to fetch rental' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', () => {
|
||||||
|
const mockItem = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Item',
|
||||||
|
ownerId: 2,
|
||||||
|
availability: true,
|
||||||
|
pricePerHour: 10,
|
||||||
|
pricePerDay: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedRental = {
|
||||||
|
id: 1,
|
||||||
|
itemId: 1,
|
||||||
|
renterId: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
totalAmount: 120,
|
||||||
|
platformFee: 20,
|
||||||
|
payoutAmount: 100,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRentalWithDetails = {
|
||||||
|
...mockCreatedRental,
|
||||||
|
item: mockItem,
|
||||||
|
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||||
|
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const rentalData = {
|
||||||
|
itemId: 1,
|
||||||
|
startDateTime: '2024-01-15T10:00:00.000Z',
|
||||||
|
endDateTime: '2024-01-15T18:00:00.000Z',
|
||||||
|
deliveryMethod: 'pickup',
|
||||||
|
deliveryAddress: null,
|
||||||
|
notes: 'Test rental',
|
||||||
|
stripePaymentMethodId: 'pm_test123',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Item.findByPk.mockResolvedValue(mockItem);
|
||||||
|
mockRentalFindOne.mockResolvedValue(null); // No overlapping rentals
|
||||||
|
mockRentalCreate.mockResolvedValue(mockCreatedRental);
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRentalWithDetails);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new rental with hourly pricing', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(rentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(mockRentalWithDetails);
|
||||||
|
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(80); // 8 hours * 10/hour
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new rental with daily pricing', async () => {
|
||||||
|
const dailyRentalData = {
|
||||||
|
...rentalData,
|
||||||
|
endDateTime: '2024-01-17T18:00:00.000Z', // 3 days
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(dailyRentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(150); // 3 days * 50/day
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent item', async () => {
|
||||||
|
Item.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(rentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Item not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for unavailable item', async () => {
|
||||||
|
Item.findByPk.mockResolvedValue({ ...mockItem, availability: false });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(rentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Item is not available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for overlapping rental', async () => {
|
||||||
|
mockRentalFindOne.mockResolvedValue({ id: 999 }); // Overlapping rental exists
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(rentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Item is already booked for these dates' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when payment method is missing', async () => {
|
||||||
|
const dataWithoutPayment = { ...rentalData };
|
||||||
|
delete dataWithoutPayment.stripePaymentMethodId;
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(dataWithoutPayment);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Payment method is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors during creation', async () => {
|
||||||
|
mockRentalCreate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals')
|
||||||
|
.send(rentalData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to create rental' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /:id/status', () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
renterId: 2,
|
||||||
|
status: 'pending',
|
||||||
|
stripePaymentMethodId: 'pm_test123',
|
||||||
|
totalAmount: 120,
|
||||||
|
item: { id: 1, name: 'Test Item' },
|
||||||
|
renter: {
|
||||||
|
id: 2,
|
||||||
|
username: 'renter1',
|
||||||
|
firstName: 'Alice',
|
||||||
|
lastName: 'Johnson',
|
||||||
|
stripeCustomerId: 'cus_test123'
|
||||||
|
},
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update rental status to confirmed without payment processing', async () => {
|
||||||
|
const nonPendingRental = { ...mockRental, status: 'active' };
|
||||||
|
mockRentalFindByPk.mockResolvedValueOnce(nonPendingRental);
|
||||||
|
|
||||||
|
const updatedRental = { ...nonPendingRental, status: 'confirmed' };
|
||||||
|
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(nonPendingRental.update).toHaveBeenCalledWith({ status: 'confirmed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process payment when owner approves pending rental', async () => {
|
||||||
|
// Use the original mockRental (status: 'pending') for this test
|
||||||
|
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
|
||||||
|
|
||||||
|
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||||
|
paymentIntentId: 'pi_test123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRental = {
|
||||||
|
...mockRental,
|
||||||
|
status: 'confirmed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
stripePaymentIntentId: 'pi_test123'
|
||||||
|
};
|
||||||
|
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(StripeService.chargePaymentMethod).toHaveBeenCalledWith(
|
||||||
|
'pm_test123',
|
||||||
|
120,
|
||||||
|
'cus_test123',
|
||||||
|
expect.objectContaining({
|
||||||
|
rentalId: 1,
|
||||||
|
itemName: 'Test Item',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockRental.update).toHaveBeenCalledWith({
|
||||||
|
status: 'confirmed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
stripePaymentIntentId: 'pi_test123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when renter has no Stripe customer ID', async () => {
|
||||||
|
const rentalWithoutStripeCustomer = {
|
||||||
|
...mockRental,
|
||||||
|
renter: { ...mockRental.renter, stripeCustomerId: null }
|
||||||
|
};
|
||||||
|
mockRentalFindByPk.mockResolvedValue(rentalWithoutStripeCustomer);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Renter does not have a Stripe customer account'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle payment failure during approval', async () => {
|
||||||
|
StripeService.chargePaymentMethod.mockRejectedValue(
|
||||||
|
new Error('Payment failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Payment failed during approval',
|
||||||
|
details: 'Payment failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent rental', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized to update this rental' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/rentals/1/status')
|
||||||
|
.send({ status: 'confirmed' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to update rental status' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /:id/review-renter', () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
renterId: 2,
|
||||||
|
status: 'completed',
|
||||||
|
renterReviewSubmittedAt: null,
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to review renter', async () => {
|
||||||
|
const reviewData = {
|
||||||
|
rating: 5,
|
||||||
|
review: 'Great renter!',
|
||||||
|
privateMessage: 'Thanks for taking care of my item',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRental.update.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send(reviewData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(mockRental.update).toHaveBeenCalledWith({
|
||||||
|
renterRating: 5,
|
||||||
|
renterReview: 'Great renter!',
|
||||||
|
renterReviewSubmittedAt: expect.any(Date),
|
||||||
|
renterPrivateMessage: 'Thanks for taking care of my item',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent rental', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send({ rating: 5, review: 'Great!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for non-owner', async () => {
|
||||||
|
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send({ rating: 5, review: 'Great!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Only owners can review renters' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for non-completed rental', async () => {
|
||||||
|
const activeRental = { ...mockRental, status: 'active' };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(activeRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send({ rating: 5, review: 'Great!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Can only review completed rentals' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if review already submitted', async () => {
|
||||||
|
const reviewedRental = {
|
||||||
|
...mockRental,
|
||||||
|
renterReviewSubmittedAt: new Date()
|
||||||
|
};
|
||||||
|
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send({ rating: 5, review: 'Great!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Renter review already submitted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-renter')
|
||||||
|
.send({ rating: 5, review: 'Great!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to submit review' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /:id/review-item', () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
renterId: 1,
|
||||||
|
status: 'completed',
|
||||||
|
itemReviewSubmittedAt: null,
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow renter to review item', async () => {
|
||||||
|
const reviewData = {
|
||||||
|
rating: 4,
|
||||||
|
review: 'Good item!',
|
||||||
|
privateMessage: 'Item was as described',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRental.update.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-item')
|
||||||
|
.send(reviewData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(mockRental.update).toHaveBeenCalledWith({
|
||||||
|
itemRating: 4,
|
||||||
|
itemReview: 'Good item!',
|
||||||
|
itemReviewSubmittedAt: expect.any(Date),
|
||||||
|
itemPrivateMessage: 'Item was as described',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for non-renter', async () => {
|
||||||
|
const nonRenterRental = { ...mockRental, renterId: 3 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-item')
|
||||||
|
.send({ rating: 4, review: 'Good!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Only renters can review items' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if review already submitted', async () => {
|
||||||
|
const reviewedRental = {
|
||||||
|
...mockRental,
|
||||||
|
itemReviewSubmittedAt: new Date()
|
||||||
|
};
|
||||||
|
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/review-item')
|
||||||
|
.send({ rating: 4, review: 'Good!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Item review already submitted' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /:id/mark-completed', () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
renterId: 2,
|
||||||
|
status: 'active',
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to mark rental as completed', async () => {
|
||||||
|
const completedRental = { ...mockRental, status: 'completed' };
|
||||||
|
mockRentalFindByPk
|
||||||
|
.mockResolvedValueOnce(mockRental)
|
||||||
|
.mockResolvedValueOnce(completedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/mark-completed');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockRental.update).toHaveBeenCalledWith({ status: 'completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for non-owner', async () => {
|
||||||
|
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/mark-completed');
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Only owners can mark rentals as completed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid status', async () => {
|
||||||
|
const pendingRental = { ...mockRental, status: 'pending' };
|
||||||
|
mockRentalFindByPk.mockResolvedValue(pendingRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/mark-completed');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Can only mark active or confirmed rentals as completed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /calculate-fees', () => {
|
||||||
|
it('should calculate fees for given amount', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/calculate-fees')
|
||||||
|
.send({ totalAmount: 100 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
fees: {
|
||||||
|
totalChargedAmount: 120,
|
||||||
|
platformFee: 20,
|
||||||
|
payoutAmount: 100,
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
baseAmount: '$100.00',
|
||||||
|
platformFee: '$20.00',
|
||||||
|
totalAmount: '$120.00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(100);
|
||||||
|
expect(FeeCalculator.formatFeesForDisplay).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid amount', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/calculate-fees')
|
||||||
|
.send({ totalAmount: 0 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Valid base amount is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle calculation errors', async () => {
|
||||||
|
FeeCalculator.calculateRentalFees.mockImplementation(() => {
|
||||||
|
throw new Error('Calculation error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/calculate-fees')
|
||||||
|
.send({ totalAmount: 100 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to calculate fees' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /earnings/status', () => {
|
||||||
|
it('should get earnings status for owner', async () => {
|
||||||
|
const mockEarnings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
totalAmount: 120,
|
||||||
|
platformFee: 20,
|
||||||
|
payoutAmount: 100,
|
||||||
|
payoutStatus: 'completed',
|
||||||
|
payoutProcessedAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
stripeTransferId: 'tr_test123',
|
||||||
|
item: { name: 'Test Item' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockEarnings);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/earnings/status');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockEarnings);
|
||||||
|
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
ownerId: 1,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
'id',
|
||||||
|
'totalAmount',
|
||||||
|
'platformFee',
|
||||||
|
'payoutAmount',
|
||||||
|
'payoutStatus',
|
||||||
|
'payoutProcessedAt',
|
||||||
|
'stripeTransferId',
|
||||||
|
],
|
||||||
|
include: [{ model: Item, as: 'item', attributes: ['name'] }],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/earnings/status');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id/refund-preview', () => {
|
||||||
|
it('should get refund preview', async () => {
|
||||||
|
const mockPreview = {
|
||||||
|
refundAmount: 80,
|
||||||
|
refundPercentage: 80,
|
||||||
|
reason: 'Cancelled more than 24 hours before start',
|
||||||
|
};
|
||||||
|
|
||||||
|
RefundService.getRefundPreview.mockResolvedValue(mockPreview);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1/refund-preview');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockPreview);
|
||||||
|
expect(RefundService.getRefundPreview).toHaveBeenCalledWith('1', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle refund service errors', async () => {
|
||||||
|
RefundService.getRefundPreview.mockRejectedValue(
|
||||||
|
new Error('Rental not found')
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/1/refund-preview');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /:id/cancel', () => {
|
||||||
|
it('should cancel rental with refund', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rental: {
|
||||||
|
id: 1,
|
||||||
|
status: 'cancelled',
|
||||||
|
},
|
||||||
|
refund: {
|
||||||
|
amount: 80,
|
||||||
|
stripeRefundId: 'rf_test123',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUpdatedRental = {
|
||||||
|
id: 1,
|
||||||
|
status: 'cancelled',
|
||||||
|
item: { id: 1, name: 'Test Item' },
|
||||||
|
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||||
|
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||||
|
};
|
||||||
|
|
||||||
|
RefundService.processCancellation.mockResolvedValue(mockResult);
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockUpdatedRental);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/cancel')
|
||||||
|
.send({ reason: 'Change of plans' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
rental: mockUpdatedRental,
|
||||||
|
refund: mockResult.refund,
|
||||||
|
});
|
||||||
|
expect(RefundService.processCancellation).toHaveBeenCalledWith(
|
||||||
|
'1',
|
||||||
|
1,
|
||||||
|
'Change of plans'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancellation errors', async () => {
|
||||||
|
RefundService.processCancellation.mockRejectedValue(
|
||||||
|
new Error('Cannot cancel completed rental')
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/rentals/1/cancel')
|
||||||
|
.send({ reason: 'Change of plans' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'Cannot cancel completed rental' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
805
backend/tests/unit/routes/stripe.test.js
Normal file
805
backend/tests/unit/routes/stripe.test.js
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('jsonwebtoken');
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findOne: jest.fn()
|
||||||
|
},
|
||||||
|
Item: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/stripeService', () => ({
|
||||||
|
getCheckoutSession: jest.fn(),
|
||||||
|
createConnectedAccount: jest.fn(),
|
||||||
|
createAccountLink: jest.fn(),
|
||||||
|
getAccountStatus: jest.fn(),
|
||||||
|
createCustomer: jest.fn(),
|
||||||
|
createSetupCheckoutSession: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth middleware
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: (req, res, next) => {
|
||||||
|
// Mock authenticated user
|
||||||
|
if (req.headers.authorization) {
|
||||||
|
req.user = { id: 1 };
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { User } = require('../../../models');
|
||||||
|
const StripeService = require('../../../services/stripeService');
|
||||||
|
const stripeRoutes = require('../../../routes/stripe');
|
||||||
|
|
||||||
|
// Set up Express app for testing
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/stripe', stripeRoutes);
|
||||||
|
|
||||||
|
describe('Stripe Routes', () => {
|
||||||
|
let consoleSpy, consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /checkout-session/:sessionId', () => {
|
||||||
|
it('should retrieve checkout session successfully', async () => {
|
||||||
|
const mockSession = {
|
||||||
|
status: 'complete',
|
||||||
|
payment_status: 'paid',
|
||||||
|
customer_details: {
|
||||||
|
email: 'test@example.com'
|
||||||
|
},
|
||||||
|
setup_intent: {
|
||||||
|
id: 'seti_123456789',
|
||||||
|
status: 'succeeded'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: '1'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/checkout-session/cs_123456789');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
status: 'complete',
|
||||||
|
payment_status: 'paid',
|
||||||
|
customer_email: 'test@example.com',
|
||||||
|
setup_intent: {
|
||||||
|
id: 'seti_123456789',
|
||||||
|
status: 'succeeded'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: '1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith('cs_123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing customer_details gracefully', async () => {
|
||||||
|
const mockSession = {
|
||||||
|
status: 'complete',
|
||||||
|
payment_status: 'paid',
|
||||||
|
customer_details: null,
|
||||||
|
setup_intent: null,
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/checkout-session/cs_123456789');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
status: 'complete',
|
||||||
|
payment_status: 'paid',
|
||||||
|
customer_email: undefined,
|
||||||
|
setup_intent: null,
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle checkout session retrieval errors', async () => {
|
||||||
|
const error = new Error('Session not found');
|
||||||
|
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/checkout-session/invalid_session');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Session not found' });
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error retrieving checkout session:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing session ID', async () => {
|
||||||
|
const error = new Error('Invalid session ID');
|
||||||
|
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/checkout-session/');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /accounts', () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
stripeConnectedAccountId: null,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUser.update.mockReset();
|
||||||
|
mockUser.stripeConnectedAccountId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create connected account successfully', async () => {
|
||||||
|
const mockAccount = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'US'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||||
|
mockUser.update.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
stripeConnectedAccountId: 'acct_123456789',
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||||
|
expect(StripeService.createConnectedAccount).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'US'
|
||||||
|
});
|
||||||
|
expect(mockUser.update).toHaveBeenCalledWith({
|
||||||
|
stripeConnectedAccountId: 'acct_123456789'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if user not found', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'User not found' });
|
||||||
|
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if user already has connected account', async () => {
|
||||||
|
const userWithAccount = {
|
||||||
|
...mockUser,
|
||||||
|
stripeConnectedAccountId: 'acct_existing'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(userWithAccount);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'User already has a connected account' });
|
||||||
|
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body).toEqual({ error: 'No token provided' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Stripe account creation errors', async () => {
|
||||||
|
const error = new Error('Invalid email address');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createConnectedAccount.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating connected account:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors', async () => {
|
||||||
|
const mockAccount = { id: 'acct_123456789' };
|
||||||
|
const dbError = new Error('Database update failed');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||||
|
mockUser.update.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /account-links', () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
stripeConnectedAccountId: 'acct_123456789'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create account link successfully', async () => {
|
||||||
|
const mockAccountLink = {
|
||||||
|
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
||||||
|
expires_at: Date.now() + 3600
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createAccountLink.mockResolvedValue(mockAccountLink);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh',
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
url: mockAccountLink.url,
|
||||||
|
expiresAt: mockAccountLink.expires_at
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(StripeService.createAccountLink).toHaveBeenCalledWith(
|
||||||
|
'acct_123456789',
|
||||||
|
'http://localhost:3000/refresh',
|
||||||
|
'http://localhost:3000/return'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if no connected account found', async () => {
|
||||||
|
const userWithoutAccount = {
|
||||||
|
id: 1,
|
||||||
|
stripeConnectedAccountId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh',
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||||
|
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if user not found', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh',
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required URLs', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh'
|
||||||
|
// Missing returnUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||||
|
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate both URLs are provided', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
// Missing refreshUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh',
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Stripe account link creation errors', async () => {
|
||||||
|
const error = new Error('Account not found');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createAccountLink.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/account-links')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({
|
||||||
|
refreshUrl: 'http://localhost:3000/refresh',
|
||||||
|
returnUrl: 'http://localhost:3000/return'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Account not found' });
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating account link:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /account-status', () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
stripeConnectedAccountId: 'acct_123456789'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should get account status successfully', async () => {
|
||||||
|
const mockAccountStatus = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
details_submitted: true,
|
||||||
|
payouts_enabled: true,
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'active' }
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
pending_verification: [],
|
||||||
|
currently_due: [],
|
||||||
|
past_due: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.getAccountStatus.mockResolvedValue(mockAccountStatus);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/account-status')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
accountId: 'acct_123456789',
|
||||||
|
detailsSubmitted: true,
|
||||||
|
payoutsEnabled: true,
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'active' }
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
pending_verification: [],
|
||||||
|
currently_due: [],
|
||||||
|
past_due: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(StripeService.getAccountStatus).toHaveBeenCalledWith('acct_123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if no connected account found', async () => {
|
||||||
|
const userWithoutAccount = {
|
||||||
|
id: 1,
|
||||||
|
stripeConnectedAccountId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/account-status')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||||
|
expect(StripeService.getAccountStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if user not found', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/account-status')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/account-status');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Stripe account status retrieval errors', async () => {
|
||||||
|
const error = new Error('Account not found');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.getAccountStatus.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/stripe/account-status')
|
||||||
|
.set('Authorization', 'Bearer valid_token');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Account not found' });
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error getting account status:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /create-setup-checkout-session', () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
stripeCustomerId: null,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUser.update.mockReset();
|
||||||
|
mockUser.stripeCustomerId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create setup checkout session for new customer', async () => {
|
||||||
|
const mockCustomer = {
|
||||||
|
id: 'cus_123456789',
|
||||||
|
email: 'test@example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
client_secret: 'cs_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rentalData = {
|
||||||
|
itemId: '123',
|
||||||
|
startDate: '2023-12-01',
|
||||||
|
endDate: '2023-12-03'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||||
|
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
mockUser.update.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ rentalData });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
clientSecret: 'cs_123456789_secret_test',
|
||||||
|
sessionId: 'cs_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||||
|
expect(StripeService.createCustomer).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
metadata: {
|
||||||
|
userId: '1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(mockUser.update).toHaveBeenCalledWith({ stripeCustomerId: 'cus_123456789' });
|
||||||
|
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||||
|
customerId: 'cus_123456789',
|
||||||
|
metadata: {
|
||||||
|
rentalData: JSON.stringify(rentalData)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use existing customer ID if available', async () => {
|
||||||
|
const userWithCustomer = {
|
||||||
|
...mockUser,
|
||||||
|
stripeCustomerId: 'cus_existing123'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
client_secret: 'cs_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(userWithCustomer);
|
||||||
|
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
clientSecret: 'cs_123456789_secret_test',
|
||||||
|
sessionId: 'cs_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||||
|
expect(userWithCustomer.update).not.toHaveBeenCalled();
|
||||||
|
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||||
|
customerId: 'cus_existing123',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle session without rental data', async () => {
|
||||||
|
const mockCustomer = {
|
||||||
|
id: 'cus_123456789'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
client_secret: 'cs_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||||
|
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
mockUser.update.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||||
|
customerId: 'cus_123456789',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if user not found', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'User not found' });
|
||||||
|
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||||
|
expect(StripeService.createSetupCheckoutSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle customer creation errors', async () => {
|
||||||
|
const error = new Error('Invalid email address');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating setup checkout session:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors', async () => {
|
||||||
|
const mockCustomer = { id: 'cus_123456789' };
|
||||||
|
const dbError = new Error('Database update failed');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||||
|
mockUser.update.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle session creation errors', async () => {
|
||||||
|
const mockCustomer = { id: 'cus_123456789' };
|
||||||
|
const sessionError = new Error('Session creation failed');
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||||
|
mockUser.update.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createSetupCheckoutSession.mockRejectedValue(sessionError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Session creation failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex rental data', async () => {
|
||||||
|
const mockCustomer = { id: 'cus_123456789' };
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
client_secret: 'cs_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
const complexRentalData = {
|
||||||
|
itemId: '123',
|
||||||
|
startDate: '2023-12-01',
|
||||||
|
endDate: '2023-12-03',
|
||||||
|
totalAmount: 150.00,
|
||||||
|
additionalServices: ['cleaning', 'delivery'],
|
||||||
|
notes: 'Special instructions'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||||
|
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||||
|
mockUser.update.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.send({ rentalData: complexRentalData });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||||
|
customerId: 'cus_123456789',
|
||||||
|
metadata: {
|
||||||
|
rentalData: JSON.stringify(complexRentalData)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling and edge cases', () => {
|
||||||
|
it('should handle malformed JSON in rental data', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
stripeCustomerId: 'cus_123456789'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
// This should work fine as Express will parse valid JSON
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/stripe/create-setup-checkout-session')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send('{"rentalData":{"itemId":"123"}}');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large session IDs', async () => {
|
||||||
|
const longSessionId = 'cs_' + 'a'.repeat(100);
|
||||||
|
const error = new Error('Session ID too long');
|
||||||
|
|
||||||
|
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/stripe/checkout-session/${longSessionId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Session ID too long' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent requests for same user', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
stripeConnectedAccountId: null,
|
||||||
|
update: jest.fn().mockResolvedValue({})
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAccount = { id: 'acct_123456789' };
|
||||||
|
|
||||||
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||||
|
|
||||||
|
// Simulate concurrent requests
|
||||||
|
const [response1, response2] = await Promise.all([
|
||||||
|
request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token'),
|
||||||
|
request(app)
|
||||||
|
.post('/stripe/accounts')
|
||||||
|
.set('Authorization', 'Bearer valid_token')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Both should succeed (in this test scenario)
|
||||||
|
expect(response1.status).toBe(200);
|
||||||
|
expect(response2.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
658
backend/tests/unit/routes/users.test.js
Normal file
658
backend/tests/unit/routes/users.test.js
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const usersRouter = require('../../../routes/users');
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
UserAddress: {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
|
authenticateToken: jest.fn((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
id: 1,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../middleware/upload', () => ({
|
||||||
|
uploadProfileImage: jest.fn((req, res, callback) => {
|
||||||
|
// Mock successful upload
|
||||||
|
req.file = {
|
||||||
|
filename: 'test-profile.jpg'
|
||||||
|
};
|
||||||
|
callback(null);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
unlink: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('path');
|
||||||
|
const { User, UserAddress } = require('../../../models');
|
||||||
|
const { uploadProfileImage } = require('../../../middleware/upload');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
// Create express app with the router
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/users', usersRouter);
|
||||||
|
|
||||||
|
// Mock models
|
||||||
|
const mockUserFindByPk = User.findByPk;
|
||||||
|
const mockUserUpdate = User.update;
|
||||||
|
const mockUserAddressFindAll = UserAddress.findAll;
|
||||||
|
const mockUserAddressFindByPk = UserAddress.findByPk;
|
||||||
|
const mockUserAddressCreate = UserAddress.create;
|
||||||
|
|
||||||
|
describe('Users Routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /profile', () => {
|
||||||
|
it('should get user profile for authenticated user', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phone: '555-1234',
|
||||||
|
profileImage: 'profile.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/profile');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockUser);
|
||||||
|
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/profile');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /addresses', () => {
|
||||||
|
it('should get user addresses', async () => {
|
||||||
|
const mockAddresses = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
address1: '123 Main St',
|
||||||
|
city: 'New York',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
address1: '456 Oak Ave',
|
||||||
|
city: 'Boston',
|
||||||
|
isPrimary: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUserAddressFindAll.mockResolvedValue(mockAddresses);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/addresses');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockAddresses);
|
||||||
|
expect(mockUserAddressFindAll).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 1 },
|
||||||
|
order: [['isPrimary', 'DESC'], ['createdAt', 'ASC']]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserAddressFindAll.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/addresses');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /addresses', () => {
|
||||||
|
it('should create a new address', async () => {
|
||||||
|
const addressData = {
|
||||||
|
address1: '789 Pine St',
|
||||||
|
address2: 'Apt 4B',
|
||||||
|
city: 'Chicago',
|
||||||
|
state: 'IL',
|
||||||
|
zipCode: '60601',
|
||||||
|
country: 'USA',
|
||||||
|
isPrimary: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedAddress = {
|
||||||
|
id: 3,
|
||||||
|
...addressData,
|
||||||
|
userId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserAddressCreate.mockResolvedValue(mockCreatedAddress);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/addresses')
|
||||||
|
.send(addressData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(mockCreatedAddress);
|
||||||
|
expect(mockUserAddressCreate).toHaveBeenCalledWith({
|
||||||
|
...addressData,
|
||||||
|
userId: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors during creation', async () => {
|
||||||
|
mockUserAddressCreate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/addresses')
|
||||||
|
.send({
|
||||||
|
address1: '789 Pine St',
|
||||||
|
city: 'Chicago',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /addresses/:id', () => {
|
||||||
|
const mockAddress = {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
address1: '123 Main St',
|
||||||
|
city: 'New York',
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update user address', async () => {
|
||||||
|
const updateData = {
|
||||||
|
address1: '123 Updated St',
|
||||||
|
city: 'Updated City',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAddress.update.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/addresses/1')
|
||||||
|
.send(updateData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
address1: '123 Main St',
|
||||||
|
city: 'New York',
|
||||||
|
});
|
||||||
|
expect(mockAddress.update).toHaveBeenCalledWith(updateData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent address', async () => {
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/addresses/999')
|
||||||
|
.send({ address1: 'Updated St' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Address not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/addresses/1')
|
||||||
|
.send({ address1: 'Updated St' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/addresses/1')
|
||||||
|
.send({ address1: 'Updated St' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /addresses/:id', () => {
|
||||||
|
const mockAddress = {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
address1: '123 Main St',
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete user address', async () => {
|
||||||
|
mockAddress.destroy.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/users/addresses/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(mockAddress.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent address', async () => {
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/users/addresses/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'Address not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for unauthorized user', async () => {
|
||||||
|
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||||
|
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/users/addresses/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/users/addresses/1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /availability', () => {
|
||||||
|
it('should get user availability settings', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
defaultAvailableAfter: '09:00',
|
||||||
|
defaultAvailableBefore: '17:00',
|
||||||
|
defaultSpecifyTimesPerDay: true,
|
||||||
|
defaultWeeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/availability');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
generalAvailableAfter: '09:00',
|
||||||
|
generalAvailableBefore: '17:00',
|
||||||
|
specifyTimesPerDay: true,
|
||||||
|
weeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||||
|
});
|
||||||
|
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||||
|
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/availability');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /availability', () => {
|
||||||
|
it('should update user availability settings', async () => {
|
||||||
|
const availabilityData = {
|
||||||
|
generalAvailableAfter: '08:00',
|
||||||
|
generalAvailableBefore: '18:00',
|
||||||
|
specifyTimesPerDay: false,
|
||||||
|
weeklyTimes: { monday: '08:00-18:00' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserUpdate.mockResolvedValue([1]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/availability')
|
||||||
|
.send(availabilityData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ message: 'Availability updated successfully' });
|
||||||
|
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||||
|
defaultAvailableAfter: '08:00',
|
||||||
|
defaultAvailableBefore: '18:00',
|
||||||
|
defaultSpecifyTimesPerDay: false,
|
||||||
|
defaultWeeklyTimes: { monday: '08:00-18:00' },
|
||||||
|
}, {
|
||||||
|
where: { id: 1 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserUpdate.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/availability')
|
||||||
|
.send({
|
||||||
|
generalAvailableAfter: '08:00',
|
||||||
|
generalAvailableBefore: '18:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id', () => {
|
||||||
|
it('should get public user profile by ID', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
username: 'janesmith',
|
||||||
|
profileImage: 'jane.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/2');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockUser);
|
||||||
|
expect(mockUserFindByPk).toHaveBeenCalledWith('2', {
|
||||||
|
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent user', async () => {
|
||||||
|
mockUserFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/999');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toEqual({ error: 'User not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/users/2');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /profile', () => {
|
||||||
|
const mockUpdatedUser = {
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Updated',
|
||||||
|
lastName: 'User',
|
||||||
|
email: 'updated@example.com',
|
||||||
|
phone: '555-9999',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockUpdatedUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update user profile', async () => {
|
||||||
|
const profileData = {
|
||||||
|
firstName: 'Updated',
|
||||||
|
lastName: 'User',
|
||||||
|
email: 'updated@example.com',
|
||||||
|
phone: '555-9999',
|
||||||
|
address1: '123 New St',
|
||||||
|
city: 'New City',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/profile')
|
||||||
|
.send(profileData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockUpdatedUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude empty email from update', async () => {
|
||||||
|
const profileData = {
|
||||||
|
firstName: 'Updated',
|
||||||
|
lastName: 'User',
|
||||||
|
email: '',
|
||||||
|
phone: '555-9999',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/profile')
|
||||||
|
.send(profileData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Verify email was not included in the update call
|
||||||
|
// (This would need to check the actual update call if we spy on req.user.update)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle validation errors', async () => {
|
||||||
|
const mockValidationError = new Error('Validation error');
|
||||||
|
mockValidationError.errors = [
|
||||||
|
{ path: 'email', message: 'Invalid email format' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock req.user.update to throw validation error
|
||||||
|
const { authenticateToken } = require('../../../middleware/auth');
|
||||||
|
authenticateToken.mockImplementation((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
id: 1,
|
||||||
|
update: jest.fn().mockRejectedValue(mockValidationError)
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/profile')
|
||||||
|
.send({
|
||||||
|
firstName: 'Test',
|
||||||
|
email: 'invalid-email',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Validation error',
|
||||||
|
details: [{ field: 'email', message: 'Invalid email format' }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle general database errors', async () => {
|
||||||
|
// Reset the authenticateToken mock to use default user
|
||||||
|
const { authenticateToken } = require('../../../middleware/auth');
|
||||||
|
authenticateToken.mockImplementation((req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
id: 1,
|
||||||
|
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/users/profile')
|
||||||
|
.send({
|
||||||
|
firstName: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /profile/image', () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
profileImage: 'old-image.jpg',
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upload profile image successfully', async () => {
|
||||||
|
mockUser.update.mockResolvedValue();
|
||||||
|
fs.unlink.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: 'Profile image uploaded successfully',
|
||||||
|
filename: 'test-profile.jpg',
|
||||||
|
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||||
|
});
|
||||||
|
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
||||||
|
expect(mockUser.update).toHaveBeenCalledWith({
|
||||||
|
profileImage: 'test-profile.jpg'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle upload errors', async () => {
|
||||||
|
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||||
|
callback(new Error('File too large'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'File too large' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing file', async () => {
|
||||||
|
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||||
|
req.file = null;
|
||||||
|
callback(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: 'No file uploaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors', async () => {
|
||||||
|
// Mock upload to succeed but database update to fail
|
||||||
|
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||||
|
req.file = { filename: 'test-profile.jpg' };
|
||||||
|
callback(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userWithError = {
|
||||||
|
...mockUser,
|
||||||
|
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||||
|
};
|
||||||
|
mockUserFindByPk.mockResolvedValue(userWithError);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to update profile image' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case when user has no existing profile image', async () => {
|
||||||
|
// Mock upload to succeed
|
||||||
|
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||||
|
req.file = { filename: 'test-profile.jpg' };
|
||||||
|
callback(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userWithoutImage = {
|
||||||
|
id: 1,
|
||||||
|
profileImage: null,
|
||||||
|
update: jest.fn().mockResolvedValue()
|
||||||
|
};
|
||||||
|
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(fs.unlink).not.toHaveBeenCalled(); // No old image to delete
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue if old image deletion fails', async () => {
|
||||||
|
// Mock upload to succeed
|
||||||
|
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||||
|
req.file = { filename: 'test-profile.jpg' };
|
||||||
|
callback(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userWithImage = {
|
||||||
|
id: 1,
|
||||||
|
profileImage: 'old-image.jpg',
|
||||||
|
update: jest.fn().mockResolvedValue()
|
||||||
|
};
|
||||||
|
mockUserFindByPk.mockResolvedValue(userWithImage);
|
||||||
|
fs.unlink.mockRejectedValue(new Error('File not found'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/users/profile/image');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: 'Profile image uploaded successfully',
|
||||||
|
filename: 'test-profile.jpg',
|
||||||
|
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
940
backend/tests/unit/services/googleMapsService.test.js
Normal file
940
backend/tests/unit/services/googleMapsService.test.js
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
// Mock the Google Maps client
|
||||||
|
const mockPlaceAutocomplete = jest.fn();
|
||||||
|
const mockPlaceDetails = jest.fn();
|
||||||
|
const mockGeocode = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('@googlemaps/google-maps-services-js', () => ({
|
||||||
|
Client: jest.fn().mockImplementation(() => ({
|
||||||
|
placeAutocomplete: mockPlaceAutocomplete,
|
||||||
|
placeDetails: mockPlaceDetails,
|
||||||
|
geocode: mockGeocode
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GoogleMapsService', () => {
|
||||||
|
let service;
|
||||||
|
let consoleSpy, consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
// Reset environment
|
||||||
|
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
|
// Clear module cache to get fresh instance
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should initialize with API key and log success', () => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('✅ Google Maps service initialized');
|
||||||
|
expect(service.isConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error when API key is not configured', () => {
|
||||||
|
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Google Maps API key not configured in environment variables');
|
||||||
|
expect(service.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize Google Maps Client', () => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
const { Client } = require('@googlemaps/google-maps-services-js');
|
||||||
|
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(Client).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlacesAutocomplete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input validation', () => {
|
||||||
|
it('should throw error when API key is not configured', async () => {
|
||||||
|
service.apiKey = null;
|
||||||
|
|
||||||
|
await expect(service.getPlacesAutocomplete('test')).rejects.toThrow('Google Maps API key not configured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty predictions for empty input', async () => {
|
||||||
|
const result = await service.getPlacesAutocomplete('');
|
||||||
|
|
||||||
|
expect(result).toEqual({ predictions: [] });
|
||||||
|
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty predictions for input less than 2 characters', async () => {
|
||||||
|
const result = await service.getPlacesAutocomplete('a');
|
||||||
|
|
||||||
|
expect(result).toEqual({ predictions: [] });
|
||||||
|
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim input and proceed with valid input', async () => {
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
predictions: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.getPlacesAutocomplete(' test ');
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
input: 'test'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parameters handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
predictions: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default parameters', async () => {
|
||||||
|
await service.getPlacesAutocomplete('test input');
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: {
|
||||||
|
key: 'test-api-key',
|
||||||
|
input: 'test input',
|
||||||
|
types: 'address',
|
||||||
|
language: 'en'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom options', async () => {
|
||||||
|
const options = {
|
||||||
|
types: 'establishment',
|
||||||
|
language: 'fr'
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.getPlacesAutocomplete('test input', options);
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: {
|
||||||
|
key: 'test-api-key',
|
||||||
|
input: 'test input',
|
||||||
|
types: 'establishment',
|
||||||
|
language: 'fr'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include session token when provided', async () => {
|
||||||
|
const options = {
|
||||||
|
sessionToken: 'session-123'
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.getPlacesAutocomplete('test input', options);
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
sessiontoken: 'session-123'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle component restrictions', async () => {
|
||||||
|
const options = {
|
||||||
|
componentRestrictions: {
|
||||||
|
country: 'us',
|
||||||
|
administrative_area: 'CA'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.getPlacesAutocomplete('test input', options);
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
components: 'country:us|administrative_area:CA'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge additional options', async () => {
|
||||||
|
const options = {
|
||||||
|
radius: 1000,
|
||||||
|
location: '40.7128,-74.0060'
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.getPlacesAutocomplete('test input', options);
|
||||||
|
|
||||||
|
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
radius: 1000,
|
||||||
|
location: '40.7128,-74.0060'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful responses', () => {
|
||||||
|
it('should return formatted predictions on success', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
predictions: [
|
||||||
|
{
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
description: 'Test Location, City, State',
|
||||||
|
types: ['establishment'],
|
||||||
|
structured_formatting: {
|
||||||
|
main_text: 'Test Location',
|
||||||
|
secondary_text: 'City, State'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
place_id: 'ChIJ456',
|
||||||
|
description: 'Another Place',
|
||||||
|
types: ['locality'],
|
||||||
|
structured_formatting: {
|
||||||
|
main_text: 'Another Place'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlacesAutocomplete('test input');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
predictions: [
|
||||||
|
{
|
||||||
|
placeId: 'ChIJ123',
|
||||||
|
description: 'Test Location, City, State',
|
||||||
|
types: ['establishment'],
|
||||||
|
mainText: 'Test Location',
|
||||||
|
secondaryText: 'City, State'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeId: 'ChIJ456',
|
||||||
|
description: 'Another Place',
|
||||||
|
types: ['locality'],
|
||||||
|
mainText: 'Another Place',
|
||||||
|
secondaryText: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle predictions without secondary text', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
predictions: [
|
||||||
|
{
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
description: 'Test Location',
|
||||||
|
types: ['establishment'],
|
||||||
|
structured_formatting: {
|
||||||
|
main_text: 'Test Location'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlacesAutocomplete('test input');
|
||||||
|
|
||||||
|
expect(result.predictions[0].secondaryText).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error responses', () => {
|
||||||
|
it('should handle API error responses', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'ZERO_RESULTS',
|
||||||
|
error_message: 'No results found'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlacesAutocomplete('test input');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
predictions: [],
|
||||||
|
error: 'No results found for this query',
|
||||||
|
status: 'ZERO_RESULTS'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Places Autocomplete API error:',
|
||||||
|
'ZERO_RESULTS',
|
||||||
|
'No results found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown error status', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'UNKNOWN_STATUS'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlacesAutocomplete('test input');
|
||||||
|
|
||||||
|
expect(result.error).toBe('Google Maps API error: UNKNOWN_STATUS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
mockPlaceAutocomplete.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(service.getPlacesAutocomplete('test input')).rejects.toThrow('Failed to fetch place predictions');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Places Autocomplete service error:', 'Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlaceDetails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input validation', () => {
|
||||||
|
it('should throw error when API key is not configured', async () => {
|
||||||
|
service.apiKey = null;
|
||||||
|
|
||||||
|
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API key not configured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when placeId is not provided', async () => {
|
||||||
|
await expect(service.getPlaceDetails()).rejects.toThrow('Place ID is required');
|
||||||
|
await expect(service.getPlaceDetails('')).rejects.toThrow('Place ID is required');
|
||||||
|
await expect(service.getPlaceDetails(null)).rejects.toThrow('Place ID is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parameters handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlaceDetails.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: 'Test Address',
|
||||||
|
address_components: [],
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default parameters', async () => {
|
||||||
|
await service.getPlaceDetails('ChIJ123');
|
||||||
|
|
||||||
|
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||||
|
params: {
|
||||||
|
key: 'test-api-key',
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
fields: [
|
||||||
|
'address_components',
|
||||||
|
'formatted_address',
|
||||||
|
'geometry',
|
||||||
|
'place_id'
|
||||||
|
],
|
||||||
|
language: 'en'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom language', async () => {
|
||||||
|
await service.getPlaceDetails('ChIJ123', { language: 'fr' });
|
||||||
|
|
||||||
|
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
language: 'fr'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include session token when provided', async () => {
|
||||||
|
await service.getPlaceDetails('ChIJ123', { sessionToken: 'session-123' });
|
||||||
|
|
||||||
|
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
sessiontoken: 'session-123'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful responses', () => {
|
||||||
|
it('should return formatted place details', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||||
|
address_components: [
|
||||||
|
{
|
||||||
|
long_name: '123',
|
||||||
|
short_name: '123',
|
||||||
|
types: ['street_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
long_name: 'Test Street',
|
||||||
|
short_name: 'Test St',
|
||||||
|
types: ['route']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
long_name: 'Test City',
|
||||||
|
short_name: 'Test City',
|
||||||
|
types: ['locality', 'political']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
long_name: 'Test State',
|
||||||
|
short_name: 'TS',
|
||||||
|
types: ['administrative_area_level_1', 'political']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
long_name: '12345',
|
||||||
|
short_name: '12345',
|
||||||
|
types: ['postal_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
long_name: 'United States',
|
||||||
|
short_name: 'US',
|
||||||
|
types: ['country', 'political']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlaceDetails('ChIJ123');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
placeId: 'ChIJ123',
|
||||||
|
formattedAddress: '123 Test St, Test City, TC 12345, USA',
|
||||||
|
addressComponents: {
|
||||||
|
streetNumber: '123',
|
||||||
|
route: 'Test Street',
|
||||||
|
locality: 'Test City',
|
||||||
|
administrativeAreaLevel1: 'TS',
|
||||||
|
administrativeAreaLevel1Long: 'Test State',
|
||||||
|
postalCode: '12345',
|
||||||
|
country: 'US'
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
latitude: 40.7128,
|
||||||
|
longitude: -74.0060
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle place details without address components', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: 'Test Address',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlaceDetails('ChIJ123');
|
||||||
|
|
||||||
|
expect(result.addressComponents).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle place details without geometry', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: 'Test Address'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlaceDetails('ChIJ123');
|
||||||
|
|
||||||
|
expect(result.geometry).toEqual({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial geometry data', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: 'Test Address',
|
||||||
|
geometry: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.getPlaceDetails('ChIJ123');
|
||||||
|
|
||||||
|
expect(result.geometry).toEqual({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error responses', () => {
|
||||||
|
it('should handle API error responses', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'NOT_FOUND',
|
||||||
|
error_message: 'Place not found'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('The specified place was not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Place Details API error:',
|
||||||
|
'NOT_FOUND',
|
||||||
|
'Place not found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle response without result', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API error: OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
const originalError = new Error('Network error');
|
||||||
|
mockPlaceDetails.mockRejectedValue(originalError);
|
||||||
|
|
||||||
|
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow(originalError);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Place Details service error:', 'Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geocodeAddress', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input validation', () => {
|
||||||
|
it('should throw error when API key is not configured', async () => {
|
||||||
|
service.apiKey = null;
|
||||||
|
|
||||||
|
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Google Maps API key not configured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when address is not provided', async () => {
|
||||||
|
await expect(service.geocodeAddress()).rejects.toThrow('Address is required for geocoding');
|
||||||
|
await expect(service.geocodeAddress('')).rejects.toThrow('Address is required for geocoding');
|
||||||
|
await expect(service.geocodeAddress(' ')).rejects.toThrow('Address is required for geocoding');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parameters handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGeocode.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
formatted_address: 'Test Address',
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default parameters', async () => {
|
||||||
|
await service.geocodeAddress('123 Test St');
|
||||||
|
|
||||||
|
expect(mockGeocode).toHaveBeenCalledWith({
|
||||||
|
params: {
|
||||||
|
key: 'test-api-key',
|
||||||
|
address: '123 Test St',
|
||||||
|
language: 'en'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim address input', async () => {
|
||||||
|
await service.geocodeAddress(' 123 Test St ');
|
||||||
|
|
||||||
|
expect(mockGeocode).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
address: '123 Test St'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom language', async () => {
|
||||||
|
await service.geocodeAddress('123 Test St', { language: 'fr' });
|
||||||
|
|
||||||
|
expect(mockGeocode).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
language: 'fr'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle component restrictions', async () => {
|
||||||
|
const options = {
|
||||||
|
componentRestrictions: {
|
||||||
|
country: 'us',
|
||||||
|
administrative_area: 'CA'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.geocodeAddress('123 Test St', options);
|
||||||
|
|
||||||
|
expect(mockGeocode).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
components: 'country:us|administrative_area:CA'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle bounds parameter', async () => {
|
||||||
|
const options = {
|
||||||
|
bounds: '40.7,-74.1|40.8,-73.9'
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.geocodeAddress('123 Test St', options);
|
||||||
|
|
||||||
|
expect(mockGeocode).toHaveBeenCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
bounds: '40.7,-74.1|40.8,-73.9'
|
||||||
|
}),
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful responses', () => {
|
||||||
|
it('should return geocoded location', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGeocode.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.geocodeAddress('123 Test St');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
latitude: 40.7128,
|
||||||
|
longitude: -74.0060,
|
||||||
|
formattedAddress: '123 Test St, Test City, TC 12345, USA',
|
||||||
|
placeId: 'ChIJ123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return first result when multiple results', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
formatted_address: 'First Result',
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
geometry: { location: { lat: 40.7128, lng: -74.0060 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
formatted_address: 'Second Result',
|
||||||
|
place_id: 'ChIJ456',
|
||||||
|
geometry: { location: { lat: 40.7129, lng: -74.0061 } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGeocode.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.geocodeAddress('123 Test St');
|
||||||
|
|
||||||
|
expect(result.formattedAddress).toBe('First Result');
|
||||||
|
expect(result.placeId).toBe('ChIJ123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error responses', () => {
|
||||||
|
it('should handle API error responses', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'ZERO_RESULTS',
|
||||||
|
error_message: 'No results found'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGeocode.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.geocodeAddress('123 Test St');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: 'No results found for this query',
|
||||||
|
status: 'ZERO_RESULTS'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Geocoding API error:',
|
||||||
|
'ZERO_RESULTS',
|
||||||
|
'No results found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty results array', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
results: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGeocode.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await service.geocodeAddress('123 Test St');
|
||||||
|
|
||||||
|
expect(result.error).toBe('Google Maps API error: OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
mockGeocode.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Failed to geocode address');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Geocoding service error:', 'Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getErrorMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct error messages for known status codes', () => {
|
||||||
|
expect(service.getErrorMessage('ZERO_RESULTS')).toBe('No results found for this query');
|
||||||
|
expect(service.getErrorMessage('OVER_QUERY_LIMIT')).toBe('API quota exceeded. Please try again later');
|
||||||
|
expect(service.getErrorMessage('REQUEST_DENIED')).toBe('API request denied. Check API key configuration');
|
||||||
|
expect(service.getErrorMessage('INVALID_REQUEST')).toBe('Invalid request parameters');
|
||||||
|
expect(service.getErrorMessage('UNKNOWN_ERROR')).toBe('Server error. Please try again');
|
||||||
|
expect(service.getErrorMessage('NOT_FOUND')).toBe('The specified place was not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return generic error message for unknown status codes', () => {
|
||||||
|
expect(service.getErrorMessage('UNKNOWN_STATUS')).toBe('Google Maps API error: UNKNOWN_STATUS');
|
||||||
|
expect(service.getErrorMessage('CUSTOM_ERROR')).toBe('Google Maps API error: CUSTOM_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined status', () => {
|
||||||
|
expect(service.getErrorMessage(null)).toBe('Google Maps API error: null');
|
||||||
|
expect(service.getErrorMessage(undefined)).toBe('Google Maps API error: undefined');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConfigured', () => {
|
||||||
|
it('should return true when API key is configured', () => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(service.isConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when API key is not configured', () => {
|
||||||
|
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(service.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when API key is empty string', () => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = '';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(service.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Singleton pattern', () => {
|
||||||
|
it('should return the same instance on multiple requires', () => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
const service1 = require('../../../services/googleMapsService');
|
||||||
|
const service2 = require('../../../services/googleMapsService');
|
||||||
|
|
||||||
|
expect(service1).toBe(service2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||||
|
service = require('../../../services/googleMapsService');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle typical place search workflow', async () => {
|
||||||
|
// Mock autocomplete response
|
||||||
|
mockPlaceAutocomplete.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
predictions: [
|
||||||
|
{
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
description: 'Test Location',
|
||||||
|
types: ['establishment'],
|
||||||
|
structured_formatting: {
|
||||||
|
main_text: 'Test Location',
|
||||||
|
secondary_text: 'City, State'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock place details response
|
||||||
|
mockPlaceDetails.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
result: {
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
formatted_address: 'Test Location, City, State',
|
||||||
|
address_components: [],
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Get autocomplete predictions
|
||||||
|
const autocompleteResult = await service.getPlacesAutocomplete('test loc');
|
||||||
|
expect(autocompleteResult.predictions).toHaveLength(1);
|
||||||
|
|
||||||
|
// Step 2: Get detailed place information
|
||||||
|
const placeId = autocompleteResult.predictions[0].placeId;
|
||||||
|
const detailsResult = await service.getPlaceDetails(placeId);
|
||||||
|
|
||||||
|
expect(detailsResult.placeId).toBe('ChIJ123');
|
||||||
|
expect(detailsResult.geometry.latitude).toBe(40.7128);
|
||||||
|
expect(detailsResult.geometry.longitude).toBe(-74.0060);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle geocoding workflow', async () => {
|
||||||
|
mockGeocode.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: 'OK',
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||||
|
place_id: 'ChIJ123',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 40.7128, lng: -74.0060 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.geocodeAddress('123 Test St, Test City, TC');
|
||||||
|
|
||||||
|
expect(result.latitude).toBe(40.7128);
|
||||||
|
expect(result.longitude).toBe(-74.0060);
|
||||||
|
expect(result.formattedAddress).toBe('123 Test St, Test City, TC 12345, USA');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
743
backend/tests/unit/services/payoutService.test.js
Normal file
743
backend/tests/unit/services/payoutService.test.js
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
// Mock dependencies
|
||||||
|
const mockRentalFindAll = jest.fn();
|
||||||
|
const mockRentalUpdate = jest.fn();
|
||||||
|
const mockUserModel = jest.fn();
|
||||||
|
const mockCreateTransfer = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
Rental: {
|
||||||
|
findAll: mockRentalFindAll,
|
||||||
|
update: mockRentalUpdate
|
||||||
|
},
|
||||||
|
User: mockUserModel
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/stripeService', () => ({
|
||||||
|
createTransfer: mockCreateTransfer
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('sequelize', () => ({
|
||||||
|
Op: {
|
||||||
|
not: 'not'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PayoutService = require('../../../services/payoutService');
|
||||||
|
|
||||||
|
describe('PayoutService', () => {
|
||||||
|
let consoleSpy, consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEligiblePayouts', () => {
|
||||||
|
it('should return eligible rentals for payout', async () => {
|
||||||
|
const mockRentals = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
owner: {
|
||||||
|
id: 1,
|
||||||
|
stripeConnectedAccountId: 'acct_123'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
status: 'completed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
owner: {
|
||||||
|
id: 2,
|
||||||
|
stripeConnectedAccountId: 'acct_456'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockRentals);
|
||||||
|
|
||||||
|
const result = await PayoutService.getEligiblePayouts();
|
||||||
|
|
||||||
|
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
status: 'completed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
payoutStatus: 'pending'
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: mockUserModel,
|
||||||
|
as: 'owner',
|
||||||
|
where: {
|
||||||
|
stripeConnectedAccountId: {
|
||||||
|
'not': null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockRentals);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', async () => {
|
||||||
|
const dbError = new Error('Database connection failed');
|
||||||
|
mockRentalFindAll.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(PayoutService.getEligiblePayouts()).rejects.toThrow('Database connection failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting eligible payouts:', dbError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no eligible rentals found', async () => {
|
||||||
|
mockRentalFindAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await PayoutService.getEligiblePayouts();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processRentalPayout', () => {
|
||||||
|
let mockRental;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutAmount: 9500, // $95.00
|
||||||
|
totalAmount: 10000, // $100.00
|
||||||
|
platformFee: 500, // $5.00
|
||||||
|
startDateTime: new Date('2023-01-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2023-01-02T10:00:00Z'),
|
||||||
|
owner: {
|
||||||
|
id: 2,
|
||||||
|
stripeConnectedAccountId: 'acct_123'
|
||||||
|
},
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation', () => {
|
||||||
|
it('should throw error when owner has no connected Stripe account', async () => {
|
||||||
|
mockRental.owner.stripeConnectedAccountId = null;
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when owner is missing', async () => {
|
||||||
|
mockRental.owner = null;
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when payout already processed', async () => {
|
||||||
|
mockRental.payoutStatus = 'completed';
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Rental payout has already been processed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when payout amount is invalid', async () => {
|
||||||
|
mockRental.payoutAmount = 0;
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Invalid payout amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when payout amount is negative', async () => {
|
||||||
|
mockRental.payoutAmount = -100;
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Invalid payout amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when payout amount is null', async () => {
|
||||||
|
mockRental.payoutAmount = null;
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Invalid payout amount');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful processing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreateTransfer.mockResolvedValue({
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 9500,
|
||||||
|
destination: 'acct_123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully process a rental payout', async () => {
|
||||||
|
const result = await PayoutService.processRentalPayout(mockRental);
|
||||||
|
|
||||||
|
// Verify status update to processing
|
||||||
|
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
payoutStatus: 'processing'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Stripe transfer creation
|
||||||
|
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||||
|
amount: 9500,
|
||||||
|
destination: 'acct_123',
|
||||||
|
metadata: {
|
||||||
|
rentalId: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
totalAmount: '10000',
|
||||||
|
platformFee: '500',
|
||||||
|
startDateTime: '2023-01-01T10:00:00.000Z',
|
||||||
|
endDateTime: '2023-01-02T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify status update to completed
|
||||||
|
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
payoutStatus: 'completed',
|
||||||
|
payoutProcessedAt: expect.any(Date),
|
||||||
|
stripeTransferId: 'tr_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify success log
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Payout completed for rental 1: $9500 to acct_123'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify return value
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_123456789',
|
||||||
|
amount: 9500
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle successful payout with different amounts', async () => {
|
||||||
|
mockRental.payoutAmount = 15000;
|
||||||
|
mockRental.totalAmount = 16000;
|
||||||
|
mockRental.platformFee = 1000;
|
||||||
|
|
||||||
|
mockCreateTransfer.mockResolvedValue({
|
||||||
|
id: 'tr_987654321',
|
||||||
|
amount: 15000,
|
||||||
|
destination: 'acct_123'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await PayoutService.processRentalPayout(mockRental);
|
||||||
|
|
||||||
|
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||||
|
amount: 15000,
|
||||||
|
destination: 'acct_123',
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
totalAmount: '16000',
|
||||||
|
platformFee: '1000'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.amount).toBe(15000);
|
||||||
|
expect(result.transferId).toBe('tr_987654321');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should handle Stripe transfer creation errors', async () => {
|
||||||
|
const stripeError = new Error('Stripe transfer failed');
|
||||||
|
mockCreateTransfer.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Stripe transfer failed');
|
||||||
|
|
||||||
|
// Verify processing status was set
|
||||||
|
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
payoutStatus: 'processing'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify failure status was set
|
||||||
|
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
payoutStatus: 'failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing payout for rental 1:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors during processing', async () => {
|
||||||
|
const dbError = new Error('Database update failed');
|
||||||
|
mockRental.update.mockRejectedValueOnce(dbError);
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Database update failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing payout for rental 1:',
|
||||||
|
dbError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors during completion', async () => {
|
||||||
|
mockCreateTransfer.mockResolvedValue({
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 9500
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbError = new Error('Database completion update failed');
|
||||||
|
mockRental.update
|
||||||
|
.mockResolvedValueOnce(true) // processing update succeeds
|
||||||
|
.mockRejectedValueOnce(dbError); // completion update fails
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Database completion update failed');
|
||||||
|
|
||||||
|
expect(mockCreateTransfer).toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing payout for rental 1:',
|
||||||
|
dbError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failure status update errors gracefully', async () => {
|
||||||
|
const stripeError = new Error('Stripe transfer failed');
|
||||||
|
const updateError = new Error('Update failed status failed');
|
||||||
|
|
||||||
|
mockCreateTransfer.mockRejectedValue(stripeError);
|
||||||
|
mockRental.update
|
||||||
|
.mockResolvedValueOnce(true) // processing update succeeds
|
||||||
|
.mockRejectedValueOnce(updateError); // failed status update fails
|
||||||
|
|
||||||
|
// The service will throw the update error since it happens in the catch block
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Update failed status failed');
|
||||||
|
|
||||||
|
// Should still attempt to update to failed status
|
||||||
|
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
payoutStatus: 'failed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processAllEligiblePayouts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(PayoutService, 'getEligiblePayouts');
|
||||||
|
jest.spyOn(PayoutService, 'processRentalPayout');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
PayoutService.getEligiblePayouts.mockRestore();
|
||||||
|
PayoutService.processRentalPayout.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process all eligible payouts successfully', async () => {
|
||||||
|
const mockRentals = [
|
||||||
|
{ id: 1, payoutAmount: 9500 },
|
||||||
|
{ id: 2, payoutAmount: 7500 }
|
||||||
|
];
|
||||||
|
|
||||||
|
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||||
|
PayoutService.processRentalPayout
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_123',
|
||||||
|
amount: 9500
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_456',
|
||||||
|
amount: 7500
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await PayoutService.processAllEligiblePayouts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 2 eligible rentals for payout');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 0 failed');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [
|
||||||
|
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
|
||||||
|
{ rentalId: 2, amount: 7500, transferId: 'tr_456' }
|
||||||
|
],
|
||||||
|
failed: [],
|
||||||
|
totalProcessed: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed success and failure results', async () => {
|
||||||
|
const mockRentals = [
|
||||||
|
{ id: 1, payoutAmount: 9500 },
|
||||||
|
{ id: 2, payoutAmount: 7500 },
|
||||||
|
{ id: 3, payoutAmount: 12000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||||
|
PayoutService.processRentalPayout
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_123',
|
||||||
|
amount: 9500
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error('Stripe account suspended'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_789',
|
||||||
|
amount: 12000
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await PayoutService.processAllEligiblePayouts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [
|
||||||
|
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
|
||||||
|
{ rentalId: 3, amount: 12000, transferId: 'tr_789' }
|
||||||
|
],
|
||||||
|
failed: [
|
||||||
|
{ rentalId: 2, error: 'Stripe account suspended' }
|
||||||
|
],
|
||||||
|
totalProcessed: 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no eligible payouts', async () => {
|
||||||
|
PayoutService.getEligiblePayouts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await PayoutService.processAllEligiblePayouts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 0 eligible rentals for payout');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 0 successful, 0 failed');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [],
|
||||||
|
failed: [],
|
||||||
|
totalProcessed: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors in getEligiblePayouts', async () => {
|
||||||
|
const dbError = new Error('Database connection failed');
|
||||||
|
PayoutService.getEligiblePayouts.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(PayoutService.processAllEligiblePayouts())
|
||||||
|
.rejects.toThrow('Database connection failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing all eligible payouts:',
|
||||||
|
dbError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all payouts failing', async () => {
|
||||||
|
const mockRentals = [
|
||||||
|
{ id: 1, payoutAmount: 9500 },
|
||||||
|
{ id: 2, payoutAmount: 7500 }
|
||||||
|
];
|
||||||
|
|
||||||
|
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||||
|
PayoutService.processRentalPayout
|
||||||
|
.mockRejectedValueOnce(new Error('Transfer failed'))
|
||||||
|
.mockRejectedValueOnce(new Error('Account not found'));
|
||||||
|
|
||||||
|
const result = await PayoutService.processAllEligiblePayouts();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [],
|
||||||
|
failed: [
|
||||||
|
{ rentalId: 1, error: 'Transfer failed' },
|
||||||
|
{ rentalId: 2, error: 'Account not found' }
|
||||||
|
],
|
||||||
|
totalProcessed: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('retryFailedPayouts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(PayoutService, 'processRentalPayout');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
PayoutService.processRentalPayout.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry failed payouts successfully', async () => {
|
||||||
|
const mockFailedRentals = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
payoutAmount: 9500,
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
payoutAmount: 7500,
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||||
|
PayoutService.processRentalPayout
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_retry_123',
|
||||||
|
amount: 9500
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_retry_456',
|
||||||
|
amount: 7500
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await PayoutService.retryFailedPayouts();
|
||||||
|
|
||||||
|
// Verify query for failed rentals
|
||||||
|
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
status: 'completed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
payoutStatus: 'failed'
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: mockUserModel,
|
||||||
|
as: 'owner',
|
||||||
|
where: {
|
||||||
|
stripeConnectedAccountId: {
|
||||||
|
'not': null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify status reset to pending
|
||||||
|
expect(mockFailedRentals[0].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
|
||||||
|
expect(mockFailedRentals[1].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
|
||||||
|
|
||||||
|
// Verify processing attempts
|
||||||
|
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[0]);
|
||||||
|
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[1]);
|
||||||
|
|
||||||
|
// Verify logs
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 2 failed payouts to retry');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 2 successful, 0 failed');
|
||||||
|
|
||||||
|
// Verify result
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [
|
||||||
|
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' },
|
||||||
|
{ rentalId: 2, amount: 7500, transferId: 'tr_retry_456' }
|
||||||
|
],
|
||||||
|
failed: [],
|
||||||
|
totalProcessed: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed retry results', async () => {
|
||||||
|
const mockFailedRentals = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
payoutAmount: 9500,
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
payoutAmount: 7500,
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||||
|
PayoutService.processRentalPayout
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
transferId: 'tr_retry_123',
|
||||||
|
amount: 9500
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error('Still failing'));
|
||||||
|
|
||||||
|
const result = await PayoutService.retryFailedPayouts();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [
|
||||||
|
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' }
|
||||||
|
],
|
||||||
|
failed: [
|
||||||
|
{ rentalId: 2, error: 'Still failing' }
|
||||||
|
],
|
||||||
|
totalProcessed: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no failed payouts to retry', async () => {
|
||||||
|
mockRentalFindAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await PayoutService.retryFailedPayouts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 0 failed payouts to retry');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 0 successful, 0 failed');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
successful: [],
|
||||||
|
failed: [],
|
||||||
|
totalProcessed: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors in finding failed rentals', async () => {
|
||||||
|
const dbError = new Error('Database query failed');
|
||||||
|
mockRentalFindAll.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(PayoutService.retryFailedPayouts())
|
||||||
|
.rejects.toThrow('Database query failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error retrying failed payouts:',
|
||||||
|
dbError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle status reset errors', async () => {
|
||||||
|
const mockFailedRentals = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
payoutAmount: 9500,
|
||||||
|
update: jest.fn().mockRejectedValue(new Error('Status reset failed'))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||||
|
|
||||||
|
const result = await PayoutService.retryFailedPayouts();
|
||||||
|
|
||||||
|
expect(result.failed).toEqual([
|
||||||
|
{ rentalId: 1, error: 'Status reset failed' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error logging', () => {
|
||||||
|
it('should log errors with rental context in processRentalPayout', async () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 123,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutAmount: 9500,
|
||||||
|
owner: {
|
||||||
|
stripeConnectedAccountId: 'acct_123'
|
||||||
|
},
|
||||||
|
update: jest.fn().mockRejectedValue(new Error('Update failed'))
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Update failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing payout for rental 123:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log aggregate results in processAllEligiblePayouts', async () => {
|
||||||
|
jest.spyOn(PayoutService, 'getEligiblePayouts').mockResolvedValue([
|
||||||
|
{ id: 1 }, { id: 2 }, { id: 3 }
|
||||||
|
]);
|
||||||
|
jest.spyOn(PayoutService, 'processRentalPayout')
|
||||||
|
.mockResolvedValueOnce({ amount: 100, transferId: 'tr_1' })
|
||||||
|
.mockRejectedValueOnce(new Error('Failed'))
|
||||||
|
.mockResolvedValueOnce({ amount: 300, transferId: 'tr_3' });
|
||||||
|
|
||||||
|
await PayoutService.processAllEligiblePayouts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
|
||||||
|
|
||||||
|
PayoutService.getEligiblePayouts.mockRestore();
|
||||||
|
PayoutService.processRentalPayout.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle rental with undefined owner', async () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutAmount: 9500,
|
||||||
|
owner: undefined,
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rental with empty string Stripe account ID', async () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutAmount: 9500,
|
||||||
|
owner: {
|
||||||
|
stripeConnectedAccountId: ''
|
||||||
|
},
|
||||||
|
update: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(PayoutService.processRentalPayout(mockRental))
|
||||||
|
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large payout amounts', async () => {
|
||||||
|
const mockRental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 2,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutAmount: 999999999, // Very large amount
|
||||||
|
totalAmount: 1000000000,
|
||||||
|
platformFee: 1,
|
||||||
|
startDateTime: new Date('2023-01-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2023-01-02T10:00:00Z'),
|
||||||
|
owner: {
|
||||||
|
stripeConnectedAccountId: 'acct_123'
|
||||||
|
},
|
||||||
|
update: jest.fn().mockResolvedValue(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCreateTransfer.mockResolvedValue({
|
||||||
|
id: 'tr_large_amount',
|
||||||
|
amount: 999999999
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await PayoutService.processRentalPayout(mockRental);
|
||||||
|
|
||||||
|
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||||
|
amount: 999999999,
|
||||||
|
destination: 'acct_123',
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
totalAmount: '1000000000',
|
||||||
|
platformFee: '1'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.amount).toBe(999999999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
684
backend/tests/unit/services/refundService.test.js
Normal file
684
backend/tests/unit/services/refundService.test.js
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
// Mock dependencies
|
||||||
|
const mockRentalFindByPk = jest.fn();
|
||||||
|
const mockRentalUpdate = jest.fn();
|
||||||
|
const mockCreateRefund = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../models', () => ({
|
||||||
|
Rental: {
|
||||||
|
findByPk: mockRentalFindByPk
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/stripeService', () => ({
|
||||||
|
createRefund: mockCreateRefund
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RefundService = require('../../../services/refundService');
|
||||||
|
|
||||||
|
describe('RefundService', () => {
|
||||||
|
let consoleSpy, consoleErrorSpy, consoleWarnSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateRefundAmount', () => {
|
||||||
|
const baseRental = {
|
||||||
|
totalAmount: 100.00,
|
||||||
|
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Owner cancellation', () => {
|
||||||
|
it('should return 100% refund when cancelled by owner', () => {
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'owner');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 100.00,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal amounts correctly for owner cancellation', () => {
|
||||||
|
const rental = { ...baseRental, totalAmount: 125.75 };
|
||||||
|
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 125.75,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Renter cancellation', () => {
|
||||||
|
it('should return 0% refund when cancelled within 24 hours', () => {
|
||||||
|
// Use fake timers to set the current time
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-30T15:00:00Z')); // 19 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundPercentage: 0.0,
|
||||||
|
reason: 'No refund - cancelled within 24 hours of start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 50% refund when cancelled between 24-48 hours', () => {
|
||||||
|
// Use fake timers to set the current time
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 50.00,
|
||||||
|
refundPercentage: 0.5,
|
||||||
|
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 100% refund when cancelled more than 48 hours before', () => {
|
||||||
|
// Use fake timers to set the current time
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-28T15:00:00Z')); // 67 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 100.00,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled more than 48 hours before start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal calculations correctly for 50% refund', () => {
|
||||||
|
const rental = { ...baseRental, totalAmount: 127.33 };
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(rental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 63.66, // 127.33 * 0.5 = 63.665, rounded to 63.66
|
||||||
|
refundPercentage: 0.5,
|
||||||
|
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge case exactly at 24 hours', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-30T10:00:00Z')); // exactly 24 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 50.00,
|
||||||
|
refundPercentage: 0.5,
|
||||||
|
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge case exactly at 48 hours', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T10:00:00Z')); // exactly 48 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 100.00,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled more than 48 hours before start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle zero total amount', () => {
|
||||||
|
const rental = { ...baseRental, totalAmount: 0 };
|
||||||
|
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown cancelledBy value', () => {
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'unknown');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundPercentage: 0,
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle past rental start time for renter cancellation', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-12-02T10:00:00Z')); // 24 hours after start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundPercentage: 0.0,
|
||||||
|
reason: 'No refund - cancelled within 24 hours of start time'
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCancellationEligibility', () => {
|
||||||
|
const baseRental = {
|
||||||
|
id: 1,
|
||||||
|
renterId: 100,
|
||||||
|
ownerId: 200,
|
||||||
|
status: 'pending',
|
||||||
|
paymentStatus: 'paid'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Status validation', () => {
|
||||||
|
it('should reject cancellation for already cancelled rental', () => {
|
||||||
|
const rental = { ...baseRental, status: 'cancelled' };
|
||||||
|
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'Rental is already cancelled',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject cancellation for completed rental', () => {
|
||||||
|
const rental = { ...baseRental, status: 'completed' };
|
||||||
|
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'Cannot cancel completed rental',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject cancellation for active rental', () => {
|
||||||
|
const rental = { ...baseRental, status: 'active' };
|
||||||
|
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'Cannot cancel active rental',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authorization validation', () => {
|
||||||
|
it('should allow renter to cancel', () => {
|
||||||
|
const result = RefundService.validateCancellationEligibility(baseRental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: true,
|
||||||
|
reason: 'Cancellation allowed',
|
||||||
|
cancelledBy: 'renter'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to cancel', () => {
|
||||||
|
const result = RefundService.validateCancellationEligibility(baseRental, 200);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: true,
|
||||||
|
reason: 'Cancellation allowed',
|
||||||
|
cancelledBy: 'owner'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject unauthorized user', () => {
|
||||||
|
const result = RefundService.validateCancellationEligibility(baseRental, 999);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'You are not authorized to cancel this rental',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Payment status validation', () => {
|
||||||
|
it('should reject cancellation for unpaid rental', () => {
|
||||||
|
const rental = { ...baseRental, paymentStatus: 'pending' };
|
||||||
|
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'Cannot cancel rental that hasn\'t been paid',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject cancellation for failed payment', () => {
|
||||||
|
const rental = { ...baseRental, paymentStatus: 'failed' };
|
||||||
|
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'Cannot cancel rental that hasn\'t been paid',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle string user IDs that don\'t match', () => {
|
||||||
|
const result = RefundService.validateCancellationEligibility(baseRental, '100');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'You are not authorized to cancel this rental',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null user ID', () => {
|
||||||
|
const result = RefundService.validateCancellationEligibility(baseRental, null);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: false,
|
||||||
|
reason: 'You are not authorized to cancel this rental',
|
||||||
|
cancelledBy: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processCancellation', () => {
|
||||||
|
let mockRental;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRental = {
|
||||||
|
id: 1,
|
||||||
|
renterId: 100,
|
||||||
|
ownerId: 200,
|
||||||
|
status: 'pending',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
totalAmount: 100.00,
|
||||||
|
stripePaymentIntentId: 'pi_123456789',
|
||||||
|
startDateTime: new Date('2023-12-01T10:00:00Z'),
|
||||||
|
update: mockRentalUpdate
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
mockRentalUpdate.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rental not found', () => {
|
||||||
|
it('should throw error when rental not found', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(RefundService.processCancellation('999', 100))
|
||||||
|
.rejects.toThrow('Rental not found');
|
||||||
|
|
||||||
|
expect(mockRentalFindByPk).toHaveBeenCalledWith('999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation failures', () => {
|
||||||
|
it('should throw error for invalid cancellation', async () => {
|
||||||
|
mockRental.status = 'cancelled';
|
||||||
|
|
||||||
|
await expect(RefundService.processCancellation(1, 100))
|
||||||
|
.rejects.toThrow('Rental is already cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for unauthorized user', async () => {
|
||||||
|
await expect(RefundService.processCancellation(1, 999))
|
||||||
|
.rejects.toThrow('You are not authorized to cancel this rental');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful cancellation with refund', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set time to more than 48 hours before start for full refund
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||||
|
|
||||||
|
mockCreateRefund.mockResolvedValue({
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 10000 // Stripe uses cents
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process owner cancellation with full refund', async () => {
|
||||||
|
const result = await RefundService.processCancellation(1, 200, 'Owner needs to cancel');
|
||||||
|
|
||||||
|
// Verify Stripe refund was created
|
||||||
|
expect(mockCreateRefund).toHaveBeenCalledWith({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 100.00,
|
||||||
|
metadata: {
|
||||||
|
rentalId: 1,
|
||||||
|
cancelledBy: 'owner',
|
||||||
|
refundReason: 'Full refund - cancelled by owner'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify rental was updated
|
||||||
|
expect(mockRentalUpdate).toHaveBeenCalledWith({
|
||||||
|
status: 'cancelled',
|
||||||
|
cancelledBy: 'owner',
|
||||||
|
cancelledAt: expect.any(Date),
|
||||||
|
refundAmount: 100.00,
|
||||||
|
refundProcessedAt: expect.any(Date),
|
||||||
|
refundReason: 'Owner needs to cancel',
|
||||||
|
stripeRefundId: 're_123456789',
|
||||||
|
payoutStatus: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
rental: mockRental,
|
||||||
|
refund: {
|
||||||
|
amount: 100.00,
|
||||||
|
percentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner',
|
||||||
|
processed: true,
|
||||||
|
stripeRefundId: 're_123456789'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process renter cancellation with partial refund', async () => {
|
||||||
|
// Set time to 36 hours before start for 50% refund
|
||||||
|
jest.useRealTimers(); // Reset timers first
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||||
|
|
||||||
|
mockCreateRefund.mockResolvedValue({
|
||||||
|
id: 're_partial',
|
||||||
|
amount: 5000 // 50% in cents
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await RefundService.processCancellation(1, 100);
|
||||||
|
|
||||||
|
expect(mockCreateRefund).toHaveBeenCalledWith({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 50.00,
|
||||||
|
metadata: {
|
||||||
|
rentalId: 1,
|
||||||
|
cancelledBy: 'renter',
|
||||||
|
refundReason: '50% refund - cancelled between 24-48 hours of start time'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.refund).toEqual({
|
||||||
|
amount: 50.00,
|
||||||
|
percentage: 0.5,
|
||||||
|
reason: '50% refund - cancelled between 24-48 hours of start time',
|
||||||
|
processed: true,
|
||||||
|
stripeRefundId: 're_partial'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('No refund scenarios', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set time to within 24 hours for no refund
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-30T15:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancellation with no refund', async () => {
|
||||||
|
const result = await RefundService.processCancellation(1, 100);
|
||||||
|
|
||||||
|
// Verify no Stripe refund was attempted
|
||||||
|
expect(mockCreateRefund).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify rental was updated
|
||||||
|
expect(mockRentalUpdate).toHaveBeenCalledWith({
|
||||||
|
status: 'cancelled',
|
||||||
|
cancelledBy: 'renter',
|
||||||
|
cancelledAt: expect.any(Date),
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundProcessedAt: null,
|
||||||
|
refundReason: 'No refund - cancelled within 24 hours of start time',
|
||||||
|
stripeRefundId: null,
|
||||||
|
payoutStatus: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.refund).toEqual({
|
||||||
|
amount: 0.00,
|
||||||
|
percentage: 0.0,
|
||||||
|
reason: 'No refund - cancelled within 24 hours of start time',
|
||||||
|
processed: false,
|
||||||
|
stripeRefundId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle refund without payment intent ID', async () => {
|
||||||
|
mockRental.stripePaymentIntentId = null;
|
||||||
|
// Set to full refund scenario
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||||
|
|
||||||
|
const result = await RefundService.processCancellation(1, 200);
|
||||||
|
|
||||||
|
expect(mockCreateRefund).not.toHaveBeenCalled();
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
'Refund amount calculated but no payment intent ID for rental 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.refund).toEqual({
|
||||||
|
amount: 100.00,
|
||||||
|
percentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner',
|
||||||
|
processed: false,
|
||||||
|
stripeRefundId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Stripe refund errors', async () => {
|
||||||
|
const stripeError = new Error('Refund failed');
|
||||||
|
mockCreateRefund.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(RefundService.processCancellation(1, 200))
|
||||||
|
.rejects.toThrow('Failed to process refund: Refund failed');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error processing Stripe refund:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database update errors', async () => {
|
||||||
|
const dbError = new Error('Database update failed');
|
||||||
|
mockRentalUpdate.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
mockCreateRefund.mockResolvedValue({
|
||||||
|
id: 're_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(RefundService.processCancellation(1, 200))
|
||||||
|
.rejects.toThrow('Database update failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRefundPreview', () => {
|
||||||
|
let mockRental;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRental = {
|
||||||
|
id: 1,
|
||||||
|
renterId: 100,
|
||||||
|
ownerId: 200,
|
||||||
|
status: 'pending',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
totalAmount: 150.00,
|
||||||
|
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Successful preview', () => {
|
||||||
|
it('should return owner cancellation preview', async () => {
|
||||||
|
const result = await RefundService.getRefundPreview(1, 200);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: true,
|
||||||
|
cancelledBy: 'owner',
|
||||||
|
refundAmount: 150.00,
|
||||||
|
refundPercentage: 1.0,
|
||||||
|
reason: 'Full refund - cancelled by owner',
|
||||||
|
totalAmount: 150.00
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return renter cancellation preview with partial refund', async () => {
|
||||||
|
// Set time for 50% refund
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||||
|
|
||||||
|
const result = await RefundService.getRefundPreview(1, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: true,
|
||||||
|
cancelledBy: 'renter',
|
||||||
|
refundAmount: 75.00,
|
||||||
|
refundPercentage: 0.5,
|
||||||
|
reason: '50% refund - cancelled between 24-48 hours of start time',
|
||||||
|
totalAmount: 150.00
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return renter cancellation preview with no refund', async () => {
|
||||||
|
// Set time for no refund
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-30T15:00:00Z'));
|
||||||
|
|
||||||
|
const result = await RefundService.getRefundPreview(1, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
canCancel: true,
|
||||||
|
cancelledBy: 'renter',
|
||||||
|
refundAmount: 0.00,
|
||||||
|
refundPercentage: 0.0,
|
||||||
|
reason: 'No refund - cancelled within 24 hours of start time',
|
||||||
|
totalAmount: 150.00
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error cases', () => {
|
||||||
|
it('should throw error when rental not found', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(RefundService.getRefundPreview('999', 100))
|
||||||
|
.rejects.toThrow('Rental not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid cancellation', async () => {
|
||||||
|
mockRental.status = 'cancelled';
|
||||||
|
|
||||||
|
await expect(RefundService.getRefundPreview(1, 100))
|
||||||
|
.rejects.toThrow('Rental is already cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for unauthorized user', async () => {
|
||||||
|
await expect(RefundService.getRefundPreview(1, 999))
|
||||||
|
.rejects.toThrow('You are not authorized to cancel this rental');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases and error scenarios', () => {
|
||||||
|
it('should handle invalid rental IDs in processCancellation', async () => {
|
||||||
|
mockRentalFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(RefundService.processCancellation('invalid', 100))
|
||||||
|
.rejects.toThrow('Rental not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large refund amounts', async () => {
|
||||||
|
const rental = {
|
||||||
|
totalAmount: 999999.99,
|
||||||
|
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||||
|
|
||||||
|
expect(result.refundAmount).toBe(999999.99);
|
||||||
|
expect(result.refundPercentage).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle refund amount rounding edge cases', async () => {
|
||||||
|
const rental = {
|
||||||
|
totalAmount: 33.333,
|
||||||
|
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set time for 50% refund
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||||
|
|
||||||
|
const result = RefundService.calculateRefundAmount(rental, 'renter');
|
||||||
|
|
||||||
|
expect(result.refundAmount).toBe(16.67); // 33.333 * 0.5 = 16.6665, rounded to 16.67
|
||||||
|
expect(result.refundPercentage).toBe(0.5);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
988
backend/tests/unit/services/stripeService.test.js
Normal file
988
backend/tests/unit/services/stripeService.test.js
Normal file
@@ -0,0 +1,988 @@
|
|||||||
|
// Mock Stripe SDK
|
||||||
|
const mockStripeCheckoutSessionsRetrieve = jest.fn();
|
||||||
|
const mockStripeAccountsCreate = jest.fn();
|
||||||
|
const mockStripeAccountsRetrieve = jest.fn();
|
||||||
|
const mockStripeAccountLinksCreate = jest.fn();
|
||||||
|
const mockStripeTransfersCreate = jest.fn();
|
||||||
|
const mockStripeRefundsCreate = jest.fn();
|
||||||
|
const mockStripeRefundsRetrieve = jest.fn();
|
||||||
|
const mockStripePaymentIntentsCreate = jest.fn();
|
||||||
|
const mockStripeCustomersCreate = jest.fn();
|
||||||
|
const mockStripeCheckoutSessionsCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('stripe', () => {
|
||||||
|
return jest.fn(() => ({
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
retrieve: mockStripeCheckoutSessionsRetrieve,
|
||||||
|
create: mockStripeCheckoutSessionsCreate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
create: mockStripeAccountsCreate,
|
||||||
|
retrieve: mockStripeAccountsRetrieve
|
||||||
|
},
|
||||||
|
accountLinks: {
|
||||||
|
create: mockStripeAccountLinksCreate
|
||||||
|
},
|
||||||
|
transfers: {
|
||||||
|
create: mockStripeTransfersCreate
|
||||||
|
},
|
||||||
|
refunds: {
|
||||||
|
create: mockStripeRefundsCreate,
|
||||||
|
retrieve: mockStripeRefundsRetrieve
|
||||||
|
},
|
||||||
|
paymentIntents: {
|
||||||
|
create: mockStripePaymentIntentsCreate
|
||||||
|
},
|
||||||
|
customers: {
|
||||||
|
create: mockStripeCustomersCreate
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const StripeService = require('../../../services/stripeService');
|
||||||
|
|
||||||
|
describe('StripeService', () => {
|
||||||
|
let consoleSpy, consoleErrorSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up console spies
|
||||||
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
// Set environment variables for tests
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCheckoutSession', () => {
|
||||||
|
it('should retrieve checkout session successfully', async () => {
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
status: 'complete',
|
||||||
|
setup_intent: {
|
||||||
|
id: 'seti_123456789',
|
||||||
|
payment_method: {
|
||||||
|
id: 'pm_123456789',
|
||||||
|
type: 'card'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeCheckoutSessionsRetrieve.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const result = await StripeService.getCheckoutSession('cs_123456789');
|
||||||
|
|
||||||
|
expect(mockStripeCheckoutSessionsRetrieve).toHaveBeenCalledWith('cs_123456789', {
|
||||||
|
expand: ['setup_intent', 'setup_intent.payment_method']
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle checkout session retrieval errors', async () => {
|
||||||
|
const stripeError = new Error('Session not found');
|
||||||
|
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.getCheckoutSession('invalid_session'))
|
||||||
|
.rejects.toThrow('Session not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error retrieving checkout session:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing session ID', async () => {
|
||||||
|
const stripeError = new Error('Invalid session ID');
|
||||||
|
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.getCheckoutSession(null))
|
||||||
|
.rejects.toThrow('Invalid session ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createConnectedAccount', () => {
|
||||||
|
it('should create connected account with default country', async () => {
|
||||||
|
const mockAccount = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
type: 'express',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'US',
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'pending' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
||||||
|
|
||||||
|
const result = await StripeService.createConnectedAccount({
|
||||||
|
email: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
||||||
|
type: 'express',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'US',
|
||||||
|
capabilities: {
|
||||||
|
transfers: { requested: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockAccount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create connected account with custom country', async () => {
|
||||||
|
const mockAccount = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
type: 'express',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'CA',
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'pending' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
||||||
|
|
||||||
|
const result = await StripeService.createConnectedAccount({
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'CA'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
||||||
|
type: 'express',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'CA',
|
||||||
|
capabilities: {
|
||||||
|
transfers: { requested: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockAccount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle connected account creation errors', async () => {
|
||||||
|
const stripeError = new Error('Invalid email address');
|
||||||
|
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createConnectedAccount({
|
||||||
|
email: 'invalid-email'
|
||||||
|
})).rejects.toThrow('Invalid email address');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating connected account:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing email parameter', async () => {
|
||||||
|
const stripeError = new Error('Email is required');
|
||||||
|
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createConnectedAccount({}))
|
||||||
|
.rejects.toThrow('Email is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAccountLink', () => {
|
||||||
|
it('should create account link successfully', async () => {
|
||||||
|
const mockAccountLink = {
|
||||||
|
object: 'account_link',
|
||||||
|
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
||||||
|
created: Date.now(),
|
||||||
|
expires_at: Date.now() + 3600
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeAccountLinksCreate.mockResolvedValue(mockAccountLink);
|
||||||
|
|
||||||
|
const result = await StripeService.createAccountLink(
|
||||||
|
'acct_123456789',
|
||||||
|
'http://localhost:3000/refresh',
|
||||||
|
'http://localhost:3000/return'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockStripeAccountLinksCreate).toHaveBeenCalledWith({
|
||||||
|
account: 'acct_123456789',
|
||||||
|
refresh_url: 'http://localhost:3000/refresh',
|
||||||
|
return_url: 'http://localhost:3000/return',
|
||||||
|
type: 'account_onboarding'
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockAccountLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle account link creation errors', async () => {
|
||||||
|
const stripeError = new Error('Account not found');
|
||||||
|
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createAccountLink(
|
||||||
|
'invalid_account',
|
||||||
|
'http://localhost:3000/refresh',
|
||||||
|
'http://localhost:3000/return'
|
||||||
|
)).rejects.toThrow('Account not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating account link:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid URLs', async () => {
|
||||||
|
const stripeError = new Error('Invalid URL format');
|
||||||
|
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createAccountLink(
|
||||||
|
'acct_123456789',
|
||||||
|
'invalid-url',
|
||||||
|
'invalid-url'
|
||||||
|
)).rejects.toThrow('Invalid URL format');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAccountStatus', () => {
|
||||||
|
it('should retrieve account status successfully', async () => {
|
||||||
|
const mockAccount = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
details_submitted: true,
|
||||||
|
payouts_enabled: true,
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'active' }
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
pending_verification: [],
|
||||||
|
currently_due: [],
|
||||||
|
past_due: []
|
||||||
|
},
|
||||||
|
other_field: 'should_be_filtered_out'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
||||||
|
|
||||||
|
const result = await StripeService.getAccountStatus('acct_123456789');
|
||||||
|
|
||||||
|
expect(mockStripeAccountsRetrieve).toHaveBeenCalledWith('acct_123456789');
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'acct_123456789',
|
||||||
|
details_submitted: true,
|
||||||
|
payouts_enabled: true,
|
||||||
|
capabilities: {
|
||||||
|
transfers: { status: 'active' }
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
pending_verification: [],
|
||||||
|
currently_due: [],
|
||||||
|
past_due: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle account status retrieval errors', async () => {
|
||||||
|
const stripeError = new Error('Account not found');
|
||||||
|
mockStripeAccountsRetrieve.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.getAccountStatus('invalid_account'))
|
||||||
|
.rejects.toThrow('Account not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error retrieving account status:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle accounts with incomplete data', async () => {
|
||||||
|
const mockAccount = {
|
||||||
|
id: 'acct_123456789',
|
||||||
|
details_submitted: false,
|
||||||
|
payouts_enabled: false,
|
||||||
|
capabilities: null,
|
||||||
|
requirements: null
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
||||||
|
|
||||||
|
const result = await StripeService.getAccountStatus('acct_123456789');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'acct_123456789',
|
||||||
|
details_submitted: false,
|
||||||
|
payouts_enabled: false,
|
||||||
|
capabilities: null,
|
||||||
|
requirements: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTransfer', () => {
|
||||||
|
it('should create transfer with default currency', async () => {
|
||||||
|
const mockTransfer = {
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 5000, // $50.00 in cents
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1',
|
||||||
|
ownerId: '2'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||||
|
|
||||||
|
const result = await StripeService.createTransfer({
|
||||||
|
amount: 50.00,
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1',
|
||||||
|
ownerId: '2'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 5000, // Converted to cents
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1',
|
||||||
|
ownerId: '2'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockTransfer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create transfer with custom currency', async () => {
|
||||||
|
const mockTransfer = {
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 5000,
|
||||||
|
currency: 'eur',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||||
|
|
||||||
|
const result = await StripeService.createTransfer({
|
||||||
|
amount: 50.00,
|
||||||
|
currency: 'eur',
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 5000,
|
||||||
|
currency: 'eur',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockTransfer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal amounts correctly', async () => {
|
||||||
|
const mockTransfer = {
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 12534, // $125.34 in cents
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||||
|
|
||||||
|
await StripeService.createTransfer({
|
||||||
|
amount: 125.34,
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 12534, // Properly converted to cents
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle transfer creation errors', async () => {
|
||||||
|
const stripeError = new Error('Insufficient funds');
|
||||||
|
mockStripeTransfersCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createTransfer({
|
||||||
|
amount: 50.00,
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
})).rejects.toThrow('Insufficient funds');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating transfer:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rounding for very small amounts', async () => {
|
||||||
|
const mockTransfer = {
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 1, // $0.005 rounded to 1 cent
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||||
|
|
||||||
|
await StripeService.createTransfer({
|
||||||
|
amount: 0.005, // Should round to 1 cent
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 1,
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRefund', () => {
|
||||||
|
it('should create refund with default parameters', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 5000, // $50.00 in cents
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
status: 'succeeded',
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
const result = await StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 50.00,
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
amount: 5000, // Converted to cents
|
||||||
|
metadata: {
|
||||||
|
rentalId: '1'
|
||||||
|
},
|
||||||
|
reason: 'requested_by_customer'
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockRefund);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create refund with custom reason', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 10000,
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'fraudulent',
|
||||||
|
status: 'succeeded',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
const result = await StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 100.00,
|
||||||
|
reason: 'fraudulent'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
amount: 10000,
|
||||||
|
metadata: {},
|
||||||
|
reason: 'fraudulent'
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockRefund);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal amounts correctly', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 12534, // $125.34 in cents
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
status: 'succeeded',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
await StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 125.34
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
amount: 12534, // Properly converted to cents
|
||||||
|
metadata: {},
|
||||||
|
reason: 'requested_by_customer'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle refund creation errors', async () => {
|
||||||
|
const stripeError = new Error('Payment intent not found');
|
||||||
|
mockStripeRefundsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_invalid',
|
||||||
|
amount: 50.00
|
||||||
|
})).rejects.toThrow('Payment intent not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating refund:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial refund scenarios', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 2500, // Partial refund of $25.00
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
status: 'succeeded',
|
||||||
|
metadata: {
|
||||||
|
type: 'partial'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
const result = await StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 25.00,
|
||||||
|
metadata: {
|
||||||
|
type: 'partial'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.amount).toBe(2500);
|
||||||
|
expect(result.metadata.type).toBe('partial');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRefund', () => {
|
||||||
|
it('should retrieve refund successfully', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 5000,
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
status: 'succeeded',
|
||||||
|
created: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsRetrieve.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
const result = await StripeService.getRefund('re_123456789');
|
||||||
|
|
||||||
|
expect(mockStripeRefundsRetrieve).toHaveBeenCalledWith('re_123456789');
|
||||||
|
expect(result).toEqual(mockRefund);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle refund retrieval errors', async () => {
|
||||||
|
const stripeError = new Error('Refund not found');
|
||||||
|
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.getRefund('re_invalid'))
|
||||||
|
.rejects.toThrow('Refund not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error retrieving refund:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null refund ID', async () => {
|
||||||
|
const stripeError = new Error('Invalid refund ID');
|
||||||
|
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.getRefund(null))
|
||||||
|
.rejects.toThrow('Invalid refund ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chargePaymentMethod', () => {
|
||||||
|
it('should charge payment method successfully', async () => {
|
||||||
|
const mockPaymentIntent = {
|
||||||
|
id: 'pi_123456789',
|
||||||
|
status: 'succeeded',
|
||||||
|
client_secret: 'pi_123456789_secret_test',
|
||||||
|
amount: 5000,
|
||||||
|
currency: 'usd'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||||
|
|
||||||
|
const result = await StripeService.chargePaymentMethod(
|
||||||
|
'pm_123456789',
|
||||||
|
50.00,
|
||||||
|
'cus_123456789',
|
||||||
|
{ rentalId: '1' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 5000, // Converted to cents
|
||||||
|
currency: 'usd',
|
||||||
|
payment_method: 'pm_123456789',
|
||||||
|
customer: 'cus_123456789',
|
||||||
|
confirm: true,
|
||||||
|
return_url: 'http://localhost:3000/payment-complete',
|
||||||
|
metadata: { rentalId: '1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
status: 'succeeded',
|
||||||
|
clientSecret: 'pi_123456789_secret_test'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle payment method charge errors', async () => {
|
||||||
|
const stripeError = new Error('Payment method declined');
|
||||||
|
mockStripePaymentIntentsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.chargePaymentMethod(
|
||||||
|
'pm_invalid',
|
||||||
|
50.00,
|
||||||
|
'cus_123456789'
|
||||||
|
)).rejects.toThrow('Payment method declined');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error charging payment method:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default frontend URL when not set', async () => {
|
||||||
|
delete process.env.FRONTEND_URL;
|
||||||
|
|
||||||
|
const mockPaymentIntent = {
|
||||||
|
id: 'pi_123456789',
|
||||||
|
status: 'succeeded',
|
||||||
|
client_secret: 'pi_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||||
|
|
||||||
|
await StripeService.chargePaymentMethod(
|
||||||
|
'pm_123456789',
|
||||||
|
50.00,
|
||||||
|
'cus_123456789'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
return_url: 'http://localhost:3000/payment-complete'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal amounts correctly', async () => {
|
||||||
|
const mockPaymentIntent = {
|
||||||
|
id: 'pi_123456789',
|
||||||
|
status: 'succeeded',
|
||||||
|
client_secret: 'pi_123456789_secret_test'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||||
|
|
||||||
|
await StripeService.chargePaymentMethod(
|
||||||
|
'pm_123456789',
|
||||||
|
125.34,
|
||||||
|
'cus_123456789'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
amount: 12534 // Properly converted to cents
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle payment requiring authentication', async () => {
|
||||||
|
const mockPaymentIntent = {
|
||||||
|
id: 'pi_123456789',
|
||||||
|
status: 'requires_action',
|
||||||
|
client_secret: 'pi_123456789_secret_test',
|
||||||
|
next_action: {
|
||||||
|
type: 'use_stripe_sdk'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||||
|
|
||||||
|
const result = await StripeService.chargePaymentMethod(
|
||||||
|
'pm_123456789',
|
||||||
|
50.00,
|
||||||
|
'cus_123456789'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('requires_action');
|
||||||
|
expect(result.clientSecret).toBe('pi_123456789_secret_test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCustomer', () => {
|
||||||
|
it('should create customer successfully', async () => {
|
||||||
|
const mockCustomer = {
|
||||||
|
id: 'cus_123456789',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
metadata: {
|
||||||
|
userId: '123'
|
||||||
|
},
|
||||||
|
created: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
||||||
|
|
||||||
|
const result = await StripeService.createCustomer({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
metadata: {
|
||||||
|
userId: '123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
metadata: {
|
||||||
|
userId: '123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockCustomer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create customer with minimal data', async () => {
|
||||||
|
const mockCustomer = {
|
||||||
|
id: 'cus_123456789',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: null,
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
||||||
|
|
||||||
|
const result = await StripeService.createCustomer({
|
||||||
|
email: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: undefined,
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockCustomer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle customer creation errors', async () => {
|
||||||
|
const stripeError = new Error('Invalid email format');
|
||||||
|
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createCustomer({
|
||||||
|
email: 'invalid-email'
|
||||||
|
})).rejects.toThrow('Invalid email format');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating customer:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate customer errors', async () => {
|
||||||
|
const stripeError = new Error('Customer already exists');
|
||||||
|
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createCustomer({
|
||||||
|
email: 'existing@example.com',
|
||||||
|
name: 'Existing User'
|
||||||
|
})).rejects.toThrow('Customer already exists');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSetupCheckoutSession', () => {
|
||||||
|
it('should create setup checkout session successfully', async () => {
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
url: null,
|
||||||
|
client_secret: 'cs_123456789_secret_test',
|
||||||
|
customer: 'cus_123456789',
|
||||||
|
mode: 'setup',
|
||||||
|
ui_mode: 'embedded',
|
||||||
|
metadata: {
|
||||||
|
type: 'payment_method_setup',
|
||||||
|
userId: '123'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const result = await StripeService.createSetupCheckoutSession({
|
||||||
|
customerId: 'cus_123456789',
|
||||||
|
metadata: {
|
||||||
|
userId: '123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
||||||
|
customer: 'cus_123456789',
|
||||||
|
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||||
|
mode: 'setup',
|
||||||
|
ui_mode: 'embedded',
|
||||||
|
redirect_on_completion: 'never',
|
||||||
|
metadata: {
|
||||||
|
type: 'payment_method_setup',
|
||||||
|
userId: '123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create setup checkout session with minimal data', async () => {
|
||||||
|
const mockSession = {
|
||||||
|
id: 'cs_123456789',
|
||||||
|
url: null,
|
||||||
|
client_secret: 'cs_123456789_secret_test',
|
||||||
|
customer: 'cus_123456789',
|
||||||
|
mode: 'setup',
|
||||||
|
ui_mode: 'embedded',
|
||||||
|
metadata: {
|
||||||
|
type: 'payment_method_setup'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
||||||
|
|
||||||
|
const result = await StripeService.createSetupCheckoutSession({
|
||||||
|
customerId: 'cus_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
||||||
|
customer: 'cus_123456789',
|
||||||
|
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||||
|
mode: 'setup',
|
||||||
|
ui_mode: 'embedded',
|
||||||
|
redirect_on_completion: 'never',
|
||||||
|
metadata: {
|
||||||
|
type: 'payment_method_setup'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle setup checkout session creation errors', async () => {
|
||||||
|
const stripeError = new Error('Customer not found');
|
||||||
|
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createSetupCheckoutSession({
|
||||||
|
customerId: 'cus_invalid'
|
||||||
|
})).rejects.toThrow('Customer not found');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating setup checkout session:',
|
||||||
|
stripeError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing customer ID', async () => {
|
||||||
|
const stripeError = new Error('Customer ID is required');
|
||||||
|
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
||||||
|
|
||||||
|
await expect(StripeService.createSetupCheckoutSession({}))
|
||||||
|
.rejects.toThrow('Customer ID is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling and edge cases', () => {
|
||||||
|
it('should handle very large monetary amounts', async () => {
|
||||||
|
const mockTransfer = {
|
||||||
|
id: 'tr_123456789',
|
||||||
|
amount: 99999999, // $999,999.99 in cents
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||||
|
|
||||||
|
await StripeService.createTransfer({
|
||||||
|
amount: 999999.99,
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||||
|
amount: 99999999,
|
||||||
|
currency: 'usd',
|
||||||
|
destination: 'acct_123456789',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero amounts', async () => {
|
||||||
|
const mockRefund = {
|
||||||
|
id: 're_123456789',
|
||||||
|
amount: 0,
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
status: 'succeeded',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||||
|
|
||||||
|
await StripeService.createRefund({
|
||||||
|
paymentIntentId: 'pi_123456789',
|
||||||
|
amount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||||
|
payment_intent: 'pi_123456789',
|
||||||
|
amount: 0,
|
||||||
|
metadata: {},
|
||||||
|
reason: 'requested_by_customer'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network timeout errors', async () => {
|
||||||
|
const timeoutError = new Error('Request timeout');
|
||||||
|
timeoutError.type = 'StripeConnectionError';
|
||||||
|
mockStripeTransfersCreate.mockRejectedValue(timeoutError);
|
||||||
|
|
||||||
|
await expect(StripeService.createTransfer({
|
||||||
|
amount: 50.00,
|
||||||
|
destination: 'acct_123456789'
|
||||||
|
})).rejects.toThrow('Request timeout');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating transfer:',
|
||||||
|
timeoutError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API key errors', async () => {
|
||||||
|
const apiKeyError = new Error('Invalid API key');
|
||||||
|
apiKeyError.type = 'StripeAuthenticationError';
|
||||||
|
mockStripeCustomersCreate.mockRejectedValue(apiKeyError);
|
||||||
|
|
||||||
|
await expect(StripeService.createCustomer({
|
||||||
|
email: 'test@example.com'
|
||||||
|
})).rejects.toThrow('Invalid API key');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error creating customer:',
|
||||||
|
apiKeyError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
|
||||||
render(<App />);
|
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
|
||||||
expect(linkElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user