Compare commits

...

4 Commits

Author SHA1 Message Date
jackiettran
07e5a2a320 Rebrand and updated copyright date 2025-12-22 22:35:57 -05:00
jackiettran
955517347e health endpoint 2025-12-20 15:21:33 -05:00
jackiettran
bd1bd5014c updating unit and integration tests 2025-12-20 14:59:09 -05:00
jackiettran
4e0a4ef019 updated upload unit tests for s3 image handling 2025-12-19 18:58:30 -05:00
65 changed files with 6821 additions and 3522 deletions

View File

@@ -1,5 +1,22 @@
module.exports = { module.exports = {
projects: [
{
displayName: 'unit',
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['**/tests/unit/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
},
{
displayName: 'integration',
testEnvironment: 'node',
testMatch: ['**/tests/integration/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
testTimeout: 30000,
},
],
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
maxWorkers: 1,
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
collectCoverageFrom: [ collectCoverageFrom: [
'**/*.js', '**/*.js',
@@ -9,10 +26,6 @@ module.exports = {
'!jest.config.js' '!jest.config.js'
], ],
coverageReporters: ['text', 'lcov', 'html'], coverageReporters: ['text', 'lcov', 'html'],
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
forceExit: true,
testTimeout: 10000,
coverageThreshold: { coverageThreshold: {
global: { global: {
lines: 80, lines: 80,

View File

@@ -12,10 +12,10 @@
"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": "NODE_ENV=test jest", "test": "NODE_ENV=test jest",
"test:watch": "NODE_ENV=test jest --watch", "test:watch": "NODE_ENV=test jest --watch",
"test:coverage": "jest --coverage --forceExit --maxWorkers=4", "test:coverage": "jest --coverage --maxWorkers=1",
"test:unit": "NODE_ENV=test jest tests/unit", "test:unit": "NODE_ENV=test jest tests/unit",
"test:integration": "NODE_ENV=test jest tests/integration", "test:integration": "NODE_ENV=test jest tests/integration",
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2", "test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=1",
"db:migrate": "sequelize-cli db:migrate", "db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo", "db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", "db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",

View File

@@ -553,7 +553,7 @@ router.post(
} }
// Validate the code // Validate the code
if (!user.isVerificationTokenValid(input)) { if (!user.isVerificationTokenValid(code)) {
// Increment failed attempts // Increment failed attempts
await user.incrementVerificationAttempts(); await user.incrementVerificationAttempts();

121
backend/routes/health.js Normal file
View File

@@ -0,0 +1,121 @@
const express = require("express");
const router = express.Router();
const { sequelize } = require("../models");
const s3Service = require("../services/s3Service");
const logger = require("../utils/logger");
/**
* Health check endpoint for load balancers and monitoring
* GET /health
*
* Returns:
* - 200: All services healthy
* - 503: One or more services unhealthy
*/
router.get("/", async (req, res) => {
const startTime = Date.now();
const checks = {
database: { status: "unknown", latency: null },
s3: { status: "unknown", latency: null },
};
let allHealthy = true;
// Database health check
try {
const dbStart = Date.now();
await sequelize.authenticate();
checks.database = {
status: "healthy",
latency: Date.now() - dbStart,
};
} catch (error) {
allHealthy = false;
checks.database = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: Database connection failed", {
error: error.message,
});
}
// S3 health check (if enabled)
if (s3Service.isEnabled()) {
try {
const s3Start = Date.now();
// S3 is considered healthy if it's properly initialized
// A more thorough check could list bucket contents, but that adds latency
checks.s3 = {
status: "healthy",
latency: Date.now() - s3Start,
bucket: process.env.S3_BUCKET,
};
} catch (error) {
allHealthy = false;
checks.s3 = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: S3 check failed", {
error: error.message,
});
}
} else {
checks.s3 = {
status: "disabled",
latency: 0,
};
}
// Log unhealthy states
if (!allHealthy) {
logger.warn("Health check failed", { checks });
}
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? "healthy" : "unhealthy",
});
});
/**
* Liveness probe - simple check that the process is running
* GET /health/live
*
* Used by Kubernetes/ECS for liveness probes
* Returns 200 if the process is alive
*/
router.get("/live", (req, res) => {
res.status(200).json({
status: "alive",
timestamp: new Date().toISOString(),
});
});
/**
* Readiness probe - check if the service is ready to accept traffic
* GET /health/ready
*
* Used by load balancers to determine if instance should receive traffic
* Checks critical dependencies (database)
*/
router.get("/ready", async (req, res) => {
try {
await sequelize.authenticate();
res.status(200).json({
status: "ready",
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error("Readiness check failed", { error: error.message });
res.status(503).json({
status: "not_ready",
timestamp: new Date().toISOString(),
error: "Database connection failed",
});
}
});
module.exports = router;

View File

@@ -29,6 +29,7 @@ const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks"); const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback"); const feedbackRoutes = require("./routes/feedback");
const uploadRoutes = require("./routes/upload"); const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health");
const PayoutProcessor = require("./jobs/payoutProcessor"); const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob"); const RentalStatusJob = require("./jobs/rentalStatusJob");
@@ -142,15 +143,18 @@ app.use(
express.static(path.join(__dirname, "uploads")) express.static(path.join(__dirname, "uploads"))
); );
// Health check endpoints (no auth, no rate limiting)
app.use("/health", healthRoutes);
// Root endpoint
app.get("/", (req, res) => {
res.json({ message: "Village Share API is running!" });
});
// Public routes (no alpha access required) // Public routes (no alpha access required)
app.use("/api/alpha", alphaRoutes); app.use("/api/alpha", alphaRoutes);
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
// Health check endpoint
app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" });
});
// Protected routes (require alpha access) // Protected routes (require alpha access)
app.use("/api/users", requireAlphaAccess, userRoutes); app.use("/api/users", requireAlphaAccess, userRoutes);
app.use("/api/items", requireAlphaAccess, itemRoutes); app.use("/api/items", requireAlphaAccess, itemRoutes);

View File

@@ -79,7 +79,7 @@ class EmailClient {
} }
// Use friendly sender name format for better recognition // Use friendly sender name format for better recognition
const fromName = process.env.SES_FROM_NAME || "RentAll"; const fromName = process.env.SES_FROM_NAME || "Village Share";
const fromEmail = process.env.SES_FROM_EMAIL; const fromEmail = process.env.SES_FROM_EMAIL;
const source = `${fromName} <${fromEmail}>`; const source = `${fromName} <${fromEmail}>`;

View File

@@ -219,13 +219,13 @@ class TemplateManager {
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
</div> </div>
<div class="content"> <div class="content">
{{content}} {{content}}
</div> </div>
<div class="footer"> <div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p> <p>This email was sent from Village Share. If you have any questions, please contact support.</p>
</div> </div>
</div> </div>
</body> </body>
@@ -252,7 +252,7 @@ class TemplateManager {
<p>{{message}}</p> <p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p> <p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p> <p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using RentAll!</p> <p>Thank you for using Village Share!</p>
` `
), ),
@@ -261,7 +261,7 @@ class TemplateManager {
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2> <h2>Verify Your Email Address</h2>
<p>Thank you for registering with RentAll! Please verify your email address by clicking the button below.</p> <p>Thank you for registering with Village Share! Please verify your email address by clicking the button below.</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p> <p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p> <p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
<p><strong>This link will expire in 24 hours.</strong></p> <p><strong>This link will expire in 24 hours.</strong></p>
@@ -273,7 +273,7 @@ class TemplateManager {
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2> <h2>Reset Your Password</h2>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p> <p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p> <p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p> <p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
<p><strong>This link will expire in 1 hour.</strong></p> <p><strong>This link will expire in 1 hour.</strong></p>
@@ -286,7 +286,7 @@ class TemplateManager {
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2> <h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.</p> <p>This is a confirmation that the password for your Village Share account ({{email}}) has been successfully changed.</p>
<p><strong>Changed on:</strong> {{timestamp}}</p> <p><strong>Changed on:</strong> {{timestamp}}</p>
<p>For your security, all existing sessions have been logged out.</p> <p>For your security, all existing sessions have been logged out.</p>
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p> <p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
@@ -370,7 +370,7 @@ class TemplateManager {
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p> <p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
<p>Funds are typically available in your bank account within 2-3 business days.</p> <p>Funds are typically available in your bank account within 2-3 business days.</p>
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p> <p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
<p>Thank you for being a valued member of the RentAll community!</p> <p>Thank you for being a valued member of the Village Share community!</p>
` `
), ),
@@ -389,7 +389,7 @@ class TemplateManager {
<div class="info-box"> <div class="info-box">
<p><strong>What happens next?</strong></p> <p><strong>What happens next?</strong></p>
<p>{{paymentMessage}}</p> <p>{{paymentMessage}}</p>
<p>We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!</p> <p>We encourage you to explore other similar items available for rent on Village Share. There are many great options waiting for you!</p>
</div> </div>
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p> <p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p> <p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
@@ -424,7 +424,7 @@ class TemplateManager {
` `
<p>Hi {{renterName}},</p> <p>Hi {{renterName}},</p>
<h2>Thank You for Returning On Time!</h2> <h2>Thank You for Returning On Time!</h2>
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the RentAll community!</p> <p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the Village Share community!</p>
<h3>Rental Summary</h3> <h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p> <p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p> <p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
@@ -460,7 +460,7 @@ class TemplateManager {
{{feedbackText}} {{feedbackText}}
</div> </div>
<p><strong>Submitted:</strong> {{submittedAt}}</p> <p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.</p> <p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly.</p> <p>If your feedback requires a response, our team will reach out to you directly.</p>
` `
), ),

View File

@@ -58,7 +58,7 @@ class AlphaInvitationEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
email, email,
"Your Alpha Access Code - RentAll", "Your Alpha Access Code - Village Share",
htmlContent htmlContent
); );
} catch (error) { } catch (error) {

View File

@@ -60,7 +60,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Verify Your Email - RentAll", "Verify Your Email - Village Share",
htmlContent htmlContent
); );
} }
@@ -93,7 +93,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Reset Your Password - RentAll", "Reset Your Password - Village Share",
htmlContent htmlContent
); );
} }
@@ -128,7 +128,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Password Changed Successfully - RentAll", "Password Changed Successfully - Village Share",
htmlContent htmlContent
); );
} }
@@ -163,7 +163,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Personal Information Updated - RentAll", "Personal Information Updated - Village Share",
htmlContent htmlContent
); );
} }

View File

@@ -65,7 +65,7 @@ class FeedbackEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Thank You for Your Feedback - RentAll", "Thank You for Your Feedback - Village Share",
htmlContent htmlContent
); );
} }

View File

@@ -941,7 +941,7 @@ class RentalFlowEmailService {
<h2>Share Your Experience</h2> <h2>Share Your Experience</h2>
<div class="info-box"> <div class="info-box">
<p><strong>Help the community by leaving a review!</strong></p> <p><strong>Help the community by leaving a review!</strong></p>
<p>Your feedback helps other renters make informed decisions and supports quality listings on RentAll.</p> <p>Your feedback helps other renters make informed decisions and supports quality listings on Village Share.</p>
<ul> <ul>
<li>How was the item's condition?</li> <li>How was the item's condition?</li>
<li>Was the owner responsive and helpful?</li> <li>Was the owner responsive and helpful?</li>
@@ -956,7 +956,7 @@ class RentalFlowEmailService {
reviewSection = ` reviewSection = `
<div class="success-box"> <div class="success-box">
<p><strong>✓ Thank You for Your Review!</strong></p> <p><strong>✓ Thank You for Your Review!</strong></p>
<p>Your feedback has been submitted and helps strengthen the RentAll community.</p> <p>Your feedback has been submitted and helps strengthen the Village Share community.</p>
</div> </div>
`; `;
} }

View File

@@ -64,7 +64,7 @@ class RentalReminderEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
userEmail, userEmail,
`RentAll: ${notification.title}`, `Village Share: ${notification.title}`,
htmlContent htmlContent
); );
} catch (error) { } catch (error) {

View File

@@ -60,7 +60,7 @@ class UserEngagementEmailService {
variables variables
); );
const subject = `Congratulations! Your first item is live on RentAll`; const subject = `Congratulations! Your first item is live on Village Share`;
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Your Alpha Access Code - RentAll</title> <title>Your Alpha Access Code - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body, table, td, p, a, li, blockquote {
@@ -220,14 +220,14 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Alpha Access Invitation</div> <div class="tagline">Alpha Access Invitation</div>
</div> </div>
<div class="content"> <div class="content">
<h1>Welcome to Alpha Testing!</h1> <h1>Welcome to Alpha Testing!</h1>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for RentAll, the community-powered rental marketplace.</p> <p>Congratulations! You've been selected to participate in the exclusive alpha testing program for Village Share, the community-powered rental marketplace.</p>
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p> <p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p>
@@ -244,7 +244,7 @@
</div> </div>
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{{frontendUrl}}" class="button">Access RentAll Alpha</a> <a href="{{frontendUrl}}" class="button">Access Village Share Alpha</a>
</div> </div>
<p><strong>What to expect as an alpha tester:</strong></p> <p><strong>What to expect as an alpha tester:</strong></p>
@@ -266,7 +266,7 @@
<li>We value your feedback - let us know what you think!</li> <li>We value your feedback - let us know what you think!</li>
</ul> </ul>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making RentAll the best it can be.</p> <p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making Village Share the best it can be.</p>
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p> <p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
@@ -274,9 +274,9 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll Alpha Testing Program</strong></p> <p><strong>Village Share Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@rentall.app">support@rentall.app</a></p> <p>Need help? Contact us at <a href="mailto:support@villageshare.app">support@villageshare.app</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>{{title}}</title> <title>{{title}}</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -174,7 +182,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -193,11 +203,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Your trusted rental marketplace</div> <div class="tagline">Your trusted rental marketplace</div>
</div> </div>
@@ -212,10 +222,18 @@
<p><strong>Deadline:</strong> {{deadline}}</p> <p><strong>Deadline:</strong> {{deadline}}</p>
</div> </div>
<p>Taking condition photos helps protect both renters and owners by providing clear documentation of the item's state. This is an important step in the rental process.</p> <p>
Taking condition photos helps protect both renters and owners by
providing clear documentation of the item's state. This is an
important step in the rental process.
</p>
<div class="alert-box"> <div class="alert-box">
<p><strong>Important:</strong> Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.</p> <p>
<strong>Important:</strong> Please complete this condition check as
soon as possible. Missing this deadline may affect dispute
resolution if issues arise.
</p>
</div> </div>
<a href="#" class="button">Complete Condition Check</a> <a href="#" class="button">Complete Condition Check</a>
@@ -228,14 +246,24 @@
<li>Accessories or additional components</li> <li>Accessories or additional components</li>
</ul> </ul>
<p>If you have any questions about the condition check process, please don't hesitate to contact our support team.</p> <p>
If you have any questions about the condition check process, please
don't hesitate to contact our support team.
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
<p>You received this email because you have an active rental on RentAll.</p> <p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p> You received this email because you have an active rental on Village
Share.
</p>
<p>
If you have any questions, please
<a href="mailto:support@villageshare.app">contact our support team</a
>.
</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Verify Your Email - RentAll</title> <title>Verify Your Email - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -164,7 +172,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -183,11 +193,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Email Verification</div> <div class="tagline">Email Verification</div>
</div> </div>
@@ -196,54 +206,87 @@
<h1>Verify Your Email Address</h1> <h1>Verify Your Email Address</h1>
<p>Thank you for registering with RentAll! Use the verification code below to complete your account setup.</p> <p>
Thank you for registering with Village Share! Use the verification
code below to complete your account setup.
</p>
<!-- Verification Code Display --> <!-- Verification Code Display -->
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0">
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px;">Your verification code is:</p> <p style="margin-bottom: 10px; color: #6c757d; font-size: 14px">
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); Your verification code is:
</p>
<div
style="
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px; border-radius: 12px;
padding: 20px 40px; padding: 20px 40px;
display: inline-block; display: inline-block;
border: 2px dashed #28a745;"> border: 2px dashed #28a745;
<span style="font-size: 36px; "
>
<span
style="
font-size: 36px;
font-weight: 700; font-weight: 700;
letter-spacing: 8px; letter-spacing: 8px;
color: #28a745; color: #28a745;
font-family: 'Courier New', monospace;">{{verificationCode}}</span> font-family: 'Courier New', monospace;
"
>{{verificationCode}}</span
>
</div> </div>
<p style="margin-top: 10px; font-size: 14px; color: #6c757d;"> <p style="margin-top: 10px; font-size: 14px; color: #6c757d">
Enter this code in the app to verify your email Enter this code in the app to verify your email
</p> </p>
</div> </div>
<div style="text-align: center; margin: 20px 0;"> <div style="text-align: center; margin: 20px 0">
<p style="color: #6c757d; margin-bottom: 10px; font-size: 14px;">Or click the button below:</p> <p style="color: #6c757d; margin-bottom: 10px; font-size: 14px">
Or click the button below:
</p>
<a href="{{verificationUrl}}" class="button">Verify Email Address</a> <a href="{{verificationUrl}}" class="button">Verify Email Address</a>
</div> </div>
<div class="info-box"> <div class="info-box">
<p><strong>Why verify?</strong> Email verification helps us ensure account security and allows you to create listings, make rentals, and process payments.</p> <p>
<strong>Why verify?</strong> Email verification helps us ensure
account security and allows you to create listings, make rentals,
and process payments.
</p>
</div> </div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p> <p>
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p> If the button doesn't work, you can copy and paste this link into your
browser:
</p>
<p style="word-break: break-all; color: #667eea">{{verificationUrl}}</p>
<div class="warning-box"> <div class="warning-box">
<p><strong>This code will expire in 24 hours.</strong> If you need a new verification code, you can request one from your account settings.</p> <p>
<strong>This code will expire in 24 hours.</strong> If you need a
new verification code, you can request one from your account
settings.
</p>
</div> </div>
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p> <p>
<strong>Didn't create an account?</strong> If you didn't register for
a Village Share account, you can safely ignore this email.
</p>
<p>Welcome to the RentAll community!</p> <p>Welcome to the Village Share community!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a transactional email to verify your account. You received this message because an account was created with this email address.</p> <p>
This is a transactional email to verify your account. You received
this message because an account was created with this email address.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feedback Received - RentAll</title> <title>Feedback Received - Village Share</title>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -84,7 +84,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
</div> </div>
<div class="content"> <div class="content">
<p>Hi {{userName}},</p> <p>Hi {{userName}},</p>
@@ -102,15 +102,15 @@
<p><strong>Submitted:</strong> {{submittedAt}}</p> <p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.</p> <p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly at <strong>{{userEmail}}</strong>.</p> <p>If your feedback requires a response, our team will reach out to you directly at <strong>{{userEmail}}</strong>.</p>
<p>Want to share more thoughts? Feel free to send us additional feedback anytime through the app.</p> <p>Want to share more thoughts? Feel free to send us additional feedback anytime through the app.</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p> <p>This email was sent from Village Share. If you have any questions, please contact support.</p>
<p>&copy; {{year}} RentAll. All rights reserved.</p> <p>&copy; {{year}} Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Feedback Received - RentAll</title> <title>New Feedback Received - Village Share</title>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -99,7 +99,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<div class="logo">RentAll Admin</div> <div class="logo">Village Share Admin</div>
</div> </div>
<div class="content"> <div class="content">
<div class="alert-box"> <div class="alert-box">
@@ -151,8 +151,8 @@
<p><strong>Action Required:</strong> Please review this feedback and take appropriate action. If a response is needed, contact the user directly at <strong>{{userEmail}}</strong>.</p> <p><strong>Action Required:</strong> Please review this feedback and take appropriate action. If a response is needed, contact the user directly at <strong>{{userEmail}}</strong>.</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>This is an automated notification from RentAll Feedback System</p> <p>This is an automated notification from Village Share Feedback System</p>
<p>&copy; {{year}} RentAll. All rights reserved.</p> <p>&copy; {{year}} Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your First Listing is Live!</title> <title>Your First Listing is Live!</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -223,7 +231,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -250,65 +260,96 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">🎉 Your First Listing is Live!</div> <div class="tagline">🎉 Your First Listing is Live!</div>
</div> </div>
<div class="content"> <div class="content">
<p>Hi {{ownerName}},</p> <p>Hi {{ownerName}},</p>
<h1>Congratulations! You're Now a RentAll Host!</h1> <h1>Congratulations! You're Now a Village Share Host!</h1>
<p>Your first item is officially live and ready to be rented. This is an exciting milestone!</p> <p>
Your first item is officially live and ready to be rented. This is an
exciting milestone!
</p>
<div class="item-highlight"> <div class="item-highlight">
<div class="celebration-icon">🎊</div> <div class="celebration-icon">🎊</div>
<div class="item-name">{{itemName}}</div> <div class="item-name">{{itemName}}</div>
<p style="color: #6c757d; margin-top: 10px;"> <p style="color: #6c757d; margin-top: 10px">
<a href="{{viewItemUrl}}" class="button">View Your Listing</a> <a href="{{viewItemUrl}}" class="button">View Your Listing</a>
</p> </p>
</div> </div>
<div class="celebration-box"> <div class="celebration-box">
<p><strong>What happens next?</strong></p> <p><strong>What happens next?</strong></p>
<ul style="margin: 10px 0; padding-left: 20px;"> <ul style="margin: 10px 0; padding-left: 20px">
<li>Your listing is now searchable by renters</li> <li>Your listing is now searchable by renters</li>
<li>You'll receive email notifications for rental requests</li> <li>You'll receive email notifications for rental requests</li>
<li>You can approve or decline requests based on your availability</li> <li>
You can approve or decline requests based on your availability
</li>
<li>Payments are processed securely through Stripe</li> <li>Payments are processed securely through Stripe</li>
</ul> </ul>
</div> </div>
<h2>Tips for Success</h2> <h2>Tips for Success</h2>
<ul> <ul>
<li><strong>Respond quickly:</strong> Fast responses lead to more bookings</li> <li>
<li><strong>Keep photos updated:</strong> Great photos attract more renters</li> <strong>Respond quickly:</strong> Fast responses lead to more
<li><strong>Be clear about condition:</strong> Take photos at pickup and return</li> bookings
<li><strong>Communicate well:</strong> Clear communication = happy renters</li> </li>
<li><strong>Maintain availability:</strong> Keep your calendar up to date</li> <li>
<strong>Keep photos updated:</strong> Great photos attract more
renters
</li>
<li>
<strong>Be clear about condition:</strong> Take photos at pickup and
return
</li>
<li>
<strong>Communicate well:</strong> Clear communication = happy
renters
</li>
<li>
<strong>Maintain availability:</strong> Keep your calendar up to
date
</li>
</ul> </ul>
<div class="success-box"> <div class="success-box">
<p><strong>🌟 Pro Tip:</strong> Hosts who respond within 1 hour get 3x more bookings!</p> <p>
<strong>🌟 Pro Tip:</strong> Hosts who respond within 1 hour get 3x
more bookings!
</p>
</div> </div>
<p>We're excited to have you as part of the RentAll community. If you have any questions, our support team is here to help.</p> <p>
We're excited to have you as part of the Village Share community. If
you have any questions, our support team is here to help.
</p>
<p><strong>Happy hosting!</strong><br> <p>
The RentAll Team</p> <strong>Happy hosting!</strong><br />
The Village Share Team
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p> <p>Building a community of sharing and trust</p>
<p>This email was sent because you created your first listing on RentAll.</p> <p>
This email was sent because you created your first listing on Village
Share.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Comment Was Marked as the Answer</title> <title>Your Comment Was Marked as the Answer</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -231,7 +239,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -262,11 +272,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Forum Recognition</div> <div class="tagline">Forum Recognition</div>
</div> </div>
@@ -294,19 +304,29 @@
<a href="{{postUrl}}" class="button">View Post</a> <a href="{{postUrl}}" class="button">View Post</a>
<p>Thank you for contributing your knowledge and helping others in the RentAll community!</p> <p>
Thank you for contributing your knowledge and helping others in the
Village Share community!
</p>
<div class="info-box"> <div class="info-box">
<p><strong>Keep it up!</strong> Your contributions make RentAll a better place for everyone. Continue sharing your expertise and helping fellow community members.</p> <p>
<strong>Keep it up!</strong> Your contributions make Village Share a
better place for everyone. Continue sharing your expertise and
helping fellow community members.
</p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because your forum comment was marked as the accepted answer.</p> <p>
You received this email because your forum comment was marked as the
accepted answer.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Forum Comment Has Been Removed</title> <title>Your Forum Comment Has Been Removed</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -221,7 +229,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -248,11 +258,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Comment Removal Notice</div> <div class="tagline">⚠️ Important: Comment Removal Notice</div>
</div> </div>
@@ -261,10 +271,15 @@
<h1>Your Comment Has Been Removed</h1> <h1>Your Comment Has Been Removed</h1>
<p>We're writing to inform you that your comment has been removed from a forum discussion by {{adminName}}.</p> <p>
We're writing to inform you that your comment has been removed from a
forum discussion by {{adminName}}.
</p>
<div class="post-highlight"> <div class="post-highlight">
<p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px;">Comment on:</p> <p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px">
Comment on:
</p>
<div class="post-title">{{postTitle}}</div> <div class="post-title">{{postTitle}}</div>
<a href="{{postUrl}}" class="post-link">View Discussion →</a> <a href="{{postUrl}}" class="post-link">View Discussion →</a>
</div> </div>
@@ -276,8 +291,10 @@
<div class="info-box"> <div class="info-box">
<p><strong>What this means:</strong></p> <p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;"> <ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>Your comment is no longer visible to other community members</li> <li>
Your comment is no longer visible to other community members
</li>
<li>The comment content has been preserved in case of appeal</li> <li>The comment content has been preserved in case of appeal</li>
<li>The discussion thread remains active for other participants</li> <li>The discussion thread remains active for other participants</li>
<li>You can still participate in other forum discussions</li> <li>You can still participate in other forum discussions</li>
@@ -285,30 +302,49 @@
</div> </div>
<h2>Need Help or Have Questions?</h2> <h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:</p> <p>
If you believe this removal was made in error or if you have questions
about our community guidelines, please don't hesitate to contact our
support team:
</p>
<p style="text-align: center;"> <p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a> <a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p> </p>
<div class="warning-box"> <div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p> <p><strong>Review Our Community Guidelines:</strong></p>
<p>To ensure a positive experience for all members, please review our community guidelines. We appreciate respectful, constructive contributions that help build a supportive community.</p> <p>
To ensure a positive experience for all members, please review our
community guidelines. We appreciate respectful, constructive
contributions that help build a supportive community.
</p>
</div> </div>
<p>Thank you for your understanding, and we look forward to your continued participation in our community.</p> <p>
Thank you for your understanding, and we look forward to your
continued participation in our community.
</p>
<p><strong>Best regards,</strong><br> <p>
The RentAll Team</p> <strong>Best regards,</strong><br />
The Village Share Team
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p> <p>Building a community of sharing and trust</p>
<p>This email was sent because your comment was removed by our moderation team.</p> <p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p> This email was sent because your comment was removed by our moderation
<p>&copy; 2024 RentAll. All rights reserved.</p> team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Comment on Your Post</title> <title>New Comment on Your Post</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -203,7 +211,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -226,11 +236,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div> <div class="tagline">Forum Activity</div>
</div> </div>
@@ -253,19 +263,27 @@
<a href="{{postUrl}}" class="button">View Post & Reply</a> <a href="{{postUrl}}" class="button">View Post & Reply</a>
<p>Click the button above to see the full discussion and respond to this comment.</p> <p>
Click the button above to see the full discussion and respond to this
comment.
</p>
<div class="info-box"> <div class="info-box">
<p><strong>Tip:</strong> Engaging with commenters helps build a vibrant community and provides better answers for everyone.</p> <p>
<strong>Tip:</strong> Engaging with commenters helps build a vibrant
community and provides better answers for everyone.
</p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because someone commented on your forum post.</p> <p>
You received this email because someone commented on your forum post.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -240,7 +240,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Item Request Near You</div> <div class="tagline">Item Request Near You</div>
</div> </div>
@@ -282,13 +282,13 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
You received this email because someone near you posted an item You received this email because someone near you posted an item
request. request.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Discussion Closed</title> <title>Discussion Closed</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -198,7 +206,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -221,11 +231,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Forum Notification</div> <div class="tagline">Forum Notification</div>
</div> </div>
@@ -247,20 +257,30 @@
</div> </div>
<div class="warning-box"> <div class="warning-box">
<p><strong>Note:</strong> This discussion is now closed and no new comments can be added. You can still view the existing discussion and all previous comments.</p> <p>
<strong>Note:</strong> This discussion is now closed and no new
comments can be added. You can still view the existing discussion
and all previous comments.
</p>
</div> </div>
<a href="{{postUrl}}" class="button">View Discussion</a> <a href="{{postUrl}}" class="button">View Discussion</a>
<p>If you have questions about this closure, you can reach out to the person who closed it or contact our support team.</p> <p>
If you have questions about this closure, you can reach out to the
person who closed it or contact our support team.
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because you participated in or authored this forum discussion.</p> <p>
You received this email because you participated in or authored this
forum discussion.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Forum Post Has Been Removed</title> <title>Your Forum Post Has Been Removed</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -212,7 +220,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -239,11 +249,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Forum Post Removal Notice</div> <div class="tagline">⚠️ Important: Forum Post Removal Notice</div>
</div> </div>
@@ -252,7 +262,10 @@
<h1>Your Forum Post Has Been Removed</h1> <h1>Your Forum Post Has Been Removed</h1>
<p>We're writing to inform you that your forum post has been removed from RentAll by {{adminName}}.</p> <p>
We're writing to inform you that your forum post has been removed from
Village Share by {{adminName}}.
</p>
<div class="post-highlight"> <div class="post-highlight">
<div class="post-title">{{postTitle}}</div> <div class="post-title">{{postTitle}}</div>
@@ -265,41 +278,63 @@
<div class="info-box"> <div class="info-box">
<p><strong>What this means:</strong></p> <p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;"> <ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>Your post is no longer visible to other community members</li> <li>Your post is no longer visible to other community members</li>
<li>All comments on this post are also hidden</li> <li>All comments on this post are also hidden</li>
<li>The post cannot receive new comments or activity</li> <li>The post cannot receive new comments or activity</li>
<li>You may still see it in your dashboard if viewing as an admin</li> <li>
You may still see it in your dashboard if viewing as an admin
</li>
</ul> </ul>
</div> </div>
<h2>Need Help or Have Questions?</h2> <h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:</p> <p>
If you believe this removal was made in error or if you have questions
about our community guidelines, please don't hesitate to contact our
support team:
</p>
<p style="text-align: center;"> <p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a> <a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p> </p>
<div class="warning-box"> <div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p> <p><strong>Review Our Community Guidelines:</strong></p>
<p>To prevent future removals, please familiarize yourself with our community guidelines and forum standards. Our team is happy to help you understand how to contribute positively to the RentAll community.</p> <p>
To prevent future removals, please familiarize yourself with our
community guidelines and forum standards. Our team is happy to help
you understand how to contribute positively to the Village Share
community.
</p>
</div> </div>
<p>You can continue participating in the forum by visiting our <a href="{{forumUrl}}" style="color: #667eea;">community forum</a>.</p> <p>
You can continue participating in the forum by visiting our
<a href="{{forumUrl}}" style="color: #667eea">community forum</a>.
</p>
<p>Thank you for your understanding.</p> <p>Thank you for your understanding.</p>
<p><strong>Best regards,</strong><br> <p>
The RentAll Team</p> <strong>Best regards,</strong><br />
The Village Share Team
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p> <p>Building a community of sharing and trust</p>
<p>This email was sent because your forum post was removed by our moderation team.</p> <p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p> This email was sent because your forum post was removed by our
<p>&copy; 2024 RentAll. All rights reserved.</p> moderation team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Reply to Your Comment</title> <title>New Reply to Your Comment</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -229,7 +237,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -253,11 +263,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div> <div class="tagline">Forum Activity</div>
</div> </div>
@@ -285,19 +295,27 @@
<a href="{{postUrl}}" class="button">View Reply & Respond</a> <a href="{{postUrl}}" class="button">View Reply & Respond</a>
<p>Click the button above to see the full discussion and continue the conversation.</p> <p>
Click the button above to see the full discussion and continue the
conversation.
</p>
<div class="info-box"> <div class="info-box">
<p><strong>Tip:</strong> Thoughtful replies help create meaningful discussions and build community connections.</p> <p>
<strong>Tip:</strong> Thoughtful replies help create meaningful
discussions and build community connections.
</p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because someone replied to your forum comment.</p> <p>
You received this email because someone replied to your forum comment.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Activity on a Forum Post You Follow</title> <title>New Activity on a Forum Post You Follow</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -212,7 +220,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -235,11 +245,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div> <div class="tagline">Forum Activity</div>
</div> </div>
@@ -248,7 +258,10 @@
<h1>New activity on a post you're following</h1> <h1>New activity on a post you're following</h1>
<p>{{commenterName}} just commented on a forum post you've participated in:</p> <p>
{{commenterName}} just commented on a forum post you've participated
in:
</p>
<div class="post-title-box"> <div class="post-title-box">
<div class="label">Post You're Following</div> <div class="label">Post You're Following</div>
@@ -263,19 +276,28 @@
<a href="{{postUrl}}" class="button">View Discussion</a> <a href="{{postUrl}}" class="button">View Discussion</a>
<p>Click the button above to see the full conversation and join the discussion.</p> <p>
Click the button above to see the full conversation and join the
discussion.
</p>
<div class="info-box"> <div class="info-box">
<p><strong>Stay engaged:</strong> You're receiving this because you've commented on this post. Keep the conversation going!</p> <p>
<strong>Stay engaged:</strong> You're receiving this because you've
commented on this post. Keep the conversation going!
</p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because there's new activity on a forum post you've commented on.</p> <p>
You received this email because there's new activity on a forum post
you've commented on.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Listing Has Been Removed</title> <title>Your Listing Has Been Removed</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -212,7 +220,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -239,11 +249,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Listing Removal Notice</div> <div class="tagline">⚠️ Important: Listing Removal Notice</div>
</div> </div>
@@ -252,7 +262,10 @@
<h1>Your Listing Has Been Removed</h1> <h1>Your Listing Has Been Removed</h1>
<p>We're writing to inform you that your listing has been removed from RentAll by our moderation team.</p> <p>
We're writing to inform you that your listing has been removed from
Village Share by our moderation team.
</p>
<div class="item-highlight"> <div class="item-highlight">
<div class="item-name">{{itemName}}</div> <div class="item-name">{{itemName}}</div>
@@ -265,7 +278,7 @@
<div class="info-box"> <div class="info-box">
<p><strong>What this means:</strong></p> <p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;"> <ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>Your listing is no longer visible to renters</li> <li>Your listing is no longer visible to renters</li>
<li>You can still view it in your dashboard</li> <li>You can still view it in your dashboard</li>
<li>No new rentals can be requested</li> <li>No new rentals can be requested</li>
@@ -274,32 +287,50 @@
</div> </div>
<h2>Need Help or Have Questions?</h2> <h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our policies, please don't hesitate to contact our support team:</p> <p>
If you believe this removal was made in error or if you have questions
about our policies, please don't hesitate to contact our support team:
</p>
<p style="text-align: center;"> <p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a> <a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p> </p>
<div class="warning-box"> <div class="warning-box">
<p><strong>Review Our Policies:</strong></p> <p><strong>Review Our Policies:</strong></p>
<p>To prevent future removals, please familiarize yourself with our community guidelines and listing standards. Our team is happy to help you understand what makes a great RentAll listing.</p> <p>
To prevent future removals, please familiarize yourself with our
community guidelines and listing standards. Our team is happy to
help you understand what makes a great Village Share listing.
</p>
</div> </div>
<p>You can view your listings anytime from your <a href="{{dashboardUrl}}" style="color: #667eea;">dashboard</a>.</p> <p>
You can view your listings anytime from your
<a href="{{dashboardUrl}}" style="color: #667eea">dashboard</a>.
</p>
<p>Thank you for your understanding.</p> <p>Thank you for your understanding.</p>
<p><strong>Best regards,</strong><br> <p>
The RentAll Team</p> <strong>Best regards,</strong><br />
The Village Share Team
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p> <p>Building a community of sharing and trust</p>
<p>This email was sent because your listing was removed by our moderation team.</p> <p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p> This email was sent because your listing was removed by our moderation
<p>&copy; 2024 RentAll. All rights reserved.</p> team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Message from {{senderName}}</title> <title>New Message from {{senderName}}</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -180,7 +188,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -203,11 +213,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">New Message</div> <div class="tagline">New Message</div>
</div> </div>
@@ -216,7 +226,7 @@
<h1>You have a new message from {{senderName}}</h1> <h1>You have a new message from {{senderName}}</h1>
<p>{{senderName}} sent you a message on RentAll.</p> <p>{{senderName}} sent you a message on Village Share.</p>
<div class="message-box"> <div class="message-box">
<div class="content-text">{{messageContent}}</div> <div class="content-text">{{messageContent}}</div>
@@ -225,19 +235,28 @@
<a href="{{conversationUrl}}" class="button">View Conversation</a> <a href="{{conversationUrl}}" class="button">View Conversation</a>
<p>Click the button above to read and reply to this message on RentAll.</p> <p>
Click the button above to read and reply to this message on Village
Share.
</p>
<div class="info-box"> <div class="info-box">
<p><strong>Tip:</strong> Reply quickly to keep your conversations active and build trust within the RentAll community.</p> <p>
<strong>Tip:</strong> Reply quickly to keep your conversations
active and build trust within the Village Share community.
</p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>You received this email because you have an account on RentAll and someone sent you a message.</p> <p>
You received this email because you have an account on Village Share
and someone sent you a message.
</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Password Changed Successfully - RentAll</title> <title>Password Changed Successfully - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,8 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -182,7 +190,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -195,11 +205,11 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Password Changed Successfully</div> <div class="tagline">Password Changed Successfully</div>
</div> </div>
@@ -209,10 +219,17 @@
<h1>Your Password Has Been Changed</h1> <h1>Your Password Has Been Changed</h1>
<div class="success-box"> <div class="success-box">
<p><strong>Your password was successfully changed.</strong> You can now use your new password to log in to your RentAll account.</p> <p>
<strong>Your password was successfully changed.</strong> You can now
use your new password to log in to your Village Share account.
</p>
</div> </div>
<p>This is a confirmation that the password for your RentAll account has been changed. For your security, all existing sessions have been logged out.</p> <p>
This is a confirmation that the password for your Village Share
account has been changed. For your security, all existing sessions
have been logged out.
</p>
<table class="details-table"> <table class="details-table">
<tr> <tr>
@@ -226,21 +243,34 @@
</table> </table>
<div class="security-box"> <div class="security-box">
<p><strong>Didn't change your password?</strong> If you did not make this change, your account may be compromised. Please contact our support team immediately at support@rentall.com to secure your account.</p> <p>
<strong>Didn't change your password?</strong> If you did not make
this change, your account may be compromised. Please contact our
support team immediately at support@villageshare.app to secure your
account.
</p>
</div> </div>
<div class="info-box"> <div class="info-box">
<p><strong>Security reminder:</strong> Keep your password secure and never share it with anyone. We recommend using a strong, unique password and enabling two-factor authentication when available.</p> <p>
<strong>Security reminder:</strong> Keep your password secure and
never share it with anyone. We recommend using a strong, unique
password and enabling two-factor authentication when available.
</p>
</div> </div>
<p>Thanks for using RentAll!</p> <p>Thanks for using Village Share!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a security notification sent to confirm your password change. If you have any concerns about your account security, please contact our support team immediately.</p> <p>
<p>&copy; 2024 RentAll. All rights reserved.</p> This is a security notification sent to confirm your password change.
If you have any concerns about your account security, please contact
our support team immediately.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Reset Your Password - RentAll</title> <title>Reset Your Password - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body, table, td, p, a, li, blockquote {
@@ -202,7 +202,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Password Reset Request</div> <div class="tagline">Password Reset Request</div>
</div> </div>
@@ -211,7 +211,7 @@
<h1>Reset Your Password</h1> <h1>Reset Your Password</h1>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p> <p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{{resetUrl}}" class="button">Reset Password</a> <a href="{{resetUrl}}" class="button">Reset Password</a>
@@ -232,14 +232,14 @@
<p><strong>Security tip:</strong> Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.</p> <p><strong>Security tip:</strong> Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.</p>
</div> </div>
<p>Thanks for using RentAll!</p> <p>Thanks for using Village Share!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a transactional email sent in response to a password reset request. You received this message because someone requested a password reset for this email address.</p> <p>This is a transactional email sent in response to a password reset request. You received this message because someone requested a password reset for this email address.</p>
<p>If you have any questions or concerns, please contact our support team.</p> <p>If you have any questions or concerns, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Earnings Received - RentAll</title> <title>Earnings Received - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -332,7 +332,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Earnings Received</div> <div class="tagline">Earnings Received</div>
</div> </div>
@@ -400,19 +400,19 @@
</div> </div>
<p> <p>
Thank you for being a valued member of the RentAll community! Keep Thank you for being a valued member of the Village Share community! Keep
sharing your items to earn more. sharing your items to earn more.
</p> </p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a notification about your earnings. You received this message This is a notification about your earnings. You received this message
because a payout was successfully processed for your rental. because a payout was successfully processed for your rental.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Personal Information Updated - RentAll</title> <title>Personal Information Updated - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body, table, td, p, a, li, blockquote {
@@ -199,7 +199,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div> <div class="tagline">Personal Information Updated</div>
</div> </div>
@@ -209,7 +209,7 @@
<h1>Your Personal Information Has Been Updated</h1> <h1>Your Personal Information Has Been Updated</h1>
<div class="info-box"> <div class="info-box">
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your RentAll account.</p> <p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your Village Share account.</p>
</div> </div>
<p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p> <p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p>
@@ -226,20 +226,20 @@
</table> </table>
<div class="security-box"> <div class="security-box">
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@rentall.com and consider changing your password.</p> <p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@villageshare.app and consider changing your password.</p>
</div> </div>
<div class="info-box"> <div class="info-box">
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p> <p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p>
</div> </div>
<p>Thanks for using RentAll!</p> <p>Thanks for using Village Share!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p> <p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -286,7 +286,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Request Approved</div> <div class="tagline">Rental Request Approved</div>
</div> </div>
@@ -343,14 +343,14 @@
<a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a> <a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a>
</p> </p>
<p>Thank you for being part of the RentAll community!</p> <p>Thank you for being part of the Village Share community!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a transactional email confirming your rental approval. You received this message because you approved a rental request on our platform.</p> <p>This is a transactional email confirming your rental approval. You received this message because you approved a rental request on our platform.</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Cancellation Confirmed - RentAll</title> <title>Cancellation Confirmed - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -251,7 +251,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Cancellation Confirmation</div> <div class="tagline">Cancellation Confirmation</div>
</div> </div>
@@ -298,13 +298,13 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a confirmation of your rental cancellation. You received this This is a confirmation of your rental cancellation. You received this
message because you cancelled a rental on RentAll. message because you cancelled a rental on Village Share.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Cancelled - RentAll</title> <title>Rental Cancelled - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -257,7 +257,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Update</div> <div class="tagline">Rental Update</div>
</div> </div>
@@ -297,13 +297,13 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a notification about a rental cancellation. You received this This is a notification about a rental cancellation. You received this
message because you were involved in a rental on RentAll. message because you were involved in a rental on Village Share.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -303,7 +303,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Complete</div> <div class="tagline">Rental Complete</div>
</div> </div>
@@ -358,14 +358,14 @@
<a href="{{owningUrl}}" class="button">View My Listings</a> <a href="{{owningUrl}}" class="button">View My Listings</a>
</p> </p>
<p>Thank you for being an excellent host on RentAll!</p> <p>Thank you for being an excellent host on Village Share!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This email confirms the successful completion of your rental. You received this message because you marked an item as returned on our platform.</p> <p>This email confirms the successful completion of your rental. You received this message because you marked an item as returned on our platform.</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -256,7 +256,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Complete</div> <div class="tagline">Rental Complete</div>
</div> </div>
@@ -268,7 +268,7 @@
<div class="success-box"> <div class="success-box">
<div class="icon"></div> <div class="icon"></div>
<p><strong>Rental Complete:</strong> You've successfully returned <strong>{{itemName}}</strong> on time.</p> <p><strong>Rental Complete:</strong> You've successfully returned <strong>{{itemName}}</strong> on time.</p>
<p>On-time returns like yours help build trust in the RentAll community. Thank you!</p> <p>On-time returns like yours help build trust in the Village Share community. Thank you!</p>
</div> </div>
<h2>Rental Summary</h2> <h2>Rental Summary</h2>
@@ -300,14 +300,14 @@
<a href="{{browseItemsUrl}}" class="button">Browse Available Items</a> <a href="{{browseItemsUrl}}" class="button">Browse Available Items</a>
</p> </p>
<p>Thank you for being a valued member of the RentAll community!</p> <p>Thank you for being a valued member of the Village Share community!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This email confirms the successful completion of your rental. You received this message because you recently returned a rented item on our platform.</p> <p>This email confirms the successful completion of your rental. You received this message because you recently returned a rented item on our platform.</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -220,7 +220,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Confirmed</div> <div class="tagline">Rental Confirmed</div>
</div> </div>
@@ -269,14 +269,14 @@
<li>Contact the owner if you have any questions</li> <li>Contact the owner if you have any questions</li>
</ul> </ul>
<p>Thank you for choosing RentAll! We hope you have a great rental experience.</p> <p>Thank you for choosing Village Share! We hope you have a great rental experience.</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p>This is a transactional email confirming your rental. You received this message because you have an active rental transaction on our platform.</p> <p>This is a transactional email confirming your rental. You received this message because you have an active rental transaction on our platform.</p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Request Declined - RentAll</title> <title>Rental Request Declined - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -247,7 +247,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Request Update</div> <div class="tagline">Rental Request Update</div>
</div> </div>
@@ -289,7 +289,7 @@
</p> </p>
<p> <p>
We encourage you to explore other similar items available for rent We encourage you to explore other similar items available for rent
on RentAll. There are many great options waiting for you! on Village Share. There are many great options waiting for you!
</p> </p>
</div> </div>
@@ -304,13 +304,13 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a notification about your rental request. You received this This is a notification about your rental request. You received this
message because you submitted a rental request on RentAll. message because you submitted a rental request on Village Share.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Request Submitted - RentAll</title> <title>Rental Request Submitted - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -245,7 +245,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Request Submitted</div> <div class="tagline">Request Submitted</div>
</div> </div>
@@ -307,13 +307,13 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a confirmation email for your rental request. You received This is a confirmation email for your rental request. You received
this message because you submitted a rental request on RentAll. this message because you submitted a rental request on Village Share.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Request - RentAll</title> <title>Rental Request - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, body,
@@ -245,7 +245,7 @@
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Rental Request</div> <div class="tagline">Rental Request</div>
</div> </div>
@@ -319,14 +319,14 @@
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>RentAll</strong></p> <p><strong>Village Share</strong></p>
<p> <p>
This is a transactional email about a rental request for your listing. This is a transactional email about a rental request for your listing.
You received this message because you have an active listing on You received this message because you have an active listing on
RentAll. Village Share.
</p> </p>
<p>If you have any questions, please contact our support team.</p> <p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,13 @@
// Integration test setup
// Integration tests use a real database, so we don't mock DATABASE_URL
process.env.NODE_ENV = 'test';
// Ensure JWT secrets are set for integration tests
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
// Set other required env vars if not already set
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key';
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key';

View File

@@ -20,6 +20,7 @@ jest.mock('../../middleware/rateLimiter', () => ({
passwordResetRequestLimiter: (req, res, next) => next(), passwordResetRequestLimiter: (req, res, next) => next(),
verifyEmailLimiter: (req, res, next) => next(), verifyEmailLimiter: (req, res, next) => next(),
resendVerificationLimiter: (req, res, next) => next(), resendVerificationLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
})); }));
// Mock CSRF protection for tests // Mock CSRF protection for tests
@@ -225,7 +226,7 @@ describe('Auth Integration Tests', () => {
}) })
.expect(401); .expect(401);
expect(response.body.error).toBe('Invalid credentials'); expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
}); });
it('should reject login with non-existent email', async () => { it('should reject login with non-existent email', async () => {
@@ -237,7 +238,7 @@ describe('Auth Integration Tests', () => {
}) })
.expect(401); .expect(401);
expect(response.body.error).toBe('Invalid credentials'); expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
}); });
it('should increment login attempts on failed login', async () => { it('should increment login attempts on failed login', async () => {
@@ -421,7 +422,8 @@ describe('Auth Integration Tests', () => {
describe('POST /auth/verify-email', () => { describe('POST /auth/verify-email', () => {
let testUser; let testUser;
let verificationToken; let verificationCode;
let accessToken;
beforeEach(async () => { beforeEach(async () => {
testUser = await createTestUser({ testUser = await createTestUser({
@@ -430,13 +432,21 @@ describe('Auth Integration Tests', () => {
}); });
await testUser.generateVerificationToken(); await testUser.generateVerificationToken();
await testUser.reload(); await testUser.reload();
verificationToken = testUser.verificationToken; verificationCode = testUser.verificationToken; // Now a 6-digit code
// Generate access token for authentication
accessToken = jwt.sign(
{ id: testUser.id, email: testUser.email, jwtVersion: testUser.jwtVersion || 0 },
process.env.JWT_ACCESS_SECRET || 'test-access-secret',
{ expiresIn: '15m' }
);
}); });
it('should verify email with valid token', async () => { it('should verify email with valid code', async () => {
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: verificationToken }) .set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(200); .expect(200);
expect(response.body.message).toBe('Email verified successfully'); expect(response.body.message).toBe('Email verified successfully');
@@ -448,13 +458,14 @@ describe('Auth Integration Tests', () => {
expect(testUser.verificationToken).toBeNull(); expect(testUser.verificationToken).toBeNull();
}); });
it('should reject verification with invalid token', async () => { it('should reject verification with invalid code', async () => {
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'invalid-token' }) .set('Cookie', `accessToken=${accessToken}`)
.send({ code: '000000' })
.expect(400); .expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); expect(response.body.code).toBe('VERIFICATION_INVALID');
}); });
it('should reject verification for already verified user', async () => { it('should reject verification for already verified user', async () => {
@@ -463,10 +474,11 @@ describe('Auth Integration Tests', () => {
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: verificationToken }) .set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(400); .expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); expect(response.body.code).toBe('ALREADY_VERIFIED');
}); });
}); });

View File

@@ -40,6 +40,7 @@ describe('User Model - Email Verification', () => {
lastName: 'User', lastName: 'User',
verificationToken: null, verificationToken: null,
verificationTokenExpiry: null, verificationTokenExpiry: null,
verificationAttempts: 0,
isVerified: false, isVerified: false,
verifiedAt: null, verifiedAt: null,
update: jest.fn().mockImplementation(function(updates) { update: jest.fn().mockImplementation(function(updates) {
@@ -53,18 +54,17 @@ describe('User Model - Email Verification', () => {
}); });
describe('generateVerificationToken', () => { describe('generateVerificationToken', () => {
it('should generate a random token and set 24-hour expiry', async () => { it('should generate a 6-digit code and set 24-hour expiry', async () => {
const mockRandomBytes = Buffer.from('a'.repeat(32)); const mockCode = 123456;
const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times crypto.randomInt.mockReturnValue(mockCode);
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser); await User.prototype.generateVerificationToken.call(mockUser);
expect(crypto.randomBytes).toHaveBeenCalledWith(32); expect(crypto.randomInt).toHaveBeenCalledWith(100000, 999999);
expect(mockUser.update).toHaveBeenCalledWith( expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
verificationToken: mockToken verificationToken: '123456',
verificationAttempts: 0,
}) })
); );
@@ -77,40 +77,40 @@ describe('User Model - Email Verification', () => {
expect(expiryTime).toBeLessThan(expectedExpiry + 1000); expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
}); });
it('should update the user with token and expiry', async () => { it('should update the user with code and expiry', async () => {
const mockRandomBytes = Buffer.from('b'.repeat(32)); const mockCode = 654321;
const mockToken = mockRandomBytes.toString('hex'); crypto.randomInt.mockReturnValue(mockCode);
crypto.randomBytes.mockReturnValue(mockRandomBytes);
const result = await User.prototype.generateVerificationToken.call(mockUser); const result = await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1); expect(mockUser.update).toHaveBeenCalledTimes(1);
expect(result.verificationToken).toBe(mockToken); expect(result.verificationToken).toBe('654321');
expect(result.verificationTokenExpiry).toBeInstanceOf(Date); expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
}); });
it('should generate unique tokens on multiple calls', async () => { it('should generate unique codes on multiple calls', async () => {
const mockRandomBytes1 = Buffer.from('a'.repeat(32)); crypto.randomInt
const mockRandomBytes2 = Buffer.from('b'.repeat(32)); .mockReturnValueOnce(111111)
.mockReturnValueOnce(222222);
crypto.randomBytes
.mockReturnValueOnce(mockRandomBytes1)
.mockReturnValueOnce(mockRandomBytes2);
await User.prototype.generateVerificationToken.call(mockUser); await User.prototype.generateVerificationToken.call(mockUser);
const firstToken = mockUser.update.mock.calls[0][0].verificationToken; const firstCode = mockUser.update.mock.calls[0][0].verificationToken;
await User.prototype.generateVerificationToken.call(mockUser); await User.prototype.generateVerificationToken.call(mockUser);
const secondToken = mockUser.update.mock.calls[1][0].verificationToken; const secondCode = mockUser.update.mock.calls[1][0].verificationToken;
expect(firstToken).not.toBe(secondToken); expect(firstCode).not.toBe(secondCode);
}); });
}); });
describe('isVerificationTokenValid', () => { describe('isVerificationTokenValid', () => {
beforeEach(() => {
// Mock timingSafeEqual to do a simple comparison
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should return true for valid token and non-expired time', () => { it('should return true for valid token and non-expired time', () => {
const validToken = 'valid-token-123'; const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
mockUser.verificationToken = validToken; mockUser.verificationToken = validToken;
@@ -131,25 +131,25 @@ describe('User Model - Email Verification', () => {
}); });
it('should return false for missing expiry', () => { it('should return false for missing expiry', () => {
mockUser.verificationToken = 'valid-token'; mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = null; mockUser.verificationTokenExpiry = null;
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token'); const result = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for mismatched token', () => { it('should return false for mismatched token', () => {
mockUser.verificationToken = 'correct-token'; mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token'); const result = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for expired token', () => { it('should return false for expired token', () => {
const validToken = 'valid-token-123'; const validToken = '123456';
const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
mockUser.verificationToken = validToken; mockUser.verificationToken = validToken;
@@ -161,7 +161,7 @@ describe('User Model - Email Verification', () => {
}); });
it('should return false for token expiring in the past by 1 second', () => { it('should return false for token expiring in the past by 1 second', () => {
const validToken = 'valid-token-123'; const validToken = '123456';
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
mockUser.verificationToken = validToken; mockUser.verificationToken = validToken;
@@ -173,7 +173,7 @@ describe('User Model - Email Verification', () => {
}); });
it('should handle edge case of token expiring exactly now', () => { it('should handle edge case of token expiring exactly now', () => {
const validToken = 'valid-token-123'; const validToken = '123456';
// Set expiry 1ms in the future to handle timing precision // Set expiry 1ms in the future to handle timing precision
const nowExpiry = new Date(Date.now() + 1); const nowExpiry = new Date(Date.now() + 1);
@@ -187,7 +187,7 @@ describe('User Model - Email Verification', () => {
}); });
it('should handle string dates correctly', () => { it('should handle string dates correctly', () => {
const validToken = 'valid-token-123'; const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
mockUser.verificationToken = validToken; mockUser.verificationToken = validToken;
@@ -201,7 +201,7 @@ describe('User Model - Email Verification', () => {
describe('verifyEmail', () => { describe('verifyEmail', () => {
it('should mark user as verified and clear token fields', async () => { it('should mark user as verified and clear token fields', async () => {
mockUser.verificationToken = 'some-token'; mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(); mockUser.verificationTokenExpiry = new Date();
await User.prototype.verifyEmail.call(mockUser); await User.prototype.verifyEmail.call(mockUser);
@@ -245,19 +245,22 @@ describe('User Model - Email Verification', () => {
}); });
describe('Complete verification flow', () => { describe('Complete verification flow', () => {
beforeEach(() => {
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should complete full verification flow successfully', async () => { it('should complete full verification flow successfully', async () => {
// Step 1: Generate verification token // Step 1: Generate verification code
const mockRandomBytes = Buffer.from('c'.repeat(32)); const mockCode = 999888;
const mockToken = mockRandomBytes.toString('hex'); crypto.randomInt.mockReturnValue(mockCode);
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser); await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.verificationToken).toBe(mockToken); expect(mockUser.verificationToken).toBe('999888');
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date); expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
// Step 2: Validate token // Step 2: Validate code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken); const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '999888');
expect(isValid).toBe(true); expect(isValid).toBe(true);
// Step 3: Verify email // Step 3: Verify email
@@ -270,25 +273,23 @@ describe('User Model - Email Verification', () => {
}); });
it('should fail verification with wrong token', async () => { it('should fail verification with wrong token', async () => {
// Generate token // Generate code
const mockToken = 'd'.repeat(64); crypto.randomInt.mockReturnValue(123456);
const mockRandomBytes = Buffer.from('d'.repeat(32));
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser); await User.prototype.generateVerificationToken.call(mockUser);
// Try to validate with wrong token // Try to validate with wrong code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token'); const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
it('should fail verification with expired token', async () => { it('should fail verification with expired token', async () => {
// Manually set an expired token // Manually set an expired token
mockUser.verificationToken = 'expired-token'; mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token'); const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });

View File

@@ -45,10 +45,15 @@ jest.mock('../../../middleware/rateLimiter', () => ({
loginLimiter: (req, res, next) => next(), loginLimiter: (req, res, next) => next(),
registerLimiter: (req, res, next) => next(), registerLimiter: (req, res, next) => next(),
passwordResetLimiter: (req, res, next) => next(), passwordResetLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
})); }));
jest.mock('../../../middleware/auth', () => ({ jest.mock('../../../middleware/auth', () => ({
optionalAuth: (req, res, next) => next(), optionalAuth: (req, res, next) => next(),
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
})); }));
jest.mock('../../../services/email', () => ({ jest.mock('../../../services/email', () => ({
@@ -290,7 +295,7 @@ describe('Auth Routes', () => {
}); });
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials'); expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
}); });
it('should reject login with invalid password', async () => { it('should reject login with invalid password', async () => {
@@ -311,7 +316,7 @@ describe('Auth Routes', () => {
}); });
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials'); expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
expect(mockUser.incLoginAttempts).toHaveBeenCalled(); expect(mockUser.incLoginAttempts).toHaveBeenCalled();
}); });
@@ -536,95 +541,147 @@ describe('Auth Routes', () => {
}); });
describe('POST /auth/verify-email', () => { describe('POST /auth/verify-email', () => {
it('should verify email with valid token', async () => { it('should verify email with valid 6-digit code', async () => {
const mockUser = { const mockUser = {
id: 1, id: 'user-123',
email: 'test@example.com', email: 'test@example.com',
isVerified: false, isVerified: false,
verificationToken: 'valid-token', verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now
verificationAttempts: 0,
isVerificationLocked: jest.fn().mockReturnValue(false),
isVerificationTokenValid: jest.fn().mockReturnValue(true), isVerificationTokenValid: jest.fn().mockReturnValue(true),
verifyEmail: jest.fn().mockResolvedValue() verifyEmail: jest.fn().mockResolvedValue()
}; };
User.findOne.mockResolvedValue(mockUser); User.findByPk.mockResolvedValue(mockUser);
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'valid-token' }); .send({ code: '123456' });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.message).toBe('Email verified successfully'); expect(response.body.message).toBe('Email verified successfully');
expect(response.body.user).toMatchObject({ expect(response.body.user).toMatchObject({
id: 1, id: 'user-123',
email: 'test@example.com', email: 'test@example.com',
isVerified: true isVerified: true
}); });
expect(mockUser.verifyEmail).toHaveBeenCalled(); expect(mockUser.verifyEmail).toHaveBeenCalled();
}); });
it('should reject missing token', async () => { it('should reject missing code', async () => {
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({}); .send({});
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification token required'); expect(response.body.error).toBe('Verification code required');
expect(response.body.code).toBe('TOKEN_REQUIRED'); expect(response.body.code).toBe('CODE_REQUIRED');
}); });
it('should reject invalid token', async () => { it('should reject invalid code format (not 6 digits)', async () => {
User.findOne.mockResolvedValue(null); const response = await request(app)
.post('/auth/verify-email')
.send({ code: '12345' }); // Only 5 digits
expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification code must be 6 digits');
expect(response.body.code).toBe('INVALID_CODE_FORMAT');
});
it('should reject when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'invalid-token' }); .send({ code: '123456' });
expect(response.status).toBe(400); expect(response.status).toBe(404);
expect(response.body.error).toBe('Invalid verification token'); expect(response.body.error).toBe('User not found');
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); expect(response.body.code).toBe('USER_NOT_FOUND');
}); });
it('should reject already verified user', async () => { it('should reject already verified user', async () => {
const mockUser = { const mockUser = {
id: 1, id: 'user-123',
isVerified: true isVerified: true
}; };
User.findOne.mockResolvedValue(mockUser); User.findByPk.mockResolvedValue(mockUser);
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'some-token' }); .send({ code: '123456' });
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.error).toBe('Email already verified'); expect(response.body.error).toBe('Email already verified');
expect(response.body.code).toBe('ALREADY_VERIFIED'); expect(response.body.code).toBe('ALREADY_VERIFIED');
}); });
it('should reject expired token', async () => { it('should reject when too many verification attempts', async () => {
const mockUser = { const mockUser = {
id: 1, id: 'user-123',
isVerified: false, isVerified: false,
isVerificationTokenValid: jest.fn().mockReturnValue(false) isVerificationLocked: jest.fn().mockReturnValue(true)
}; };
User.findOne.mockResolvedValue(mockUser); User.findByPk.mockResolvedValue(mockUser);
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'expired-token' }); .send({ code: '123456' });
expect(response.status).toBe(429);
expect(response.body.error).toContain('Too many verification attempts');
expect(response.body.code).toBe('TOO_MANY_ATTEMPTS');
});
it('should reject when no verification code exists', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: null,
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('No verification code found');
expect(response.body.code).toBe('NO_CODE');
});
it('should reject expired verification code', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() - 3600000), // 1 hour ago (expired)
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.error).toContain('expired'); expect(response.body.error).toContain('expired');
expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED'); expect(response.body.code).toBe('VERIFICATION_EXPIRED');
}); });
it('should handle verification errors', async () => { it('should handle verification errors', async () => {
User.findOne.mockRejectedValue(new Error('Database error')); User.findByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app) const response = await request(app)
.post('/auth/verify-email') .post('/auth/verify-email')
.send({ token: 'some-token' }); .send({ code: '123456' });
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body.error).toBe('Email verification failed. Please try again.'); expect(response.body.error).toBe('Email verification failed. Please try again.');
@@ -835,6 +892,48 @@ describe('Auth Routes', () => {
}); });
}); });
describe('GET /auth/status', () => {
it('should return authenticated true when user is logged in', async () => {
// The optionalAuth middleware sets req.user if authenticated
// We need to modify the mock for this specific test
const mockUser = {
id: 1,
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
isVerified: true
};
// Create a custom app for this test with user set
const statusApp = express();
statusApp.use(express.json());
statusApp.use((req, res, next) => {
req.user = mockUser;
next();
});
statusApp.use('/auth', authRoutes);
const response = await request(statusApp)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(true);
expect(response.body.user).toMatchObject({
id: 1,
email: 'test@example.com'
});
});
it('should return authenticated false when user is not logged in', async () => {
const response = await request(app)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(false);
expect(response.body.user).toBeUndefined();
});
});
describe('POST /auth/forgot-password', () => { describe('POST /auth/forgot-password', () => {
it('should send password reset email for existing user', async () => { it('should send password reset email for existing user', async () => {
const mockUser = { const mockUser = {

View File

@@ -0,0 +1,328 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../services/conditionCheckService', () => ({
submitConditionCheck: jest.fn(),
getConditionChecks: jest.fn(),
getConditionCheckTimeline: jest.fn(),
getAvailableChecks: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { conditionChecks: 10 },
}));
const ConditionCheckService = require('../../../services/conditionCheckService');
const { validateS3Keys } = require('../../../utils/s3KeyValidator');
const conditionCheckRoutes = require('../../../routes/conditionChecks');
const app = express();
app.use(express.json());
app.use('/condition-checks', conditionCheckRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Condition Check Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /condition-checks/:rentalId', () => {
const validConditionCheck = {
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: ['condition-checks/uuid1.jpg', 'condition-checks/uuid2.jpg'],
};
it('should submit a condition check successfully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: validConditionCheck.imageFilenames,
submittedBy: 'user-123',
createdAt: new Date().toISOString(),
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.conditionCheck).toMatchObject({
id: 'check-1',
checkType: 'pre_rental',
});
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
validConditionCheck.imageFilenames,
'Item in good condition'
);
});
it('should handle empty image array', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'post_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'post_rental',
notes: 'No photos',
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'post_rental',
'user-123',
[],
'No photos'
);
});
it('should reject invalid S3 keys', async () => {
validateS3Keys.mockReturnValueOnce({
valid: false,
error: 'Invalid S3 key format',
invalidKeys: ['invalid-key'],
});
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: ['invalid-key'],
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Invalid S3 key format');
expect(response.body.details).toContain('invalid-key');
});
it('should handle service errors', async () => {
ConditionCheckService.submitConditionCheck.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
it('should handle non-array imageFilenames gracefully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: 'not-an-array',
});
expect(response.status).toBe(201);
// Should convert to empty array
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
[],
undefined
);
});
});
describe('GET /condition-checks/:rentalId', () => {
it('should return condition checks for a rental', async () => {
const mockChecks = [
{
id: 'check-1',
checkType: 'pre_rental',
notes: 'Good condition',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'check-2',
checkType: 'post_rental',
notes: 'Minor wear',
createdAt: '2024-01-15T00:00:00Z',
},
];
ConditionCheckService.getConditionChecks.mockResolvedValue(mockChecks);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(2);
expect(response.body.conditionChecks[0].checkType).toBe('pre_rental');
expect(ConditionCheckService.getConditionChecks).toHaveBeenCalledWith('rental-123');
});
it('should return empty array when no checks exist', async () => {
ConditionCheckService.getConditionChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks/rental-456');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch condition checks');
});
});
describe('GET /condition-checks/:rentalId/timeline', () => {
it('should return condition check timeline', async () => {
const mockTimeline = {
rental: { id: 'rental-123', status: 'completed' },
checks: [
{ type: 'pre_rental', status: 'completed', completedAt: '2024-01-01' },
{ type: 'post_rental', status: 'pending', completedAt: null },
],
};
ConditionCheckService.getConditionCheckTimeline.mockResolvedValue(mockTimeline);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.timeline).toMatchObject(mockTimeline);
expect(ConditionCheckService.getConditionCheckTimeline).toHaveBeenCalledWith('rental-123');
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionCheckTimeline.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
});
describe('GET /condition-checks', () => {
it('should return available checks for current user', async () => {
const mockAvailableChecks = [
{
rentalId: 'rental-1',
itemName: 'Camera',
checkType: 'pre_rental',
dueDate: '2024-01-10',
},
{
rentalId: 'rental-2',
itemName: 'Laptop',
checkType: 'post_rental',
dueDate: '2024-01-15',
},
];
ConditionCheckService.getAvailableChecks.mockResolvedValue(mockAvailableChecks);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(2);
expect(response.body.availableChecks[0].itemName).toBe('Camera');
expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123');
});
it('should return empty array when no checks available', async () => {
ConditionCheckService.getAvailableChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getAvailableChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch available checks');
});
});
});

View File

@@ -0,0 +1,813 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring the route
jest.mock('../../../models', () => ({
ForumPost: {
findAndCountAll: jest.fn(),
findByPk: jest.fn(),
findOne: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
},
ForumComment: {
findAll: jest.fn(),
findByPk: jest.fn(),
create: jest.fn(),
count: jest.fn(),
destroy: jest.fn(),
},
PostTag: {
findAll: jest.fn(),
findOrCreate: jest.fn(),
create: jest.fn(),
destroy: jest.fn(),
},
User: {
findByPk: jest.fn(),
},
sequelize: {
transaction: jest.fn(() => ({
commit: jest.fn(),
rollback: jest.fn(),
})),
},
}));
jest.mock('sequelize', () => ({
Op: {
or: Symbol('or'),
iLike: Symbol('iLike'),
in: Symbol('in'),
ne: Symbol('ne'),
},
fn: jest.fn((name, col) => ({ fn: name, col })),
col: jest.fn((name) => ({ col: name })),
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123', role: 'user', isVerified: true };
next();
},
requireAdmin: (req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
},
optionalAuth: (req, res, next) => next(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../services/email', () => ({
forum: {
sendNewPostNotification: jest.fn().mockResolvedValue(),
sendNewCommentNotification: jest.fn().mockResolvedValue(),
sendAnswerAcceptedNotification: jest.fn().mockResolvedValue(),
sendReplyNotification: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../services/googleMapsService', () => ({
geocodeAddress: jest.fn().mockResolvedValue({ lat: 40.7128, lng: -74.006 }),
}));
jest.mock('../../../services/locationService', () => ({
getOrCreateLocation: jest.fn().mockResolvedValue({ id: 'loc-123' }),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { forum: 10 },
}));
const { ForumPost, ForumComment, PostTag, User } = require('../../../models');
const forumRoutes = require('../../../routes/forum');
const app = express();
app.use(express.json());
app.use('/forum', forumRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Forum Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /forum/posts', () => {
it('should return paginated posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'Test Post',
content: 'Test content',
category: 'question',
status: 'open',
commentCount: 5,
viewCount: 100,
author: { id: 'user-1', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', name: 'javascript' }],
toJSON: function() { return this; }
},
];
ForumPost.findAndCountAll.mockResolvedValue({
count: 1,
rows: mockPosts,
});
const response = await request(app)
.get('/forum/posts')
.query({ page: 1, limit: 20 });
expect(response.status).toBe(200);
expect(response.body.posts).toHaveLength(1);
expect(response.body.posts[0].title).toBe('Test Post');
expect(response.body.totalPages).toBe(1);
expect(response.body.currentPage).toBe(1);
expect(response.body.totalPosts).toBe(1);
});
it('should filter posts by category', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ category: 'question' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
category: 'question',
}),
})
);
});
it('should search posts by title and content', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ search: 'javascript' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalled();
});
it('should sort posts by different criteria', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ sort: 'comments' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
order: expect.arrayContaining([
['commentCount', 'DESC'],
]),
})
);
});
it('should handle database errors', async () => {
ForumPost.findAndCountAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/forum/posts');
expect(response.status).toBe(500);
});
});
describe('GET /forum/posts/:id', () => {
it('should return a single post with comments', async () => {
const mockPost = {
id: 'post-1',
title: 'Test Post',
content: 'Test content',
viewCount: 10,
isDeleted: false,
comments: [],
increment: jest.fn().mockResolvedValue(),
toJSON: function() {
const { increment, toJSON, ...rest } = this;
return rest;
},
author: { id: 'user-1', firstName: 'John', lastName: 'Doe', role: 'user' },
tags: [],
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(200);
expect(response.body.title).toBe('Test Post');
expect(mockPost.increment).toHaveBeenCalledWith('viewCount', { silent: true });
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.get('/forum/posts/non-existent');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
it('should return 404 for deleted post (non-admin)', async () => {
const mockPost = {
id: 'post-1',
isDeleted: true,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
});
describe('POST /forum/posts', () => {
const validPostData = {
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
tags: ['javascript', 'react'],
};
it('should create a new post successfully', async () => {
const mockCreatedPost = {
id: 'new-post-id',
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
status: 'open',
};
const mockPostWithDetails = {
...mockCreatedPost,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', tagName: 'javascript' }],
toJSON: function() { return this; },
};
ForumPost.create.mockResolvedValue(mockCreatedPost);
// After create, findByPk is called to get post with details
ForumPost.findByPk.mockResolvedValue(mockPostWithDetails);
const response = await request(app)
.post('/forum/posts')
.send(validPostData);
expect(response.status).toBe(201);
expect(ForumPost.create).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
})
);
});
it('should handle Sequelize validation error for missing title', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ content: 'Content without title', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing content', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title without content', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for invalid category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content', category: 'invalid' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for title too short', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Hi', content: 'Content', category: 'question' });
expect(response.status).toBe(500);
});
});
describe('PUT /forum/posts/:id', () => {
it('should update own post successfully', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
title: 'Original Title',
content: 'Original content',
isDeleted: false,
setTags: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
PostTag.findOrCreate.mockResolvedValue([{ id: 'tag-1', name: 'updated' }]);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated Title', content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Updated Title',
content: 'Updated content',
})
);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/posts/non-existent')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
});
it('should return 403 when updating other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Unauthorized');
});
});
describe('DELETE /forum/posts/:id', () => {
it('should hard delete own post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
destroy: jest.fn().mockResolvedValue(),
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(204);
expect(mockPost.destroy).toHaveBeenCalled();
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/posts/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(403);
});
});
describe('POST /forum/posts/:id/comments', () => {
it('should add a comment to a post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockCreatedComment = {
id: 'comment-1',
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
};
const mockCommentWithDetails = {
...mockCreatedComment,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.create.mockResolvedValue(mockCreatedComment);
// After create, findByPk is called to get comment with details
ForumComment.findByPk.mockResolvedValue(mockCommentWithDetails);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Great post!' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
})
);
expect(mockPost.increment).toHaveBeenCalledWith('commentCount');
});
it('should handle Sequelize validation error for missing content', async () => {
const mockPost = {
id: 'post-1',
status: 'open',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumComment.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({});
expect(response.status).toBe(500);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/forum/posts/non-existent/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(404);
});
it('should return 403 when commenting on closed post', async () => {
const mockPost = {
id: 'post-1',
isDeleted: false,
status: 'closed',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(403);
expect(response.body.error).toContain('closed');
});
it('should support replying to another comment', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockParentComment = {
id: 'parent-comment',
postId: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
const mockCreatedReply = {
id: 'reply-1',
content: 'Reply to comment',
parentCommentId: 'parent-comment',
authorId: 'user-123',
postId: 'post-1',
};
const mockReplyWithDetails = {
...mockCreatedReply,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
// First findByPk call checks parent comment, second gets created comment with details
ForumComment.findByPk
.mockResolvedValueOnce(mockParentComment)
.mockResolvedValueOnce(mockReplyWithDetails);
ForumComment.create.mockResolvedValue(mockCreatedReply);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Reply to comment', parentCommentId: 'parent-comment' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
parentCommentId: 'parent-comment',
})
);
});
});
describe('GET /forum/my-posts', () => {
it('should return authenticated users posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'My Post',
authorId: 'user-123',
toJSON: function() { return this; },
},
];
ForumPost.findAll.mockResolvedValue(mockPosts);
const response = await request(app)
.get('/forum/my-posts');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(ForumPost.findAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
authorId: 'user-123',
}),
})
);
});
});
describe('GET /forum/tags', () => {
it('should return all tags', async () => {
const mockTags = [
{ tagName: 'javascript', count: 10 },
{ tagName: 'react', count: 5 },
];
PostTag.findAll.mockResolvedValue(mockTags);
const response = await request(app)
.get('/forum/tags');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].tagName).toBe('javascript');
});
});
describe('PATCH /forum/posts/:id/status', () => {
it('should update post status by author', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'open',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'answered' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith({
status: 'answered',
closedBy: null,
closedAt: null,
});
});
it('should reject invalid status', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'invalid-status' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid status value');
});
});
describe('PUT /forum/comments/:id', () => {
it('should update own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
content: 'Original',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockComment.update).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Updated content',
})
);
});
it('should return 403 when editing other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated' });
expect(response.status).toBe(403);
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/comments/non-existent')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
});
});
describe('DELETE /forum/comments/:id', () => {
it('should soft delete own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
};
const mockPost = {
id: 'post-1',
commentCount: 5,
decrement: jest.fn().mockResolvedValue(),
};
ForumComment.findByPk.mockResolvedValue(mockComment);
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/comments/comment-1');
// Returns 204 No Content on successful delete
expect(response.status).toBe(204);
expect(mockComment.update).toHaveBeenCalledWith({ isDeleted: true });
expect(mockPost.decrement).toHaveBeenCalledWith('commentCount');
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/comments/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.delete('/forum/comments/comment-1');
expect(response.status).toBe(403);
});
});
});

View File

@@ -0,0 +1,107 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../models', () => ({
sequelize: {
authenticate: jest.fn(),
},
}));
jest.mock('../../../services/s3Service', () => ({
isEnabled: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const { sequelize } = require('../../../models');
const s3Service = require('../../../services/s3Service');
const healthRoutes = require('../../../routes/health');
describe('Health Routes', () => {
let app;
beforeEach(() => {
app = express();
app.use('/health', healthRoutes);
jest.clearAllMocks();
});
describe('GET /health', () => {
it('should return 200 when all services are healthy', async () => {
sequelize.authenticate.mockResolvedValue();
s3Service.isEnabled.mockReturnValue(true);
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
it('should return 503 when database is unhealthy', async () => {
sequelize.authenticate.mockRejectedValue(new Error('Connection refused'));
s3Service.isEnabled.mockReturnValue(true);
const response = await request(app).get('/health');
expect(response.status).toBe(503);
expect(response.body).toEqual({ status: 'unhealthy' });
});
it('should return healthy when S3 is disabled but database is up', async () => {
sequelize.authenticate.mockResolvedValue();
s3Service.isEnabled.mockReturnValue(false);
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
});
describe('GET /health/live', () => {
it('should return 200 alive status', async () => {
const response = await request(app).get('/health/live');
expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
expect(response.body).toHaveProperty('timestamp');
});
it('should always return 200 regardless of service state', async () => {
// Liveness probe should always pass if the process is running
sequelize.authenticate.mockRejectedValue(new Error('DB down'));
const response = await request(app).get('/health/live');
expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
});
});
describe('GET /health/ready', () => {
it('should return 200 when database is ready', async () => {
sequelize.authenticate.mockResolvedValue();
const response = await request(app).get('/health/ready');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ready');
expect(response.body).toHaveProperty('timestamp');
});
it('should return 503 when database is not ready', async () => {
sequelize.authenticate.mockRejectedValue(new Error('Connection timeout'));
const response = await request(app).get('/health/ready');
expect(response.status).toBe(503);
expect(response.body.status).toBe('not_ready');
expect(response.body.error).toBe('Database connection failed');
});
});
});

View File

@@ -199,7 +199,7 @@ describe('Items Routes', () => {
{ {
model: mockUserModel, model: mockUserModel,
as: 'owner', as: 'owner',
attributes: ['id', 'firstName', 'lastName'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
limit: 20, limit: 20,
@@ -580,7 +580,7 @@ describe('Items Routes', () => {
{ {
model: mockUserModel, model: mockUserModel,
as: 'renter', as: 'renter',
attributes: ['id', 'firstName', 'lastName'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -648,7 +648,7 @@ describe('Items Routes', () => {
{ {
model: mockUserModel, model: mockUserModel,
as: 'owner', as: 'owner',
attributes: ['id', 'firstName', 'lastName'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}, },
{ {
model: mockUserModel, model: mockUserModel,

View File

@@ -143,7 +143,7 @@ describe('Rentals Routes', () => {
{ {
model: User, model: User,
as: 'owner', as: 'owner',
attributes: ['id', 'firstName', 'lastName'], attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
}, },
], ],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
@@ -186,7 +186,7 @@ describe('Rentals Routes', () => {
{ {
model: User, model: User,
as: 'renter', as: 'renter',
attributes: ['id', 'firstName', 'lastName'], attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
}, },
], ],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],

View File

@@ -446,15 +446,137 @@ describe('Upload Routes', () => {
}); });
}); });
// Note: The GET /upload/signed-url/*key route uses Express 5 wildcard syntax describe('GET /upload/signed-url/:key(*)', () => {
// which is not fully compatible with the test environment when mocking. const mockSignedUrl = 'https://bucket.s3.amazonaws.com/messages/uuid.jpg?signature=abc';
// The S3OwnershipService functionality is tested separately in s3OwnershipService.test.js
// The route integration is verified in integration tests. beforeEach(() => {
describe('GET /upload/signed-url/*key (wildcard route)', () => { mockGetPresignedDownloadUrl.mockResolvedValue(mockSignedUrl);
it('should be defined as a route', () => { mockCanAccessFile.mockResolvedValue({ authorized: true });
// The route exists and is properly configured });
// Full integration testing of wildcard routes is done in integration tests
expect(true).toBe(true); it('should return signed URL for authorized private content (messages)', async () => {
const response = await request(app)
.get('/upload/signed-url/messages/550e8400-e29b-41d4-a716-446655440000.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
expect(response.body.url).toBe(mockSignedUrl);
expect(response.body.expiresIn).toBe(3600);
expect(mockCanAccessFile).toHaveBeenCalledWith(
'messages/550e8400-e29b-41d4-a716-446655440000.jpg',
'user-123'
);
expect(mockGetPresignedDownloadUrl).toHaveBeenCalledWith(
'messages/550e8400-e29b-41d4-a716-446655440000.jpg'
);
});
it('should return signed URL for authorized condition-check content', async () => {
const response = await request(app)
.get('/upload/signed-url/condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
expect(response.body.url).toBe(mockSignedUrl);
expect(mockCanAccessFile).toHaveBeenCalledWith(
'condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg',
'user-123'
);
});
it('should require authentication', async () => {
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg');
expect(response.status).toBe(401);
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should return 503 when S3 is disabled', async () => {
mockIsEnabled.mockReturnValue(false);
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(503);
});
it('should return 400 for public folder paths (items)', async () => {
const response = await request(app)
.get('/upload/signed-url/items/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should return 400 for public folder paths (profiles)', async () => {
const response = await request(app)
.get('/upload/signed-url/profiles/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
});
it('should return 400 for public folder paths (forum)', async () => {
const response = await request(app)
.get('/upload/signed-url/forum/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
});
it('should return 403 when user is not authorized to access file', async () => {
mockCanAccessFile.mockResolvedValue({
authorized: false,
reason: 'Not a participant in this message'
});
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Access denied');
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should handle URL-encoded keys', async () => {
const response = await request(app)
.get('/upload/signed-url/messages%2Fuuid.jpg')
.set('Authorization', 'Bearer valid-token');
// The key should be decoded
expect(mockCanAccessFile).toHaveBeenCalledWith(
expect.stringContaining('messages'),
'user-123'
);
});
it('should handle S3 service errors gracefully', async () => {
mockGetPresignedDownloadUrl.mockRejectedValue(new Error('S3 error'));
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(500);
});
it('should handle ownership service errors gracefully', async () => {
mockCanAccessFile.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(500);
}); });
}); });
}); });

View File

@@ -0,0 +1,352 @@
const UserService = require('../../../services/UserService');
const { User, UserAddress } = require('../../../models');
const emailServices = require('../../../services/email');
const logger = require('../../../utils/logger');
// Mock dependencies
jest.mock('../../../models', () => ({
User: {
findByPk: jest.fn(),
},
UserAddress: {
create: jest.fn(),
findOne: jest.fn(),
},
}));
jest.mock('../../../services/email', () => ({
auth: {
sendPersonalInfoChangedEmail: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.NODE_ENV = 'test';
});
describe('updateProfile', () => {
const mockUser = {
id: 'user-123',
email: 'original@example.com',
firstName: 'John',
lastName: 'Doe',
address1: '123 Main St',
address2: null,
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'USA',
update: jest.fn().mockResolvedValue(),
};
it('should update user profile successfully', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser) // First call to find user
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); // Second call for return
const updateData = { firstName: 'Jane' };
const result = await UserService.updateProfile('user-123', updateData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(mockUser.update).toHaveBeenCalledWith({ firstName: 'Jane' }, {});
expect(result.firstName).toBe('Jane');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.updateProfile('non-existent', { firstName: 'Test' }))
.rejects.toThrow('User not found');
});
it('should trim email and ignore empty email', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' new@example.com ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@example.com' }),
{}
);
});
it('should not update email if empty string', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' ' });
// Email should not be in the update call
expect(mockUser.update).toHaveBeenCalledWith({}, {});
});
it('should convert empty phone to null', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: '' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: null }),
{}
);
});
it('should trim phone number', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: ' 555-1234 ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: '555-1234' }),
{}
);
});
it('should pass options to update call', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
const mockTransaction = { id: 'tx-123' };
await UserService.updateProfile('user-123', { firstName: 'Jane' }, { transaction: mockTransaction });
expect(mockUser.update).toHaveBeenCalledWith(
expect.any(Object),
{ transaction: mockTransaction }
);
});
it('should not send email notification in test environment', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
// Email should not be sent in test environment
expect(emailServices.auth.sendPersonalInfoChangedEmail).not.toHaveBeenCalled();
});
it('should send email notification in production when personal info changes', async () => {
process.env.NODE_ENV = 'production';
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
expect(logger.info).toHaveBeenCalledWith(
'Personal information changed notification sent',
expect.objectContaining({
userId: 'user-123',
changedFields: ['firstName'],
})
);
});
it('should handle email notification failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email service down')
);
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, email: 'new@example.com' });
// Should not throw despite email failure
const result = await UserService.updateProfile('user-123', { email: 'new@example.com' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalledWith(
'Failed to send personal information changed notification',
expect.objectContaining({
error: 'Email service down',
userId: 'user-123',
})
);
});
});
describe('createUserAddress', () => {
const mockUser = {
id: 'user-123',
email: 'user@example.com',
};
const addressData = {
label: 'Home',
address1: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zipCode: '02101',
country: 'USA',
};
it('should create a new address successfully', async () => {
const mockAddress = { id: 'addr-123', ...addressData, userId: 'user-123' };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
const result = await UserService.createUserAddress('user-123', addressData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(UserAddress.create).toHaveBeenCalledWith({
...addressData,
userId: 'user-123',
});
expect(result.id).toBe('addr-123');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.createUserAddress('non-existent', addressData))
.rejects.toThrow('User not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockAddress = { id: 'addr-123', ...addressData };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
await UserService.createUserAddress('user-123', addressData);
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
});
describe('updateUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
address1: '123 Old St',
update: jest.fn().mockResolvedValue(),
};
it('should update address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', {
address1: '789 New St',
});
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.update).toHaveBeenCalledWith({ address1: '789 New St' });
expect(result.id).toBe('addr-123');
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.updateUserAddress('user-123', 'non-existent', { address1: 'New' })
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalled();
});
});
describe('deleteUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
destroy: jest.fn().mockResolvedValue(),
};
it('should delete address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.destroy).toHaveBeenCalled();
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.deleteUserAddress('user-123', 'non-existent')
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
// Should not throw
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -103,8 +103,8 @@ describe('EmailClient', () => {
process.env = { process.env = {
...originalEnv, ...originalEnv,
EMAIL_ENABLED: 'true', EMAIL_ENABLED: 'true',
SES_FROM_EMAIL: 'noreply@rentall.com', SES_FROM_EMAIL: 'noreply@villageshare.app',
SES_FROM_NAME: 'RentAll', SES_FROM_NAME: 'Village Share',
}; };
}); });
@@ -159,7 +159,7 @@ describe('EmailClient', () => {
); );
expect(SendEmailCommand).toHaveBeenCalledWith({ expect(SendEmailCommand).toHaveBeenCalledWith({
Source: 'RentAll <noreply@rentall.com>', Source: 'Village Share <noreply@villageshare.app>',
Destination: { Destination: {
ToAddresses: ['test@example.com'], ToAddresses: ['test@example.com'],
}, },
@@ -237,7 +237,7 @@ describe('EmailClient', () => {
}); });
it('should add reply-to address if configured', async () => { it('should add reply-to address if configured', async () => {
process.env.SES_REPLY_TO_EMAIL = 'support@rentall.com'; process.env.SES_REPLY_TO_EMAIL = 'support@villageshare.app';
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
@@ -253,7 +253,7 @@ describe('EmailClient', () => {
expect(SendEmailCommand).toHaveBeenCalledWith( expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
ReplyToAddresses: ['support@rentall.com'], ReplyToAddresses: ['support@villageshare.app'],
}) })
); );
}); });

View File

@@ -186,7 +186,7 @@ describe('TemplateManager', () => {
// Should return fallback template content // Should return fallback template content
expect(result).toContain('Test Title'); expect(result).toContain('Test Title');
expect(result).toContain('Test Message'); expect(result).toContain('Test Message');
expect(result).toContain('RentAll'); expect(result).toContain('Village Share');
}); });
it('should auto-initialize if not initialized', async () => { it('should auto-initialize if not initialized', async () => {
@@ -275,7 +275,7 @@ describe('TemplateManager', () => {
expect(fallback).toContain('{{title}}'); expect(fallback).toContain('{{title}}');
expect(fallback).toContain('{{message}}'); expect(fallback).toContain('{{message}}');
expect(fallback).toContain('RentAll'); expect(fallback).toContain('Village Share');
}); });
}); });
}); });

View File

@@ -7,10 +7,10 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="CommunityRentals.App - Rent gym equipment, tools, and musical instruments from your neighbors" content="Village Share - Life is too expensive. Rent or borrow from your neighbors"
/> />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>CommunityRentals.App - Equipment & Tool Rental Marketplace</title> <title>Village Share - Community Rental Marketplace</title>
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet" rel="stylesheet"

View File

@@ -0,0 +1,425 @@
/**
* AuthModal Component Tests
*
* Tests for the AuthModal component including login, signup,
* form validation, and modal behavior.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthModal from '../../components/AuthModal';
// Mock the auth context
const mockLogin = jest.fn();
const mockRegister = jest.fn();
jest.mock('../../contexts/AuthContext', () => ({
...jest.requireActual('../../contexts/AuthContext'),
useAuth: () => ({
login: mockLogin,
register: mockRegister,
user: null,
loading: false,
}),
}));
// Mock child components
jest.mock('../../components/PasswordStrengthMeter', () => {
return function MockPasswordStrengthMeter({ password }: { password: string }) {
return <div data-testid="password-strength-meter">Strength: {password.length > 8 ? 'Strong' : 'Weak'}</div>;
};
});
jest.mock('../../components/PasswordInput', () => {
return function MockPasswordInput({
id,
label,
value,
onChange,
required
}: {
id: string;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
}) {
return (
<div className="mb-3">
<label htmlFor={id} className="form-label">{label}</label>
<input
id={id}
type="password"
className="form-control"
value={value}
onChange={onChange}
required={required}
data-testid="password-input"
/>
</div>
);
};
});
jest.mock('../../components/ForgotPasswordModal', () => {
return function MockForgotPasswordModal({
show,
onHide,
onBackToLogin
}: {
show: boolean;
onHide: () => void;
onBackToLogin: () => void;
}) {
if (!show) return null;
return (
<div data-testid="forgot-password-modal">
<button onClick={onBackToLogin} data-testid="back-to-login">Back to Login</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
jest.mock('../../components/VerificationCodeModal', () => {
return function MockVerificationCodeModal({
show,
onHide,
email,
onVerified
}: {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}) {
if (!show) return null;
return (
<div data-testid="verification-modal">
<p>Verify email: {email}</p>
<button onClick={onVerified} data-testid="verify-button">Verify</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
describe('AuthModal', () => {
const defaultProps = {
show: true,
onHide: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
// Helper to get email input (it's a textbox with type email)
const getEmailInput = () => screen.getByRole('textbox', { hidden: false });
// Helper to get inputs by their preceding label text
const getInputByLabelText = (container: HTMLElement, labelText: string) => {
const label = Array.from(container.querySelectorAll('label')).find(
l => l.textContent === labelText
);
if (!label) throw new Error(`Label "${labelText}" not found`);
// Get the next sibling input or the input inside the same parent
const parent = label.parentElement;
return parent?.querySelector('input') as HTMLInputElement;
};
describe('Rendering', () => {
it('should render login form by default', () => {
const { container } = render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Welcome to Village Share')).toBeInTheDocument();
expect(getInputByLabelText(container, 'Email')).toBeInTheDocument();
expect(screen.getByTestId('password-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
});
it('should render signup form when initialMode is signup', () => {
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument();
expect(getInputByLabelText(container, 'Last Name')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
expect(screen.getByTestId('password-strength-meter')).toBeInTheDocument();
});
it('should not render when show is false', () => {
render(<AuthModal {...defaultProps} show={false} />);
expect(screen.queryByText('Welcome to Village Share')).not.toBeInTheDocument();
});
it('should render Google login button', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
});
it('should render forgot password link in login mode', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
});
it('should not render forgot password link in signup mode', () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument();
});
});
describe('Mode Switching', () => {
it('should switch from login to signup mode', async () => {
const { container } = render(<AuthModal {...defaultProps} />);
// Initially in login mode
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
// Click "Sign up" link
fireEvent.click(screen.getByText('Sign up'));
// Should now be in signup mode
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument();
});
it('should switch from signup to login mode', async () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
// Initially in signup mode
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
// Click "Log in" link
fireEvent.click(screen.getByText('Log in'));
// Should now be in login mode
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(screen.queryByText('First Name')).not.toBeInTheDocument();
});
});
describe('Login Form Submission', () => {
it('should call login with email and password', async () => {
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
// Fill in the form
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
// Submit the form
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
});
});
it('should call onHide after successful login', async () => {
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(defaultProps.onHide).toHaveBeenCalled();
});
});
it('should display error message on login failure', async () => {
mockLogin.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
});
});
it('should show loading state during login', async () => {
// Make login take some time
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument();
});
});
describe('Signup Form Submission', () => {
it('should call register with user data', async () => {
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith({
email: 'john@example.com',
password: 'StrongPass123!',
firstName: 'John',
lastName: 'Doe',
username: 'john', // Generated from email
});
});
});
it('should show verification modal after successful signup', async () => {
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(screen.getByTestId('verification-modal')).toBeInTheDocument();
expect(screen.getByText('Verify email: john@example.com')).toBeInTheDocument();
});
});
it('should display error message on signup failure', async () => {
mockRegister.mockRejectedValue({
response: { data: { error: 'Email already exists' } },
});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(screen.getByText('Email already exists')).toBeInTheDocument();
});
});
});
describe('Forgot Password', () => {
it('should show forgot password modal when link is clicked', async () => {
render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByText('Forgot password?'));
expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument();
});
it('should hide forgot password modal and show login when back is clicked', async () => {
render(<AuthModal {...defaultProps} />);
// Open forgot password modal
fireEvent.click(screen.getByText('Forgot password?'));
expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument();
// Click back to login
fireEvent.click(screen.getByTestId('back-to-login'));
// Should show login form again
await waitFor(() => {
expect(screen.queryByTestId('forgot-password-modal')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
});
});
});
describe('Modal Close', () => {
it('should call onHide when close button is clicked', async () => {
render(<AuthModal {...defaultProps} />);
// Click close button (btn-close class)
const closeButton = document.querySelector('.btn-close') as HTMLButtonElement;
fireEvent.click(closeButton);
expect(defaultProps.onHide).toHaveBeenCalled();
});
});
describe('Google OAuth', () => {
it('should redirect to Google OAuth when Google button is clicked', () => {
// Mock window.location
const originalLocation = window.location;
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /continue with google/i }));
// Check that window.location.href was set to Google OAuth URL
expect(window.location.href).toContain('accounts.google.com');
// Restore
window.location = originalLocation;
});
});
describe('Accessibility', () => {
it('should have password label associated with input', () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
// Password input has proper htmlFor through the mock
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('should display error in an alert role', async () => {
mockLogin.mockRejectedValue({
response: { data: { error: 'Test error' } },
});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
describe('Terms and Privacy Links', () => {
it('should display terms and privacy links', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Terms of Service')).toHaveAttribute('href', '/terms');
expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', '/privacy');
});
});
});

View File

@@ -0,0 +1,268 @@
/**
* Navbar Component Tests
*
* Tests for the Navbar component including navigation links,
* user authentication state, search functionality, and notifications.
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import { rentalAPI, messageAPI } from '../../services/api';
// Mock dependencies
jest.mock('../../services/api', () => ({
rentalAPI: {
getPendingRequestsCount: jest.fn(),
},
messageAPI: {
getUnreadCount: jest.fn(),
},
}));
// Mock socket context
jest.mock('../../contexts/SocketContext', () => ({
useSocket: () => ({
onNewMessage: jest.fn(() => () => {}),
onMessageRead: jest.fn(() => () => {}),
}),
}));
// Variable to control auth state per test
let mockUser: any = null;
const mockLogout = jest.fn();
const mockOpenAuthModal = jest.fn();
jest.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
logout: mockLogout,
openAuthModal: mockOpenAuthModal,
}),
}));
// Mock useNavigate
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
// Helper to render with Router
const renderWithRouter = (component: React.ReactElement) => {
return render(<BrowserRouter>{component}</BrowserRouter>);
};
describe('Navbar', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUser = null;
// Default mock implementations
(rentalAPI.getPendingRequestsCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
(messageAPI.getUnreadCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
});
describe('Branding', () => {
it('should display the brand name', () => {
renderWithRouter(<Navbar />);
expect(screen.getByText('Village Share')).toBeInTheDocument();
});
it('should link brand to home page', () => {
renderWithRouter(<Navbar />);
const brandLink = screen.getByRole('link', { name: /Village Share/i });
expect(brandLink).toHaveAttribute('href', '/');
});
});
describe('Search Functionality', () => {
it('should render search input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument();
});
it('should render location input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('City or ZIP')).toBeInTheDocument();
});
it('should render search button', () => {
renderWithRouter(<Navbar />);
// Search button has an icon
const searchButton = document.querySelector('.bi-search');
expect(searchButton).toBeInTheDocument();
});
it('should navigate to items page when search is submitted', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
fireEvent.change(searchInput, { target: { value: 'camera' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=camera');
});
it('should handle Enter key in search input', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
fireEvent.change(searchInput, { target: { value: 'tent' } });
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(mockNavigate).toHaveBeenCalledWith('/items?search=tent');
});
it('should append city to search URL', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'kayak' } });
fireEvent.change(locationInput, { target: { value: 'Seattle' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=kayak&city=Seattle');
});
it('should append zipCode when location is a zip code', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'bike' } });
fireEvent.change(locationInput, { target: { value: '98101' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=bike&zipCode=98101');
});
it('should clear search fields after search', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: 'camera' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(searchInput.value).toBe('');
});
});
describe('Logged Out State', () => {
it('should show login button when user is not logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('button', { name: 'Login or Sign Up' })).toBeInTheDocument();
});
it('should call openAuthModal when login button is clicked', () => {
renderWithRouter(<Navbar />);
fireEvent.click(screen.getByRole('button', { name: 'Login or Sign Up' }));
expect(mockOpenAuthModal).toHaveBeenCalledWith('login');
});
});
describe('Logged In State', () => {
beforeEach(() => {
mockUser = {
id: 'user-123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};
});
it('should show user name when logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.getByText('John')).toBeInTheDocument();
});
it('should not show login button when logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.queryByRole('button', { name: 'Login or Sign Up' })).not.toBeInTheDocument();
});
it('should show profile link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Profile/i })).toHaveAttribute('href', '/profile');
});
it('should show renting link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Renting/i })).toHaveAttribute('href', '/renting');
});
it('should show owning link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Owning/i })).toHaveAttribute('href', '/owning');
});
it('should show messages link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Messages/i })).toHaveAttribute('href', '/messages');
});
it('should show forum link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Forum/i })).toHaveAttribute('href', '/forum');
});
it('should show earnings link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Earnings/i })).toHaveAttribute('href', '/earnings');
});
it('should call logout and navigate home when logout is clicked', () => {
renderWithRouter(<Navbar />);
const logoutButton = screen.getByRole('button', { name: /Logout/i });
fireEvent.click(logoutButton);
expect(mockLogout).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
describe('Start Earning Link', () => {
it('should show Start Earning link', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: 'Start Earning' })).toHaveAttribute('href', '/create-item');
});
});
describe('Mobile Navigation', () => {
it('should render mobile toggle button', () => {
renderWithRouter(<Navbar />);
expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument();
});
});
});

View File

@@ -23,12 +23,88 @@ jest.mock('../../services/api');
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
// Mock XMLHttpRequest for uploadToS3 tests
class MockXMLHttpRequest {
static instances: MockXMLHttpRequest[] = [];
status = 200;
readyState = 4;
responseText = '';
upload = {
onprogress: null as ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null,
};
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private headers: Record<string, string> = {};
private method = '';
private url = '';
constructor() {
MockXMLHttpRequest.instances.push(this);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(key: string, value: string) {
this.headers[key] = value;
}
send(_data: unknown) {
// Use Promise.resolve().then for async behavior in tests
// This allows promises to resolve without real delays
Promise.resolve().then(() => {
if (this.upload.onprogress) {
this.upload.onprogress({ lengthComputable: true, loaded: 50, total: 100 });
this.upload.onprogress({ lengthComputable: true, loaded: 100, total: 100 });
}
if (this.onload) {
this.onload();
}
});
}
getHeaders() {
return this.headers;
}
getMethod() {
return this.method;
}
getUrl() {
return this.url;
}
static reset() {
MockXMLHttpRequest.instances = [];
}
static getLastInstance() {
return MockXMLHttpRequest.instances[MockXMLHttpRequest.instances.length - 1];
}
}
// Store original XMLHttpRequest
const originalXMLHttpRequest = global.XMLHttpRequest;
describe('Upload Service', () => { describe('Upload Service', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
MockXMLHttpRequest.reset();
// Reset environment variables // Reset environment variables
process.env.REACT_APP_S3_BUCKET = 'test-bucket'; process.env.REACT_APP_S3_BUCKET = 'test-bucket';
process.env.REACT_APP_AWS_REGION = 'us-east-1'; process.env.REACT_APP_AWS_REGION = 'us-east-1';
// Mock XMLHttpRequest globally
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest;
});
afterEach(() => {
// Restore original XMLHttpRequest
(global as unknown as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = originalXMLHttpRequest;
}); });
describe('getPublicImageUrl', () => { describe('getPublicImageUrl', () => {
@@ -173,18 +249,42 @@ describe('Upload Service', () => {
}); });
describe('uploadToS3', () => { describe('uploadToS3', () => {
// Note: XMLHttpRequest mocking is complex and can cause timeouts. const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
// The uploadToS3 function is a thin wrapper around XHR. const mockUploadUrl = 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc';
// Testing focuses on verifying the function signature and basic behavior.
it('should export uploadToS3 function', () => { it('should upload file successfully', async () => {
expect(typeof uploadToS3).toBe('function'); await uploadToS3(mockFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getMethod()).toBe('PUT');
expect(instance.getUrl()).toBe(mockUploadUrl);
expect(instance.getHeaders()['Content-Type']).toBe('image/jpeg');
}); });
it('should accept file, url, and options parameters', () => { it('should call onProgress callback during upload', async () => {
// Verify function signature const onProgress = jest.fn();
await uploadToS3(mockFile, mockUploadUrl, { onProgress });
// Progress should be called at least once
expect(onProgress).toHaveBeenCalled();
// Should receive percentage values
expect(onProgress).toHaveBeenCalledWith(expect.any(Number));
});
it('should export uploadToS3 function with correct signature', () => {
expect(typeof uploadToS3).toBe('function');
// Function accepts file, url, and optional options
expect(uploadToS3.length).toBeGreaterThanOrEqual(2); expect(uploadToS3.length).toBeGreaterThanOrEqual(2);
}); });
it('should set correct content-type header', async () => {
const pngFile = new File(['test'], 'image.png', { type: 'image/png' });
await uploadToS3(pngFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getHeaders()['Content-Type']).toBe('image/png');
});
}); });
describe('confirmUploads', () => { describe('confirmUploads', () => {
@@ -214,70 +314,230 @@ describe('Upload Service', () => {
}); });
describe('uploadFile', () => { describe('uploadFile', () => {
it('should call getPresignedUrl and confirmUploads in sequence', async () => { const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
// Test the flow without mocking XMLHttpRequest (which is complex)
// Instead test that the functions are called with correct parameters
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
const presignResponse: PresignedUrlResponse = { const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com', uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg',
key: 'items/uuid.jpg', key: 'items/uuid.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}; };
it('should complete full upload flow successfully', async () => {
// Mock presign response
mockedApi.post.mockResolvedValueOnce({ data: presignResponse }); mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm response
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [presignResponse.key], total: 1 },
});
// Just test getPresignedUrl is called correctly const result = await uploadFile('item', mockFile);
await getPresignedUrl('item', file);
expect(result).toEqual({
key: presignResponse.key,
publicUrl: presignResponse.publicUrl,
});
// Verify presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', { expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', {
uploadType: 'item', uploadType: 'item',
contentType: 'image/jpeg', contentType: 'image/jpeg',
fileName: 'photo.jpg', fileName: 'photo.jpg',
fileSize: file.size, fileSize: mockFile.size,
}); });
// Verify confirm was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
keys: [presignResponse.key],
});
});
it('should throw error when upload verification fails', async () => {
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm returning empty confirmed array
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [], total: 1 },
});
await expect(uploadFile('item', mockFile)).rejects.toThrow('Upload verification failed');
});
it('should pass onProgress to uploadToS3', async () => {
const onProgress = jest.fn();
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [presignResponse.key], total: 1 },
});
await uploadFile('item', mockFile, { onProgress });
// onProgress should have been called during XHR upload
expect(onProgress).toHaveBeenCalled();
});
it('should work with different upload types', async () => {
const messagePresignResponse = {
...presignResponse,
key: 'messages/uuid.jpg',
publicUrl: null, // Messages are private
};
mockedApi.post.mockResolvedValueOnce({ data: messagePresignResponse });
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [messagePresignResponse.key], total: 1 },
});
const result = await uploadFile('message', mockFile);
expect(result.key).toBe('messages/uuid.jpg');
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({
uploadType: 'message',
}));
}); });
}); });
describe('uploadFiles', () => { describe('uploadFiles', () => {
it('should return empty array for empty files array', async () => { const mockFiles = [
const result = await uploadFiles('item', []);
expect(result).toEqual([]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('should call getPresignedUrls with correct parameters', async () => {
const files = [
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }), new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['test2'], 'photo2.png', { type: 'image/png' }), new File(['test2'], 'photo2.png', { type: 'image/png' }),
]; ];
const presignResponses: PresignedUrlResponse[] = [ const presignResponses: PresignedUrlResponse[] = [
{ {
uploadUrl: 'https://presigned1.s3.amazonaws.com', uploadUrl: 'https://presigned1.s3.amazonaws.com/items/uuid1.jpg',
key: 'items/uuid1.jpg', key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
{ {
uploadUrl: 'https://presigned2.s3.amazonaws.com', uploadUrl: 'https://presigned2.s3.amazonaws.com/items/uuid2.png',
key: 'items/uuid2.png', key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
]; ];
it('should return empty array for empty files array', async () => {
const result = await uploadFiles('item', []);
expect(result).toEqual([]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('should complete full batch upload flow successfully', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } }); mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
await getPresignedUrls('item', files); const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
});
expect(result[1]).toEqual({
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
});
// Verify batch presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', { expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', {
uploadType: 'item', uploadType: 'item',
files: [ files: [
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: files[0].size }, { contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: mockFiles[0].size },
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: files[1].size }, { contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size },
], ],
}); });
// Verify confirm was called with all keys
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
keys: ['items/uuid1.jpg', 'items/uuid2.png'],
});
});
it('should filter out unconfirmed uploads', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
// Only first file confirmed
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: ['items/uuid1.jpg'],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
// Only confirmed uploads should be returned
expect(result).toHaveLength(1);
expect(result[0].key).toBe('items/uuid1.jpg');
// Should log warning about failed verification
expect(consoleSpy).toHaveBeenCalledWith('1 uploads failed verification');
consoleSpy.mockRestore();
});
it('should handle all uploads failing verification', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: [],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith('2 uploads failed verification');
consoleSpy.mockRestore();
});
it('should upload all files in parallel', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
await uploadFiles('item', mockFiles);
// Should have created 2 XHR instances for parallel uploads
expect(MockXMLHttpRequest.instances.length).toBe(2);
});
it('should work with different upload types', async () => {
const forumResponses = presignResponses.map((r) => ({
...r,
key: r.key.replace('items/', 'forum/'),
publicUrl: r.publicUrl.replace('items/', 'forum/'),
}));
mockedApi.post.mockResolvedValueOnce({ data: { uploads: forumResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: forumResponses.map((p) => p.key),
total: 2,
},
});
const result = await uploadFiles('forum', mockFiles);
expect(result[0].key).toBe('forum/uuid1.jpg');
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', expect.objectContaining({
uploadType: 'forum',
}));
}); });
}); });

View File

@@ -74,7 +74,7 @@ const AlphaGate: React.FC = () => {
<div className="card-body p-5"> <div className="card-body p-5">
<div className="text-center mb-4"> <div className="text-center mb-4">
<h1 className="h2 mb-3" style={{ color: "#667eea" }}> <h1 className="h2 mb-3" style={{ color: "#667eea" }}>
Community Rentals Village Share
</h1> </h1>
</div> </div>
@@ -105,7 +105,7 @@ const AlphaGate: React.FC = () => {
Currently in Alpha Testing! Currently in Alpha Testing!
</h6> </h6>
<p className="text-muted small mb-0 text-center"> <p className="text-muted small mb-0 text-center">
You're among the first to try Community Rentals! Help us create You're among the first to try Village Share! Help us create
something special by sharing your thoughts as we build this something special by sharing your thoughts as we build this
together. together.
</p> </p>
@@ -115,7 +115,7 @@ const AlphaGate: React.FC = () => {
<p className="text-center text-muted small mb-0"> <p className="text-center text-muted small mb-0">
Have an alpha code? Get started below! <br></br> Want to join?{" "} Have an alpha code? Get started below! <br></br> Want to join?{" "}
<a <a
href="mailto:support@communityrentals.app?subject=Alpha Access Request" href="mailto:support@villageshare.app?subject=Alpha Access Request"
className="text-decoration-none" className="text-decoration-none"
style={{ color: "#667eea" }} style={{ color: "#667eea" }}
> >

View File

@@ -181,7 +181,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div> </div>
<div className="modal-body px-4 pb-4"> <div className="modal-body px-4 pb-4">
<h4 className="text-center mb-2"> <h4 className="text-center mb-2">
Welcome to CommunityRentals.App Welcome to Village Share
</h4> </h4>
{error && ( {error && (
@@ -307,7 +307,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div> </div>
<p className="text-center text-muted small mt-4 mb-0"> <p className="text-center text-muted small mt-4 mb-0">
By continuing, you agree to CommunityRentals.App's{" "} By continuing, you agree to Village Share's{" "}
<a href="/terms" className="text-decoration-none"> <a href="/terms" className="text-decoration-none">
Terms of Service Terms of Service
</a>{" "} </a>{" "}

View File

@@ -84,14 +84,14 @@ const FeedbackModal: React.FC<FeedbackModalProps> = ({ show, onClose }) => {
<h6 className="alert-heading">Thank you!</h6> <h6 className="alert-heading">Thank you!</h6>
<p className="mb-0"> <p className="mb-0">
Your feedback has been submitted successfully! We appreciate Your feedback has been submitted successfully! We appreciate
you making Community Rentals better! you making Village Share better!
</p> </p>
</div> </div>
) : ( ) : (
<> <>
<p className="text-muted mb-3"> <p className="text-muted mb-3">
Share your thoughts, report bugs, or suggest improvements. Share your thoughts, report bugs, or suggest improvements.
Your feedback helps us make RentAll better for everyone! Your feedback helps us make Village Share better for everyone!
</p> </p>
{error && ( {error && (

View File

@@ -47,7 +47,7 @@ const Footer: React.FC = () => {
{/* Copyright */} {/* Copyright */}
<p className="small text-white-50 mb-0"> <p className="small text-white-50 mb-0">
© 2025 CommunityRentals.App. All rights reserved. © 2025 Village Share. All rights reserved.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -126,7 +126,7 @@ const Navbar: React.FC = () => {
<div className="container-fluid" style={{ maxWidth: "1800px" }}> <div className="container-fluid" style={{ maxWidth: "1800px" }}>
<Link className="navbar-brand fw-bold" to="/"> <Link className="navbar-brand fw-bold" to="/">
<i className="bi bi-box-seam me-2"></i> <i className="bi bi-box-seam me-2"></i>
CommunityRentals.App Village Share
</Link> </Link>
<button <button
className="navbar-toggler" className="navbar-toggler"

View File

@@ -91,7 +91,7 @@ const Home: React.FC = () => {
<div className="row align-items-center"> <div className="row align-items-center">
<div className="col-lg-6"> <div className="col-lg-6">
<h2 className="mb-4" style={{ color: "white" }}> <h2 className="mb-4" style={{ color: "white" }}>
Why Choose CommunityRentals.App? Why Choose Village Share?
</h2> </h2>
<div className="d-flex mb-3"> <div className="d-flex mb-3">
<i <i