const request = require('supertest'); const app = require('../../app'); const { Task, Project, Area, Note, Tag, User } = require('../../models'); const { createTestUser } = require('../helpers/testUtils'); const moment = require('moment-timezone'); describe('Universal Search Routes', () => { let user, agent; beforeEach(async () => { user = await createTestUser({ email: 'search-test@example.com', }); // Create authenticated agent agent = request.agent(app); await agent.post('/api/login').send({ email: 'search-test@example.com', password: 'password123', }); }); describe('GET /api/search', () => { describe('Authentication', () => { it('should require authentication', async () => { const response = await request(app).get('/api/search'); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); }); }); describe('Basic Search', () => { beforeEach(async () => { // Create test data await Task.create({ user_id: user.id, name: 'Buy groceries', note: 'Milk, eggs, bread', priority: 1, status: 0, }); await Task.create({ user_id: user.id, name: 'Call dentist', note: 'Schedule appointment', priority: 2, status: 0, }); await Project.create({ user_id: user.id, name: 'Website redesign', description: 'Redesign company website', state: 'active', }); await Note.create({ user_id: user.id, title: 'Meeting notes', content: 'Discussed project timeline', }); }); it('should search across all entity types by default', async () => { const response = await agent .get('/api/search') .query({ q: '' }); expect(response.status).toBe(200); expect(response.body.results).toBeDefined(); expect(response.body.results.length).toBeGreaterThan(0); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Task')).toBe(true); expect(types.has('Project')).toBe(true); expect(types.has('Note')).toBe(true); }); it('should search tasks by name', async () => { const response = await agent.get('/api/search').query({ q: 'groceries', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks[0].name).toContain('groceries'); }); it('should search tasks by note content', async () => { const response = await agent.get('/api/search').query({ q: 'eggs', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); }); it('should search projects by name', async () => { const response = await agent.get('/api/search').query({ q: 'Website', }); expect(response.status).toBe(200); const projects = response.body.results.filter( (r) => r.type === 'Project' ); expect(projects.length).toBeGreaterThanOrEqual(1); expect(projects[0].name).toContain('Website'); }); it('should search notes by title', async () => { const response = await agent.get('/api/search').query({ q: 'Meeting', }); expect(response.status).toBe(200); const notes = response.body.results.filter( (r) => r.type === 'Note' ); expect(notes.length).toBeGreaterThanOrEqual(1); expect(notes[0].title).toContain('Meeting'); }); it('should be case-insensitive', async () => { const response = await agent.get('/api/search').query({ q: 'GROCERIES', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); }); it('should handle empty search query', async () => { const response = await agent .get('/api/search') .query({ q: '' }); expect(response.status).toBe(200); expect(response.body.results).toBeDefined(); expect(Array.isArray(response.body.results)).toBe(true); }); }); describe('Filter by Entity Type', () => { beforeEach(async () => { await Task.create({ user_id: user.id, name: 'Test task', status: 0, }); await Project.create({ user_id: user.id, name: 'Test project', state: 'active', }); await Note.create({ user_id: user.id, title: 'Test note', content: 'Content', }); await Area.create({ user_id: user.id, name: 'Test area', }); await Tag.create({ user_id: user.id, name: 'test-tag', }); }); it('should filter by Task only', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Task', }); expect(response.status).toBe(200); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Task')).toBe(true); expect(types.has('Project')).toBe(false); expect(types.has('Note')).toBe(false); }); it('should filter by multiple types', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Task,Project', }); expect(response.status).toBe(200); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Task')).toBe(true); expect(types.has('Project')).toBe(true); expect(types.has('Note')).toBe(false); expect(types.has('Area')).toBe(false); }); it('should filter by Note only', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Note', }); expect(response.status).toBe(200); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Note')).toBe(true); expect(types.has('Task')).toBe(false); }); it('should filter by Area only', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Area', }); expect(response.status).toBe(200); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Area')).toBe(true); expect(types.has('Task')).toBe(false); }); it('should filter by Tag only', async () => { const response = await agent.get('/api/search').query({ q: 'test', filters: 'Tag', }); expect(response.status).toBe(200); const types = new Set(response.body.results.map((r) => r.type)); expect(types.has('Tag')).toBe(true); expect(types.has('Task')).toBe(false); }); }); describe('Filter by Priority', () => { beforeEach(async () => { await Task.create({ user_id: user.id, name: 'Low priority task', priority: 0, status: 0, }); await Task.create({ user_id: user.id, name: 'Medium priority task', priority: 1, status: 0, }); await Task.create({ user_id: user.id, name: 'High priority task', priority: 2, status: 0, }); await Project.create({ user_id: user.id, name: 'High priority project', priority: 'high', state: 'active', }); }); it('should filter tasks by low priority', async () => { const response = await agent.get('/api/search').query({ priority: 'low', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.every((t) => t.priority === 0)).toBe(true); }); it('should filter tasks by medium priority', async () => { const response = await agent.get('/api/search').query({ priority: 'medium', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.every((t) => t.priority === 1)).toBe(true); }); it('should filter tasks by high priority', async () => { const response = await agent.get('/api/search').query({ priority: 'high', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.every((t) => t.priority === 2)).toBe(true); }); it('should filter projects by priority', async () => { const response = await agent.get('/api/search').query({ priority: 'high', filters: 'Project', }); expect(response.status).toBe(200); const projects = response.body.results.filter( (r) => r.type === 'Project' ); expect(projects.length).toBeGreaterThanOrEqual(1); expect(projects.every((p) => p.priority === 'high')).toBe(true); }); }); describe('Filter by Due Date', () => { beforeEach(async () => { const now = moment(); // Task due today await Task.create({ user_id: user.id, name: 'Task due today', due_date: now.format('YYYY-MM-DD HH:mm:ss'), status: 0, }); // Task due tomorrow await Task.create({ user_id: user.id, name: 'Task due tomorrow', due_date: now .clone() .add(1, 'day') .format('YYYY-MM-DD HH:mm:ss'), status: 0, }); // Task due next week await Task.create({ user_id: user.id, name: 'Task due next week', due_date: now .clone() .add(5, 'days') .format('YYYY-MM-DD HH:mm:ss'), status: 0, }); // Task due next month await Task.create({ user_id: user.id, name: 'Task due next month', due_date: now .clone() .add(20, 'days') .format('YYYY-MM-DD HH:mm:ss'), status: 0, }); }); it('should filter tasks due today', async () => { const response = await agent.get('/api/search').query({ due: 'today', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.some((t) => t.name === 'Task due today')).toBe( true ); }); it('should filter tasks due tomorrow', async () => { const response = await agent.get('/api/search').query({ due: 'tomorrow', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.some((t) => t.name === 'Task due tomorrow')).toBe( true ); }); it('should filter tasks due next week', async () => { const response = await agent.get('/api/search').query({ due: 'next_week', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(2); // Should include today, tomorrow, and next week }); it('should filter tasks due next month', async () => { const response = await agent.get('/api/search').query({ due: 'next_month', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(3); // Should include all tasks due within 30 days }); }); describe('Filter by Tags', () => { let workTag, personalTag, urgentTag; beforeEach(async () => { // Create tags workTag = await Tag.create({ user_id: user.id, name: 'work', }); personalTag = await Tag.create({ user_id: user.id, name: 'personal', }); urgentTag = await Tag.create({ user_id: user.id, name: 'urgent', }); // Create tasks with tags const task1 = await Task.create({ user_id: user.id, name: 'Work task', status: 0, }); await task1.addTag(workTag); const task2 = await Task.create({ user_id: user.id, name: 'Personal task', status: 0, }); await task2.addTag(personalTag); const task3 = await Task.create({ user_id: user.id, name: 'Urgent work task', status: 0, }); await task3.addTag(workTag); await task3.addTag(urgentTag); // Create project with tag const project1 = await Project.create({ user_id: user.id, name: 'Work project', state: 'active', }); await project1.addTag(workTag); // Create note with tag const note1 = await Note.create({ user_id: user.id, title: 'Personal note', content: 'Some content', }); await note1.addTag(personalTag); }); it('should filter by single tag', async () => { const response = await agent.get('/api/search').query({ tags: 'work', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBe(2); // Work task and Urgent work task }); it('should filter by multiple tags', async () => { const response = await agent.get('/api/search').query({ tags: 'work,urgent', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); // Should return tasks that have either work OR urgent tag expect(tasks.length).toBeGreaterThanOrEqual(1); }); it('should filter projects by tag', async () => { const response = await agent.get('/api/search').query({ tags: 'work', filters: 'Project', }); expect(response.status).toBe(200); const projects = response.body.results.filter( (r) => r.type === 'Project' ); expect(projects.length).toBe(1); expect(projects[0].name).toBe('Work project'); }); it('should filter notes by tag', async () => { const response = await agent.get('/api/search').query({ tags: 'personal', filters: 'Note', }); expect(response.status).toBe(200); const notes = response.body.results.filter( (r) => r.type === 'Note' ); expect(notes.length).toBe(1); expect(notes[0].title).toBe('Personal note'); }); it('should return empty results for non-existent tag', async () => { const response = await agent.get('/api/search').query({ tags: 'nonexistent', }); expect(response.status).toBe(200); expect(response.body.results).toEqual([]); }); }); describe('Combined Filters', () => { beforeEach(async () => { const now = moment(); const workTag = await Tag.create({ user_id: user.id, name: 'work', }); // High priority work task due today with tag const task1 = await Task.create({ user_id: user.id, name: 'Important work meeting', priority: 2, due_date: now.format('YYYY-MM-DD HH:mm:ss'), status: 0, }); await task1.addTag(workTag); // Low priority personal task await Task.create({ user_id: user.id, name: 'Personal errand', priority: 0, status: 0, }); // Medium priority work task (no due date) const task3 = await Task.create({ user_id: user.id, name: 'Work review', priority: 1, status: 0, }); await task3.addTag(workTag); }); it('should combine search query and priority filter', async () => { const response = await agent.get('/api/search').query({ q: 'work', priority: 'high', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks.every((t) => t.priority === 2)).toBe(true); }); it('should combine priority, due date, and tag filters', async () => { const response = await agent.get('/api/search').query({ priority: 'high', due: 'today', tags: 'work', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBeGreaterThanOrEqual(1); expect(tasks[0].name).toBe('Important work meeting'); }); it('should combine all filters with search query', async () => { const response = await agent.get('/api/search').query({ q: 'meeting', priority: 'high', due: 'today', tags: 'work', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBe(1); expect(tasks[0].name).toBe('Important work meeting'); }); }); describe('Extras Filters', () => { beforeEach(async () => { const project = await Project.create({ user_id: user.id, name: 'Extras Project', state: 'active', }); const workTag = await Tag.create({ user_id: user.id, name: 'extras-tag', }); const recurringTemplate = await Task.create({ user_id: user.id, name: 'Recurring Template', recurrence_type: 'weekly', status: 0, }); await Task.create({ user_id: user.id, name: 'Recurring Instance', recurring_parent_id: recurringTemplate.id, status: 0, }); await Task.create({ user_id: user.id, name: 'Regular Task', status: 0, }); await Task.create({ user_id: user.id, name: 'Overdue Task', due_date: moment().subtract(2, 'days').toDate(), status: 0, }); await Task.create({ user_id: user.id, name: 'Completed Overdue Task', due_date: moment().subtract(3, 'days').toDate(), completed_at: new Date(), status: 3, }); await Task.create({ user_id: user.id, name: 'Content Task', note: 'Detailed context lives here', status: 0, }); await Task.create({ user_id: user.id, name: 'Deferred Task', defer_until: moment().add(2, 'days').toDate(), status: 0, }); const taggedTask = await Task.create({ user_id: user.id, name: 'Tagged Task', status: 0, }); await taggedTask.addTag(workTag); await Task.create({ user_id: user.id, name: 'Project Task', project_id: project.id, status: 0, }); }); const getTaskNames = (response) => response.body.results .filter((r) => r.type === 'Task') .map((task) => task.original_name || task.name); it('should return only recurring tasks when extras contains recurring', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'recurring', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toEqual( expect.arrayContaining([ 'Recurring Template', 'Recurring Instance', ]) ); expect(names).not.toContain('Regular Task'); }); it('should return overdue tasks and exclude completed ones', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'overdue', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toContain('Overdue Task'); expect(names).not.toContain('Completed Overdue Task'); }); it('should return tasks that have content', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'has_content', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toContain('Content Task'); expect(names).not.toContain('Regular Task'); }); it('should return deferred tasks', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'deferred', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toContain('Deferred Task'); expect(names).not.toContain('Regular Task'); }); it('should return tasks with tags', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'has_tags', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toContain('Tagged Task'); expect(names).not.toContain('Regular Task'); }); it('should return tasks assigned to projects', async () => { const response = await agent.get('/api/search').query({ filters: 'Task', extras: 'assigned_to_project', }); expect(response.status).toBe(200); const names = getTaskNames(response); expect(names).toContain('Project Task'); expect(names).not.toContain('Regular Task'); }); it('should return only projects that have tags when extras include has_tags', async () => { const taggedProject = await Project.create({ user_id: user.id, name: 'Tagged Project', state: 'active', }); const plainProject = await Project.create({ user_id: user.id, name: 'Plain Project', state: 'active', }); const projectTag = await Tag.create({ user_id: user.id, name: 'project-tag', }); await taggedProject.addTag(projectTag); const response = await agent.get('/api/search').query({ filters: 'Project', extras: 'has_tags', }); expect(response.status).toBe(200); const projectResults = response.body.results.filter( (r) => r.type === 'Project' ); const projectNames = projectResults.map((p) => p.name); expect(projectNames).toContain('Tagged Project'); expect(projectNames).not.toContain('Plain Project'); }); }); describe('User Isolation', () => { let otherUser, otherAgent; beforeEach(async () => { // Create another user otherUser = await createTestUser({ email: 'other-user@example.com', }); otherAgent = request.agent(app); await otherAgent.post('/api/login').send({ email: 'other-user@example.com', password: 'password123', }); // Create data for first user await Task.create({ user_id: user.id, name: 'User 1 task', status: 0, }); // Create data for second user await Task.create({ user_id: otherUser.id, name: 'User 2 task', status: 0, }); }); it('should only return results for authenticated user', async () => { const response = await agent.get('/api/search').query({ q: 'task', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBe(1); expect(tasks[0].name).toBe('User 1 task'); }); it('should not return other users data', async () => { const response = await otherAgent.get('/api/search').query({ q: 'task', filters: 'Task', }); expect(response.status).toBe(200); const tasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(tasks.length).toBe(1); expect(tasks[0].name).toBe('User 2 task'); }); }); describe('Result Format', () => { beforeEach(async () => { await Task.create({ user_id: user.id, name: 'Test task', note: 'Task description', priority: 1, status: 0, }); await Project.create({ user_id: user.id, name: 'Test project', description: 'Project description', state: 'active', priority: 'medium', }); await Note.create({ user_id: user.id, title: 'Test note', content: 'Note content goes here', }); }); it('should return task with correct structure', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Task', }); expect(response.status).toBe(200); const task = response.body.results.find( (r) => r.type === 'Task' ); expect(task).toBeDefined(); expect(task.type).toBe('Task'); expect(task.id).toBeDefined(); expect(task.uid).toBeDefined(); expect(task.name).toBe('Test task'); expect(task.description).toBe('Task description'); expect(task.priority).toBe(1); expect(task.status).toBe(0); }); it('should return project with correct structure', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Project', }); expect(response.status).toBe(200); const project = response.body.results.find( (r) => r.type === 'Project' ); expect(project).toBeDefined(); expect(project.type).toBe('Project'); expect(project.id).toBeDefined(); expect(project.uid).toBeDefined(); expect(project.name).toBe('Test project'); expect(project.description).toBe('Project description'); expect(project.priority).toBe('medium'); expect(project.status).toBe('not_started'); }); it('should return note with correct structure', async () => { const response = await agent.get('/api/search').query({ q: 'Test', filters: 'Note', }); expect(response.status).toBe(200); const note = response.body.results.find( (r) => r.type === 'Note' ); expect(note).toBeDefined(); expect(note.type).toBe('Note'); expect(note.id).toBeDefined(); expect(note.uid).toBeDefined(); expect(note.name).toBe('Test note'); expect(note.title).toBe('Test note'); expect(note.description).toBe('Note content goes here'); }); }); describe('Edge Cases', () => { it('should handle special characters in search query', async () => { await Task.create({ user_id: user.id, name: 'Task with special chars: @#$%', status: 0, }); const response = await agent.get('/api/search').query({ q: '@#$', filters: 'Task', }); expect(response.status).toBe(200); // Should not error, even if results might be empty expect(response.body.results).toBeDefined(); }); it('should handle very long search queries', async () => { const longQuery = 'a'.repeat(1000); const response = await agent.get('/api/search').query({ q: longQuery, }); expect(response.status).toBe(200); expect(response.body.results).toBeDefined(); }); it('should handle invalid filter types gracefully', async () => { const response = await agent.get('/api/search').query({ filters: 'InvalidType', }); expect(response.status).toBe(200); expect(response.body.results).toEqual([]); }); it('should respect limit of 20 results per type', async () => { // Create 25 tasks const tasks = Array.from({ length: 25 }, (_, i) => Task.create({ user_id: user.id, name: `Task ${i + 1}`, status: 0, }) ); await Promise.all(tasks); const response = await agent.get('/api/search').query({ filters: 'Task', }); expect(response.status).toBe(200); const returnedTasks = response.body.results.filter( (r) => r.type === 'Task' ); expect(returnedTasks.length).toBeLessThanOrEqual(20); }); it('should perform case-insensitive search for ASCII characters', async () => { // NOTE: SQLite's LOWER() function only supports ASCII characters // For Unicode (Cyrillic, Greek, etc.), search is case-sensitive // This is a known limitation of SQLite without ICU extension // Create test data with mixed case ASCII await Task.create({ user_id: user.id, name: 'Important Meeting', note: 'Discussion about Project', status: 0, }); await Project.create({ user_id: user.id, name: 'Website Redesign', description: 'Complete overhaul of company site', state: 'active', }); // Test lowercase search finds uppercase ASCII const response1 = await agent.get('/api/search').query({ q: 'important', filters: 'Task', }); expect(response1.status).toBe(200); const tasks1 = response1.body.results.filter( (r) => r.type === 'Task' ); expect(tasks1.length).toBeGreaterThanOrEqual(1); expect(tasks1.some((t) => t.name.includes('Important'))).toBe( true ); // Test uppercase search finds mixed case ASCII const response2 = await agent.get('/api/search').query({ q: 'MEETING', filters: 'Task', }); expect(response2.status).toBe(200); const tasks2 = response2.body.results.filter( (r) => r.type === 'Task' ); expect(tasks2.length).toBeGreaterThanOrEqual(1); expect( tasks2.some((t) => t.name.toLowerCase().includes('meeting')) ).toBe(true); // Test search in note content (case-insensitive) const response3 = await agent.get('/api/search').query({ q: 'project', filters: 'Task', }); expect(response3.status).toBe(200); const tasks3 = response3.body.results.filter( (r) => r.type === 'Task' ); expect(tasks3.length).toBeGreaterThanOrEqual(1); // Test project search (case-insensitive) const response4 = await agent.get('/api/search').query({ q: 'website', filters: 'Project', }); expect(response4.status).toBe(200); const projects = response4.body.results.filter( (r) => r.type === 'Project' ); expect(projects.length).toBeGreaterThanOrEqual(1); expect(projects.some((p) => p.name.includes('Website'))).toBe( true ); }); it('should demonstrate Cyrillic search limitation', async () => { // NOTE: This test documents the current Cyrillic search limitation // SQLite's LOWER() only works with ASCII, so our search uses: // - JavaScript toLowerCase() on search query: "Тест" -> "тест" // - SQLite LOWER() on database: "Тест Русский" -> "Тест Русский" (unchanged) // Result: Case-insensitive search doesn't work for Cyrillic // Future improvement: Add ICU extension, FTS5, or normalized search fields // Create test data with Cyrillic text (all lowercase to match search query) await Task.create({ user_id: user.id, name: 'тест русский', note: 'заметка по-русски', status: 0, }); await Task.create({ user_id: user.id, name: 'завдання українське', note: 'нотатка українською', status: 0, }); // Lowercase search DOES work when database text is also lowercase const response1 = await agent.get('/api/search').query({ q: 'тест', filters: 'Task', }); expect(response1.status).toBe(200); const tasks1 = response1.body.results.filter( (r) => r.type === 'Task' ); expect(tasks1.length).toBeGreaterThanOrEqual(1); expect(tasks1.some((t) => t.name.includes('тест'))).toBe(true); // Ukrainian lowercase search works const response2 = await agent.get('/api/search').query({ q: 'завдання', filters: 'Task', }); expect(response2.status).toBe(200); const tasks2 = response2.body.results.filter( (r) => r.type === 'Task' ); expect(tasks2.length).toBeGreaterThanOrEqual(1); expect(tasks2.some((t) => t.name.includes('завдання'))).toBe( true ); // Search in note content works const response3 = await agent.get('/api/search').query({ q: 'заметка', filters: 'Task', }); expect(response3.status).toBe(200); const tasks3 = response3.body.results.filter( (r) => r.type === 'Task' ); expect(tasks3.length).toBeGreaterThanOrEqual(1); expect( tasks3.some((t) => t.description?.includes('заметка')) ).toBe(true); // Now test the limitation: Create uppercase Cyrillic task await Task.create({ user_id: user.id, name: 'ТЕСТ UPPERCASE', note: 'UPPERCASE заметка', status: 0, }); // Lowercase search will NOT find uppercase Cyrillic (limitation) const response4 = await agent.get('/api/search').query({ q: 'тест uppercase', filters: 'Task', }); expect(response4.status).toBe(200); const tasks4 = response4.body.results.filter( (r) => r.type === 'Task' ); // Won't find it because JavaScript lowercased query doesn't match uppercase Cyrillic in DB expect(tasks4.some((t) => t.name.includes('UPPERCASE'))).toBe( false ); }); }); }); describe('Pagination', () => { it('should paginate search results with limit and offset', async () => { // Create 25 tasks to test pagination (more than default limit of 20) const tasks = []; for (let i = 1; i <= 25; i++) { tasks.push( await Task.create({ user_id: user.id, name: `Paginated Task ${i}`, note: 'Test pagination', status: 0, }) ); } // First page: get first 10 results const response1 = await agent.get('/api/search').query({ filters: 'Task', limit: 10, offset: 0, }); expect(response1.status).toBe(200); expect(response1.body.results).toBeDefined(); expect(response1.body.pagination).toBeDefined(); expect(response1.body.pagination.total).toBeGreaterThanOrEqual(25); expect(response1.body.pagination.limit).toBe(10); expect(response1.body.pagination.offset).toBe(0); expect(response1.body.results.length).toBe(10); expect(response1.body.pagination.hasMore).toBe(true); // Second page: get next 10 results const response2 = await agent.get('/api/search').query({ filters: 'Task', limit: 10, offset: 10, }); expect(response2.status).toBe(200); expect(response2.body.pagination.offset).toBe(10); expect(response2.body.results.length).toBe(10); expect(response2.body.pagination.hasMore).toBe(true); // Third page: get remaining results const response3 = await agent.get('/api/search').query({ filters: 'Task', limit: 10, offset: 20, }); expect(response3.status).toBe(200); expect(response3.body.pagination.offset).toBe(20); expect(response3.body.results.length).toBeGreaterThanOrEqual(5); }); it('should support pagination with tag filters', async () => { // Create a tag const tag = await Tag.create({ user_id: user.id, name: 'pagination-test', }); // Create 30 tasks with the tag for (let i = 1; i <= 30; i++) { const task = await Task.create({ user_id: user.id, name: `Tagged Task ${i}`, status: 0, }); // Associate task with tag using Sequelize association await task.addTag(tag); } // Get first page const response1 = await agent.get('/api/search').query({ filters: 'Task', tags: 'pagination-test', limit: 15, offset: 0, }); expect(response1.status).toBe(200); expect(response1.body.pagination).toBeDefined(); expect(response1.body.pagination.total).toBe(30); expect(response1.body.results.length).toBe(15); expect(response1.body.pagination.hasMore).toBe(true); // Get second page const response2 = await agent.get('/api/search').query({ filters: 'Task', tags: 'pagination-test', limit: 15, offset: 15, }); expect(response2.status).toBe(200); expect(response2.body.results.length).toBe(15); expect(response2.body.pagination.hasMore).toBe(false); }); it('should maintain backward compatibility without pagination params', async () => { // Create 5 tasks for (let i = 1; i <= 5; i++) { await Task.create({ user_id: user.id, name: `Task ${i}`, status: 0, }); } // Request without pagination params const response = await agent.get('/api/search').query({ filters: 'Task', }); expect(response.status).toBe(200); expect(response.body.results).toBeDefined(); // Should NOT include pagination metadata when no params provided expect(response.body.pagination).toBeUndefined(); expect(response.body.results.length).toBeGreaterThanOrEqual(5); }); it('should handle offset beyond total results', async () => { // Create 10 tasks for (let i = 1; i <= 10; i++) { await Task.create({ user_id: user.id, name: `Task ${i}`, status: 0, }); } // Request with offset beyond available results const response = await agent.get('/api/search').query({ filters: 'Task', limit: 10, offset: 100, }); expect(response.status).toBe(200); expect(response.body.pagination).toBeDefined(); expect(response.body.pagination.total).toBeGreaterThanOrEqual(10); expect(response.body.results.length).toBe(0); expect(response.body.pagination.hasMore).toBe(false); }); it('should use default limit of 20 when limit param is provided without value', async () => { // Create 25 tasks for (let i = 1; i <= 25; i++) { await Task.create({ user_id: user.id, name: `Task ${i}`, status: 0, }); } // Request with limit param but no value (or invalid value) const response = await agent.get('/api/search').query({ filters: 'Task', limit: '', }); expect(response.status).toBe(200); expect(response.body.pagination).toBeDefined(); expect(response.body.pagination.limit).toBe(20); expect(response.body.results.length).toBe(20); }); }); });