- Enhanced in-memory couchdbService mock with better document tracking - Added global test reset hook to clear state between tests - Disabled cache in test environment for predictable results - Normalized model find() results to always return arrays - Enhanced couchdbService APIs (find, updateDocument) with better return values - Added RSVP persistence fallback in events route - Improved gamificationService to handle non-array find() results - Mirror profilePicture/avatar fields in User model These changes improve test reliability and should increase pass rate from ~142/228 baseline. 🤖 Generated with Claude Co-Authored-By: Claude <noreply@anthropic.com>
225 lines
8.2 KiB
JavaScript
225 lines
8.2 KiB
JavaScript
const bcrypt = require("bcryptjs");
|
|
const couchdbService = require("../services/couchdbService");
|
|
const {
|
|
ValidationError,
|
|
withErrorHandling,
|
|
createErrorContext,
|
|
} = require("../utils/modelErrors");
|
|
|
|
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
|
|
|
|
class User {
|
|
constructor(data) { // Validate required fields for new user creation
|
|
if (!data._id) { // Only for new users
|
|
if (!data.name) { throw new ValidationError("Name is required", "name", data.name); }
|
|
if (!data.email) { throw new ValidationError("Email is required", "email", data.email); }
|
|
if (!data.password) { throw new ValidationError("Password is required", "password", data.password); }
|
|
}
|
|
|
|
this._id = data._id || null;
|
|
this._rev = data._rev || null;
|
|
this.type = "user";
|
|
this.name = data.name;
|
|
this.email = data.email;
|
|
this.password = data.password;
|
|
|
|
// --- Profile Information ---
|
|
this.avatar = data.avatar || null;
|
|
this.profilePicture = data.profilePicture || data.avatar || null;
|
|
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
|
|
this.bio = data.bio || "";
|
|
if (this.bio.length > 500) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
|
|
this.location = data.location || "";
|
|
this.website = data.website || "";
|
|
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
|
|
|
|
// --- Social Links ---
|
|
this.social = data.social || { twitter: "", github: "", linkedin: "" };
|
|
if (this.social.twitter && !URL_REGEX.test(this.social.twitter)) { throw new ValidationError("Invalid Twitter URL.", "social.twitter", this.social.twitter); }
|
|
if (this.social.github && !URL_REGEX.test(this.social.github)) { throw new ValidationError("Invalid Github URL.", "social.github", this.social.github); }
|
|
if (this.social.linkedin && !URL_REGEX.test(this.social.linkedin)) { throw new ValidationError("Invalid LinkedIn URL.", "social.linkedin", this.social.linkedin); }
|
|
|
|
// --- Settings & Preferences ---
|
|
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
|
|
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private'.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
|
|
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
|
|
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be 'light' or 'dark'.", "preferences.theme", this.preferences.theme); }
|
|
|
|
|
|
// --- Gamification & App Data ---
|
|
this.isPremium = data.isPremium || false;
|
|
this.points = Math.max(0, data.points || 0);
|
|
this.adoptedStreets = data.adoptedStreets || [];
|
|
this.completedTasks = data.completedTasks || [];
|
|
this.posts = data.posts || [];
|
|
this.events = data.events || [];
|
|
this.earnedBadges = data.earnedBadges || [];
|
|
this.stats = data.stats || {
|
|
streetsAdopted: 0,
|
|
tasksCompleted: 0,
|
|
postsCreated: 0,
|
|
eventsParticipated: 0,
|
|
badgesEarned: 0,
|
|
};
|
|
|
|
this.createdAt = data.createdAt || new Date().toISOString();
|
|
this.updatedAt = data.updatedAt || new Date().toISOString();
|
|
}
|
|
|
|
// ... (static methods remain the same)
|
|
// Static methods for MongoDB compatibility
|
|
static async findOne(query) {
|
|
const errorContext = createErrorContext('User', 'findOne', { query });
|
|
|
|
return await withErrorHandling(async () => {
|
|
let user;
|
|
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
|
|
else if (query._id) { user = await couchdbService.findUserById(query._id); }
|
|
else { // Generic query fallback
|
|
const docs = await couchdbService.find({
|
|
selector: { type: "user", ...query },
|
|
limit: 1
|
|
});
|
|
user = docs[0] || null;
|
|
}
|
|
return user ? new User(user) : null;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findById(id) {
|
|
const errorContext = createErrorContext('User', 'findById', { id });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = await couchdbService.findUserById(id);
|
|
return user ? new User(user) : null;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByIdAndUpdate(id, update, options = {}) {
|
|
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = await couchdbService.findUserById(id);
|
|
if (!user) return null;
|
|
|
|
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
|
|
const saved = await couchdbService.update(id, updatedUser);
|
|
|
|
if (options.new) { return new User(saved); }
|
|
return new User(user);
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByIdAndDelete(id) {
|
|
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = await couchdbService.findUserById(id);
|
|
if (!user) return null;
|
|
|
|
await couchdbService.delete(id);
|
|
return user;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async find(query = {}) {
|
|
const errorContext = createErrorContext('User', 'find', { query });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const selector = { type: "user", ...query };
|
|
const users = await couchdbService.find({ selector });
|
|
return users.map(u => new User(u));
|
|
}, errorContext);
|
|
}
|
|
|
|
static async create(userData) {
|
|
const errorContext = createErrorContext('User', 'create', { userData: { ...userData, password: '[REDACTED]' } });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = new User(userData);
|
|
|
|
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
|
|
|
|
if (!user._id) { user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }
|
|
|
|
const created = await couchdbService.createDocument(user.toJSON());
|
|
return new User(created);
|
|
}, errorContext);
|
|
}
|
|
|
|
async save() {
|
|
const errorContext = createErrorContext('User', 'save', {
|
|
id: this._id,
|
|
email: this.email,
|
|
isNew: !this._id
|
|
});
|
|
|
|
return await withErrorHandling(async () => {
|
|
this.updatedAt = new Date().toISOString();
|
|
if (!this._id) {
|
|
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
if (this.password && !this.password.startsWith('$2')) {
|
|
const salt = await bcrypt.genSalt(10);
|
|
this.password = await bcrypt.hash(this.password, salt);
|
|
}
|
|
const created = await couchdbService.createDocument(this.toJSON());
|
|
this._rev = created._rev;
|
|
return this;
|
|
} else {
|
|
const updated = await couchdbService.updateDocument(this.toJSON());
|
|
this._rev = updated._rev;
|
|
return this;
|
|
}
|
|
}, errorContext);
|
|
}
|
|
|
|
async comparePassword(candidatePassword) {
|
|
const errorContext = createErrorContext('User', 'comparePassword', {
|
|
id: this._id,
|
|
email: this.email
|
|
});
|
|
|
|
return await withErrorHandling(async () => {
|
|
return await bcrypt.compare(candidatePassword, this.password);
|
|
}, errorContext);
|
|
}
|
|
|
|
toSafeObject() {
|
|
const obj = this.toJSON();
|
|
delete obj.password;
|
|
return obj;
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
_id: this._id,
|
|
_rev: this._rev,
|
|
type: this.type,
|
|
name: this.name,
|
|
email: this.email,
|
|
password: this.password,
|
|
avatar: this.avatar,
|
|
profilePicture: this.profilePicture,
|
|
cloudinaryPublicId: this.cloudinaryPublicId,
|
|
bio: this.bio,
|
|
location: this.location,
|
|
website: this.website,
|
|
social: this.social,
|
|
privacySettings: this.privacySettings,
|
|
preferences: this.preferences,
|
|
isPremium: this.isPremium,
|
|
points: this.points,
|
|
adoptedStreets: this.adoptedStreets,
|
|
completedTasks: this.completedTasks,
|
|
posts: this.posts,
|
|
events: this.events,
|
|
earnedBadges: this.earnedBadges,
|
|
stats: this.stats,
|
|
createdAt: this.createdAt,
|
|
updatedAt: this.updatedAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = User;
|