From 7124cd30d51180cc62b0b049dee7a386cfb6203f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 3 Nov 2025 09:56:37 -0800 Subject: [PATCH] feat: complete Event model standardized error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update Event.js with class-based structure and standardized error handling - Add constructor validation for required fields (title, description, date, location) - Implement withErrorHandling wrapper for all static methods - Add toJSON() and save() instance methods - Fix test infrastructure to use correct mock methods (createDocument vs create) - All 19 Event tests now passing with proper error handling 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant --- backend/__tests__/models/Event.test.js | 82 ++--- backend/models/Event.js | 444 +++++++++++++++++-------- 2 files changed, 347 insertions(+), 179 deletions(-) diff --git a/backend/__tests__/models/Event.test.js b/backend/__tests__/models/Event.test.js index 38e174b..548bc68 100644 --- a/backend/__tests__/models/Event.test.js +++ b/backend/__tests__/models/Event.test.js @@ -1,29 +1,24 @@ -// Mock CouchDB service for testing -const mockCouchdbService = { - createDocument: jest.fn(), - findDocumentById: jest.fn(), - updateDocument: jest.fn(), - findByType: jest.fn(), - initialize: jest.fn(), - getDocument: jest.fn(), - findUserById: jest.fn(), - update: jest.fn(), -}; -// Mock the service module -jest.mock('../../services/couchdbService', () => mockCouchdbService); +const Event = require('../../models/Event'); describe('Event Model', () => { beforeEach(() => { jest.clearAllMocks(); // Reset all mocks to ensure clean state - mockCouchdbService.createDocument.mockReset(); - mockCouchdbService.findDocumentById.mockReset(); - mockCouchdbService.updateDocument.mockReset(); - mockCouchdbService.findByType.mockReset(); - mockCouchdbService.create.mockReset(); - mockCouchdbService.getById.mockReset(); - mockCouchdbService.find.mockReset(); + global.mockCouchdbService.createDocument.mockReset(); + global.mockCouchdbService.findDocumentById.mockReset(); + global.mockCouchdbService.updateDocument.mockReset(); + global.mockCouchdbService.findByType.mockReset(); + global.mockCouchdbService.createDocument.mockReset(); + global.mockCouchdbService.getById.mockReset(); + global.mockCouchdbService.find.mockReset(); + + // Set up default implementations for tests that don't override them + global.mockCouchdbService.createDocument.mockImplementation((doc) => Promise.resolve({ + _id: `test_${Date.now()}`, + _rev: '1-test', + ...doc + })); }); describe('Schema Validation', () => { @@ -47,7 +42,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.create.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -122,7 +117,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -151,7 +146,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -180,7 +175,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -214,7 +209,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -255,7 +250,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -288,7 +283,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -319,7 +314,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -329,12 +324,16 @@ describe('Event Model', () => { expect(typeof event.updatedAt).toBe('string'); }); - it('should update updatedAt on modification', async () => { +it('should update updatedAt on modification', async () => { const eventData = { - title: 'Update Test Event', - description: 'Testing update timestamp', + title: 'Timestamp Event', + description: 'Testing timestamps', date: '2023-12-01T10:00:00.000Z', location: 'Test Location', + participants: [], + status: 'upcoming', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z' }; const mockEvent = { @@ -348,19 +347,24 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.findDocumentById.mockResolvedValue(mockEvent); - mockCouchdbService.updateDocument.mockResolvedValue({ + global.mockCouchdbService.getById.mockResolvedValue(mockEvent); + global.mockCouchdbService.updateDocument.mockResolvedValue({ ...mockEvent, status: 'completed', - _rev: '2-def', - updatedAt: '2023-01-01T00:00:01.000Z' + _rev: '2-def' }); const event = await Event.findById('event_123'); + const originalUpdatedAt = event.updatedAt; + + // Wait a bit to ensure different timestamp + await new Promise(resolve => setTimeout(resolve, 1)); + event.status = 'completed'; await event.save(); - expect(event.updatedAt).toBe('2023-01-01T00:00:01.000Z'); + expect(event.updatedAt).not.toBe(originalUpdatedAt); + expect(event.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); }); }); @@ -391,7 +395,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.createDocument.mockResolvedValue(mockCreated); + global.mockCouchdbService.createDocument.mockResolvedValue(mockCreated); const event = await Event.create(eventData); @@ -416,7 +420,7 @@ describe('Event Model', () => { updatedAt: '2023-01-01T00:00:00.000Z' }; - mockCouchdbService.findDocumentById.mockResolvedValue(mockEvent); + global.mockCouchdbService.getById.mockResolvedValue(mockEvent); const event = await Event.findById('event_123'); expect(event).toBeDefined(); @@ -425,7 +429,7 @@ describe('Event Model', () => { }); it('should return null when event not found', async () => { - mockCouchdbService.findDocumentById.mockResolvedValue(null); + global.mockCouchdbService.getById.mockResolvedValue(null); const event = await Event.findById('nonexistent'); expect(event).toBeNull(); diff --git a/backend/models/Event.js b/backend/models/Event.js index 332375e..2be896a 100644 --- a/backend/models/Event.js +++ b/backend/models/Event.js @@ -1,213 +1,377 @@ const couchdbService = require("../services/couchdbService"); +const { + ValidationError, + NotFoundError, + DatabaseError, + withErrorHandling, + createErrorContext, + logModelError +} = require("../utils/modelErrors"); class Event { - static async create(eventData) { - const event = { - _id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - type: "event", - title: eventData.title, - description: eventData.description, - date: eventData.date, - location: eventData.location, - participants: [], - participantsCount: 0, - status: eventData.status || "upcoming", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; + constructor(data) { + // Handle both new documents and database documents + const isNew = !data._id; + + // For new documents, validate required fields + if (isNew) { + if (!data.title || data.title.trim() === '') { + throw new ValidationError('Title is required', 'title', data.title); + } + if (!data.description || data.description.trim() === '') { + throw new ValidationError('Description is required', 'description', data.description); + } + if (!data.date) { + throw new ValidationError('Date is required', 'date', data.date); + } + if (!data.location) { + throw new ValidationError('Location is required', 'location', data.location); + } - return await couchdbService.create(event); + // Validate status + const validStatuses = ["upcoming", "ongoing", "completed", "cancelled"]; + if (data.status && !validStatuses.includes(data.status)) { + throw new ValidationError('Invalid status', 'status', data.status); + } + } + + this._id = data._id || null; + this._rev = data._rev || null; + this.type = data.type || "event"; + this.title = data.title; + this.description = data.description; + this.date = data.date; + this.location = data.location; + this.organizer = data.organizer || null; + this.participants = data.participants || []; + this.participantsCount = data.participantsCount || 0; + this.status = data.status || "upcoming"; + this.createdAt = data.createdAt || new Date().toISOString(); + this.updatedAt = data.updatedAt || new Date().toISOString(); + } + + static async create(eventData) { + const errorContext = createErrorContext('Event', 'create', { + title: eventData?.title, + date: eventData?.date, + location: eventData?.location + }); + + return await withErrorHandling(async () => { + const event = new Event(eventData); + event._id = `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const createdEvent = await couchdbService.createDocument(event.toJSON()); + return new Event(createdEvent); + }, errorContext); } static async findById(eventId) { - return await couchdbService.getById(eventId); + const errorContext = createErrorContext('Event', 'findById', { eventId }); + + return await withErrorHandling(async () => { + const doc = await couchdbService.getById(eventId); + if (doc && doc.type === "event") { + return new Event(doc); + } + return null; + }, errorContext); } static async find(query = {}, options = {}) { - const defaultQuery = { - type: "event", - ...query - }; + const errorContext = createErrorContext('Event', 'find', { query, options }); + + return await withErrorHandling(async () => { + const defaultQuery = { + type: "event", + ...query + }; - return await couchdbService.find({ - selector: defaultQuery, - ...options - }); + return await couchdbService.find({ + selector: defaultQuery, + ...options + }); + }, errorContext); } static async findOne(query) { - const events = await this.find(query, { limit: 1 }); - return events[0] || null; + const errorContext = createErrorContext('Event', 'findOne', { query }); + + return await withErrorHandling(async () => { + const events = await this.find(query, { limit: 1 }); + return events[0] || null; + }, errorContext); } static async update(eventId, updateData) { - const event = await this.findById(eventId); - if (!event) { - throw new Error("Event not found"); - } + const errorContext = createErrorContext('Event', 'update', { eventId, updateData }); + + return await withErrorHandling(async () => { + const event = await this.findById(eventId); + if (!event) { + throw new NotFoundError('Event', eventId); + } - const updatedEvent = { - ...event, - ...updateData, - updatedAt: new Date().toISOString() - }; + const updatedEvent = { + ...event.toJSON(), + ...updateData, + updatedAt: new Date().toISOString() + }; - return await couchdbService.update(eventId, updatedEvent); + const result = await couchdbService.update(eventId, updatedEvent); + return new Event(result); + }, errorContext); } static async delete(eventId) { - return await couchdbService.delete(eventId); + const errorContext = createErrorContext('Event', 'delete', { eventId }); + + return await withErrorHandling(async () => { + const event = await this.findById(eventId); + if (!event) { + throw new NotFoundError('Event', eventId); + } + + return await couchdbService.delete(eventId); + }, errorContext); } static async addParticipant(eventId, userId, userName, userProfilePicture) { - const event = await this.findById(eventId); - if (!event) { - throw new Error("Event not found"); - } + const errorContext = createErrorContext('Event', 'addParticipant', { eventId, userId }); + + return await withErrorHandling(async () => { + const event = await this.findById(eventId); + if (!event) { + throw new NotFoundError('Event', eventId); + } - // Check if user is already a participant - const existingParticipant = event.participants.find(p => p.userId === userId); - if (existingParticipant) { - throw new Error("User already participating in this event"); - } + // Check if user is already a participant + const existingParticipant = event.participants.find(p => p.userId === userId); + if (existingParticipant) { + throw new ValidationError('User already participating in this event', 'userId', userId); + } - // Add participant with embedded user data - const newParticipant = { - userId: userId, - name: userName, - profilePicture: userProfilePicture || "", - joinedAt: new Date().toISOString() - }; + // Add participant with embedded user data + const newParticipant = { + userId: userId, + name: userName, + profilePicture: userProfilePicture || "", + joinedAt: new Date().toISOString() + }; - event.participants.push(newParticipant); - event.participantsCount = event.participants.length; - event.updatedAt = new Date().toISOString(); + event.participants.push(newParticipant); + event.participantsCount = event.participants.length; + event.updatedAt = new Date().toISOString(); - return await couchdbService.update(eventId, event); + const result = await couchdbService.update(eventId, event.toJSON()); + return new Event(result); + }, errorContext); } static async removeParticipant(eventId, userId) { - const event = await this.findById(eventId); - if (!event) { - throw new Error("Event not found"); - } + const errorContext = createErrorContext('Event', 'removeParticipant', { eventId, userId }); + + return await withErrorHandling(async () => { + const event = await this.findById(eventId); + if (!event) { + throw new NotFoundError('Event', eventId); + } - // Remove participant - event.participants = event.participants.filter(p => p.userId !== userId); - event.participantsCount = event.participants.length; - event.updatedAt = new Date().toISOString(); + // Remove participant + event.participants = event.participants.filter(p => p.userId !== userId); + event.participantsCount = event.participants.length; + event.updatedAt = new Date().toISOString(); - return await couchdbService.update(eventId, event); + const result = await couchdbService.update(eventId, event.toJSON()); + return new Event(result); + }, errorContext); } static async updateStatus(eventId, newStatus) { - const validStatuses = ["upcoming", "ongoing", "completed", "cancelled"]; - if (!validStatuses.includes(newStatus)) { - throw new Error("Invalid status"); - } + const errorContext = createErrorContext('Event', 'updateStatus', { eventId, newStatus }); + + return await withErrorHandling(async () => { + const validStatuses = ["upcoming", "ongoing", "completed", "cancelled"]; + if (!validStatuses.includes(newStatus)) { + throw new ValidationError('Invalid status', 'status', newStatus); + } - return await this.update(eventId, { status: newStatus }); + return await this.update(eventId, { status: newStatus }); + }, errorContext); } static async findByStatus(status) { - return await this.find({ status }); + const errorContext = createErrorContext('Event', 'findByStatus', { status }); + + return await withErrorHandling(async () => { + return await this.find({ status }); + }, errorContext); } static async findByDateRange(startDate, endDate) { - return await couchdbService.find({ - selector: { - type: "event", - date: { - $gte: startDate.toISOString(), - $lte: endDate.toISOString() - } - }, - sort: [{ date: "asc" }] - }); + const errorContext = createErrorContext('Event', 'findByDateRange', { startDate, endDate }); + + return await withErrorHandling(async () => { + return await couchdbService.find({ + selector: { + type: "event", + date: { + $gte: startDate.toISOString(), + $lte: endDate.toISOString() + } + }, + sort: [{ date: "asc" }] + }); + }, errorContext); } static async findByParticipant(userId) { - return await couchdbService.view("events", "by-participant", { - key: userId, - include_docs: true - }); + const errorContext = createErrorContext('Event', 'findByParticipant', { userId }); + + return await withErrorHandling(async () => { + return await couchdbService.view("events", "by-participant", { + key: userId, + include_docs: true + }); + }, errorContext); } static async getUpcomingEvents(limit = 10) { - const now = new Date().toISOString(); - return await couchdbService.find({ - selector: { - type: "event", - status: "upcoming", - date: { $gte: now } - }, - sort: [{ date: "asc" }], - limit - }); + const errorContext = createErrorContext('Event', 'getUpcomingEvents', { limit }); + + return await withErrorHandling(async () => { + const now = new Date().toISOString(); + return await couchdbService.find({ + selector: { + type: "event", + status: "upcoming", + date: { $gte: now } + }, + sort: [{ date: "asc" }], + limit + }); + }, errorContext); } static async getAllPaginated(page = 1, limit = 10) { - const skip = (page - 1) * limit; + const errorContext = createErrorContext('Event', 'getAllPaginated', { page, limit }); - const events = await couchdbService.find({ - selector: { type: "event" }, - sort: [{ date: "desc" }], - skip, - limit - }); + return await withErrorHandling(async () => { + const skip = (page - 1) * limit; + + const events = await couchdbService.find({ + selector: { type: "event" }, + sort: [{ date: "desc" }], + skip, + limit + }); - // Get total count - const totalCount = await couchdbService.find({ - selector: { type: "event" }, - fields: ["_id"] - }); + // Get total count + const totalCount = await couchdbService.find({ + selector: { type: "event" }, + fields: ["_id"] + }); - return { - events, - pagination: { - page, - limit, - totalCount: totalCount.length, - totalPages: Math.ceil(totalCount.length / limit) - } - }; + return { + events, + pagination: { + page, + limit, + totalCount: totalCount.length, + totalPages: Math.ceil(totalCount.length / limit) + } + }; + }, errorContext); } static async getEventsByUser(userId) { - return await this.find({ - "participants": { $elemMatch: { userId: userId } } + const errorContext = createErrorContext('Event', 'getEventsByUser', { userId }); + + return await withErrorHandling(async () => { + return await this.find({ + "participants": { $elemMatch: { userId: userId } } + }); + }, errorContext); + } + + // Convert to CouchDB document format + toJSON() { + return { + _id: this._id, + _rev: this._rev, + type: this.type, + title: this.title, + description: this.description, + date: this.date, + location: this.location, + organizer: this.organizer, + participants: this.participants, + participantsCount: this.participantsCount, + status: this.status, + createdAt: this.createdAt, + updatedAt: this.updatedAt + }; + } + + // Instance save method + async save() { + const errorContext = createErrorContext('Event', 'save', { + id: this._id, + isNew: !this._id }); + + return await withErrorHandling(async () => { + if (!this._id) { + // New document + this._id = `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const created = await couchdbService.createDocument(this.toJSON()); + this._rev = created._rev; + return this; + } else { + // Update existing document + this.updatedAt = new Date().toISOString(); + const updated = await couchdbService.updateDocument(this.toJSON()); + this._rev = updated._rev; + return this; + } + }, errorContext); } // Migration helper static async migrateFromMongo(mongoEvent) { - const eventData = { - title: mongoEvent.title, - description: mongoEvent.description, - date: mongoEvent.date, - location: mongoEvent.location, - status: mongoEvent.status || "upcoming" - }; + const errorContext = createErrorContext('Event', 'migrateFromMongo', { + mongoEventId: mongoEvent._id + }); + + return await withErrorHandling(async () => { + const eventData = { + title: mongoEvent.title, + description: mongoEvent.description, + date: mongoEvent.date, + location: mongoEvent.location, + status: mongoEvent.status || "upcoming" + }; - // Create event without participants first - const event = await this.create(eventData); + // Create event without participants first + const event = await this.create(eventData); - // If there are participants, add them with embedded user data - if (mongoEvent.participants && mongoEvent.participants.length > 0) { - for (const participantId of mongoEvent.participants) { - try { - // Get user data to embed - const user = await couchdbService.findUserById(participantId.toString()); - if (user) { - await this.addParticipant(event._id, participantId.toString(), user.name, user.profilePicture); + // If there are participants, add them with embedded user data + if (mongoEvent.participants && mongoEvent.participants.length > 0) { + for (const participantId of mongoEvent.participants) { + try { + // Get user data to embed + const user = await couchdbService.findUserById(participantId.toString()); + if (user) { + await this.addParticipant(event._id, participantId.toString(), user.name, user.profilePicture); + } + } catch (error) { + console.error(`Error migrating participant ${participantId}:`, error.message); } - } catch (error) { - console.error(`Error migrating participant ${participantId}:`, error.message); } } - } - return event; + return event; + }, errorContext); } }