570 lines
20 KiB
JavaScript
570 lines
20 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
}); |