Compare commits
4 Commits
4b4584bc0f
...
07e5a2a320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e5a2a320 | ||
|
|
955517347e | ||
|
|
bd1bd5014c | ||
|
|
4e0a4ef019 |
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
121
backend/routes/health.js
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>`;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 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>
|
||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© {{year}} RentAll. All rights reserved.</p>
|
<p>© {{year}} Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© {{year}} RentAll. All rights reserved.</p>
|
<p>© {{year}} Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
team.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have questions, please contact
|
||||||
|
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
moderation team.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have questions, please contact
|
||||||
|
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
team.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have questions, please contact
|
||||||
|
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>© 2024 RentAll. All rights reserved.</p>
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
13
backend/tests/integration-setup.js
Normal file
13
backend/tests/integration-setup.js
Normal 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';
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
328
backend/tests/unit/routes/conditionChecks.test.js
Normal file
328
backend/tests/unit/routes/conditionChecks.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
813
backend/tests/unit/routes/forum.test.js
Normal file
813
backend/tests/unit/routes/forum.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
107
backend/tests/unit/routes/health.test.js
Normal file
107
backend/tests/unit/routes/health.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']],
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
352
backend/tests/unit/services/UserService.test.js
Normal file
352
backend/tests/unit/services/UserService.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
425
frontend/src/__tests__/components/AuthModal.test.tsx
Normal file
425
frontend/src/__tests__/components/AuthModal.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
frontend/src/__tests__/components/Navbar.test.tsx
Normal file
268
frontend/src/__tests__/components/Navbar.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>{" "}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user