1376 lines
51 KiB
JavaScript
1376 lines
51 KiB
JavaScript
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('active');
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
});
|