const Bookmark = require('../../src/models/Bookmark'); const dbConnection = require('../../src/database/connection'); // Mock dependencies jest.mock('../../src/database/connection'); describe('Bookmark Model Unit Tests', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('Validation', () => { describe('validateBookmark', () => { it('should validate correct bookmark data', () => { const validBookmark = { title: 'Test Bookmark', url: 'https://example.com', folder: 'Test Folder', status: 'valid' }; const result = Bookmark.validateBookmark(validBookmark); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); it('should reject bookmark without title', () => { const invalidBookmark = { url: 'https://example.com' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Title is required'); }); it('should reject bookmark with empty title', () => { const invalidBookmark = { title: ' ', url: 'https://example.com' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Title is required'); }); it('should reject bookmark with title too long', () => { const invalidBookmark = { title: 'a'.repeat(501), url: 'https://example.com' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Title must be 500 characters or less'); }); it('should reject bookmark without URL', () => { const invalidBookmark = { title: 'Test Bookmark' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('URL is required'); }); it('should reject bookmark with invalid URL', () => { const invalidBookmark = { title: 'Test Bookmark', url: 'not-a-valid-url' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Invalid URL format'); }); it('should reject bookmark with folder name too long', () => { const invalidBookmark = { title: 'Test Bookmark', url: 'https://example.com', folder: 'a'.repeat(256) }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Folder name must be 255 characters or less'); }); it('should reject bookmark with invalid status', () => { const invalidBookmark = { title: 'Test Bookmark', url: 'https://example.com', status: 'invalid-status' }; const result = Bookmark.validateBookmark(invalidBookmark); expect(result.isValid).toBe(false); expect(result.errors).toContain('Invalid status value'); }); it('should accept valid status values', () => { const validStatuses = ['unknown', 'valid', 'invalid', 'testing', 'duplicate']; validStatuses.forEach(status => { const bookmark = { title: 'Test Bookmark', url: 'https://example.com', status }; const result = Bookmark.validateBookmark(bookmark); expect(result.isValid).toBe(true); }); }); }); }); describe('Database Operations', () => { describe('create', () => { it('should create a new bookmark successfully', async () => { const userId = 'user-123'; const bookmarkData = { title: 'Test Bookmark', url: 'https://example.com', folder: 'Test Folder', status: 'valid' }; const mockCreatedBookmark = { id: 'bookmark-123', user_id: userId, ...bookmarkData, created_at: new Date(), updated_at: new Date() }; dbConnection.query.mockResolvedValue({ rows: [mockCreatedBookmark] }); const result = await Bookmark.create(userId, bookmarkData); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO bookmarks'), [ userId, 'Test Bookmark', 'https://example.com', 'Test Folder', expect.any(Date), undefined, undefined, 'valid' ] ); expect(result).toBeInstanceOf(Bookmark); expect(result.title).toBe('Test Bookmark'); expect(result.user_id).toBe(userId); }); it('should reject invalid bookmark data', async () => { const userId = 'user-123'; const invalidBookmarkData = { title: '', url: 'invalid-url' }; await expect(Bookmark.create(userId, invalidBookmarkData)) .rejects.toThrow('Bookmark validation failed'); }); it('should trim whitespace from title, url, and folder', async () => { const userId = 'user-123'; const bookmarkData = { title: ' Test Bookmark ', url: ' https://example.com ', folder: ' Test Folder ' }; const mockCreatedBookmark = { id: 'bookmark-123', user_id: userId, title: 'Test Bookmark', url: 'https://example.com', folder: 'Test Folder' }; dbConnection.query.mockResolvedValue({ rows: [mockCreatedBookmark] }); await Bookmark.create(userId, bookmarkData); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO bookmarks'), expect.arrayContaining([ userId, 'Test Bookmark', 'https://example.com', 'Test Folder' ]) ); }); }); describe('findByUserId', () => { it('should find bookmarks by user ID with default options', async () => { const userId = 'user-123'; const mockBookmarks = [ { id: 'bookmark-1', user_id: userId, title: 'Bookmark 1' }, { id: 'bookmark-2', user_id: userId, title: 'Bookmark 2' } ]; dbConnection.query .mockResolvedValueOnce({ rows: [{ count: '2' }] }) // Count query .mockResolvedValueOnce({ rows: mockBookmarks }); // Data query const result = await Bookmark.findByUserId(userId); expect(result.bookmarks).toHaveLength(2); expect(result.pagination.totalCount).toBe(2); expect(result.pagination.page).toBe(1); expect(result.pagination.limit).toBe(50); }); it('should apply folder filter', async () => { const userId = 'user-123'; const options = { folder: 'Work' }; dbConnection.query .mockResolvedValueOnce({ rows: [{ count: '1' }] }) .mockResolvedValueOnce({ rows: [] }); await Bookmark.findByUserId(userId, options); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('folder = $2'), expect.arrayContaining([userId, 'Work']) ); }); it('should apply status filter', async () => { const userId = 'user-123'; const options = { status: 'valid' }; dbConnection.query .mockResolvedValueOnce({ rows: [{ count: '1' }] }) .mockResolvedValueOnce({ rows: [] }); await Bookmark.findByUserId(userId, options); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('status = $2'), expect.arrayContaining([userId, 'valid']) ); }); it('should apply search filter', async () => { const userId = 'user-123'; const options = { search: 'test' }; dbConnection.query .mockResolvedValueOnce({ rows: [{ count: '1' }] }) .mockResolvedValueOnce({ rows: [] }); await Bookmark.findByUserId(userId, options); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('title ILIKE $2 OR url ILIKE $2'), expect.arrayContaining([userId, '%test%']) ); }); it('should handle pagination correctly', async () => { const userId = 'user-123'; const options = { page: 2, limit: 10 }; dbConnection.query .mockResolvedValueOnce({ rows: [{ count: '25' }] }) .mockResolvedValueOnce({ rows: [] }); const result = await Bookmark.findByUserId(userId, options); expect(result.pagination.page).toBe(2); expect(result.pagination.limit).toBe(10); expect(result.pagination.totalCount).toBe(25); expect(result.pagination.totalPages).toBe(3); expect(result.pagination.hasNext).toBe(true); expect(result.pagination.hasPrev).toBe(true); }); }); describe('findByIdAndUserId', () => { it('should find bookmark by ID and user ID', async () => { const bookmarkId = 'bookmark-123'; const userId = 'user-123'; const mockBookmark = { id: bookmarkId, user_id: userId, title: 'Test Bookmark' }; dbConnection.query.mockResolvedValue({ rows: [mockBookmark] }); const result = await Bookmark.findByIdAndUserId(bookmarkId, userId); expect(dbConnection.query).toHaveBeenCalledWith( 'SELECT * FROM bookmarks WHERE id = $1 AND user_id = $2', [bookmarkId, userId] ); expect(result).toBeInstanceOf(Bookmark); expect(result.id).toBe(bookmarkId); }); it('should return null if bookmark not found', async () => { dbConnection.query.mockResolvedValue({ rows: [] }); const result = await Bookmark.findByIdAndUserId('nonexistent', 'user-123'); expect(result).toBeNull(); }); }); describe('bulkCreate', () => { it('should create multiple bookmarks', async () => { const userId = 'user-123'; const bookmarksData = [ { title: 'Bookmark 1', url: 'https://example1.com' }, { title: 'Bookmark 2', url: 'https://example2.com' } ]; const mockCreatedBookmarks = bookmarksData.map((data, index) => ({ id: `bookmark-${index + 1}`, user_id: userId, ...data })); dbConnection.query.mockResolvedValue({ rows: mockCreatedBookmarks }); const result = await Bookmark.bulkCreate(userId, bookmarksData); expect(result).toHaveLength(2); expect(result[0]).toBeInstanceOf(Bookmark); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO bookmarks'), expect.any(Array) ); }); it('should return empty array for empty input', async () => { const result = await Bookmark.bulkCreate('user-123', []); expect(result).toEqual([]); expect(dbConnection.query).not.toHaveBeenCalled(); }); it('should validate all bookmarks before creation', async () => { const userId = 'user-123'; const bookmarksData = [ { title: 'Valid Bookmark', url: 'https://example.com' }, { title: '', url: 'invalid-url' } // Invalid bookmark ]; await expect(Bookmark.bulkCreate(userId, bookmarksData)) .rejects.toThrow('Bookmark validation failed'); expect(dbConnection.query).not.toHaveBeenCalled(); }); }); }); describe('Instance Methods', () => { describe('update', () => { it('should update bookmark successfully', async () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123', title: 'Old Title', url: 'https://old-url.com' }); const updates = { title: 'New Title', url: 'https://new-url.com' }; dbConnection.query.mockResolvedValue({ rowCount: 1 }); const result = await bookmark.update(updates); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('UPDATE bookmarks SET'), expect.arrayContaining(['New Title', 'https://new-url.com', 'bookmark-123', 'user-123']) ); expect(result).toBe(true); expect(bookmark.title).toBe('New Title'); expect(bookmark.url).toBe('https://new-url.com'); }); it('should validate updates before applying', async () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123', title: 'Valid Title', url: 'https://valid-url.com' }); const invalidUpdates = { title: '', // Invalid title url: 'invalid-url' // Invalid URL }; await expect(bookmark.update(invalidUpdates)) .rejects.toThrow('Bookmark validation failed'); expect(dbConnection.query).not.toHaveBeenCalled(); }); it('should return false if no valid fields to update', async () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123' }); const result = await bookmark.update({}); expect(result).toBe(false); expect(dbConnection.query).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should delete bookmark successfully', async () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123' }); dbConnection.query.mockResolvedValue({ rowCount: 1 }); const result = await bookmark.delete(); expect(dbConnection.query).toHaveBeenCalledWith( 'DELETE FROM bookmarks WHERE id = $1 AND user_id = $2', ['bookmark-123', 'user-123'] ); expect(result).toBe(true); }); it('should return false if bookmark not found', async () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123' }); dbConnection.query.mockResolvedValue({ rowCount: 0 }); const result = await bookmark.delete(); expect(result).toBe(false); }); }); describe('toSafeObject', () => { it('should return safe bookmark object without user_id', () => { const bookmark = new Bookmark({ id: 'bookmark-123', user_id: 'user-123', title: 'Test Bookmark', url: 'https://example.com', folder: 'Test Folder', status: 'valid', created_at: new Date(), updated_at: new Date() }); const safeObject = bookmark.toSafeObject(); expect(safeObject).toHaveProperty('id'); expect(safeObject).toHaveProperty('title'); expect(safeObject).toHaveProperty('url'); expect(safeObject).toHaveProperty('folder'); expect(safeObject).toHaveProperty('status'); expect(safeObject).toHaveProperty('created_at'); expect(safeObject).toHaveProperty('updated_at'); expect(safeObject).not.toHaveProperty('user_id'); }); }); }); describe('Static Utility Methods', () => { describe('getFoldersByUserId', () => { it('should get folders with counts', async () => { const userId = 'user-123'; const mockFolders = [ { folder: 'Work', count: '5' }, { folder: 'Personal', count: '3' } ]; dbConnection.query.mockResolvedValue({ rows: mockFolders }); const result = await Bookmark.getFoldersByUserId(userId); expect(dbConnection.query).toHaveBeenCalledWith( expect.stringContaining('GROUP BY folder'), [userId] ); expect(result).toEqual(mockFolders); }); }); describe('getStatsByUserId', () => { it('should get bookmark statistics', async () => { const userId = 'user-123'; const mockStats = { total_bookmarks: '10', total_folders: '3', valid_bookmarks: '7', invalid_bookmarks: '2', duplicate_bookmarks: '1', unknown_bookmarks: '0' }; dbConnection.query.mockResolvedValue({ rows: [mockStats] }); const result = await Bookmark.getStatsByUserId(userId); expect(result).toEqual(mockStats); }); }); describe('deleteAllByUserId', () => { it('should delete all bookmarks for user', async () => { const userId = 'user-123'; dbConnection.query.mockResolvedValue({ rowCount: 5 }); const result = await Bookmark.deleteAllByUserId(userId); expect(dbConnection.query).toHaveBeenCalledWith( 'DELETE FROM bookmarks WHERE user_id = $1', [userId] ); expect(result).toBe(5); }); }); }); });