import { getRelatedNotes } from '@/lib/related' // Mock prisma and usage before importing related module jest.mock('@/lib/prisma', () => ({ prisma: { note: { findUnique: jest.fn(), findMany: jest.fn(), }, }, })) jest.mock('@/lib/usage', () => ({ getUsageStats: jest.fn(), })) import { prisma } from '@/lib/prisma' import { getUsageStats } from '@/lib/usage' describe('related.ts', () => { const createMockNote = (overrides = {}) => ({ id: 'note-1', title: 'Docker Commands', content: 'docker build and docker run commands', type: 'command', isFavorite: false, isPinned: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-15'), tags: [ { tag: { id: 'tag-1', name: 'docker' } }, { tag: { id: 'tag-2', name: 'containers' } }, ], ...overrides, }) const createMockOtherNote = (overrides = {}) => ({ id: 'note-other', title: 'Other Note Title', content: 'other content xyz123', type: 'snippet', tags: [] as { tag: { id: string; name: string } }[], ...overrides, }) beforeEach(() => { jest.clearAllMocks() // Default usage stats - no usage ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 }) }) describe('getRelatedNotes', () => { it('should return empty array when note not found', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null) const result = await getRelatedNotes('non-existent') expect(result).toEqual([]) }) it('should return empty array when no related notes found', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-x', title: 'Completely Different', content: 'xyz abc def' }), ]) const result = await getRelatedNotes('note-1') expect(result).toEqual([]) }) it('should return related notes sorted by score', async () => { const mockNote = createMockNote() ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker Compose', tags: [{ tag: { id: 't1', name: 'docker' } }] }), createMockOtherNote({ id: 'note-3', title: 'React Hooks', content: 'react hooks' }), ]) const result = await getRelatedNotes('note-1') expect(result.length).toBeGreaterThan(0) // First result should have highest score for (let i = 0; i < result.length - 1; i++) { expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score) } }) it('should give +3 for same type', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different' }), ]) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') expect(note2).toBeDefined() expect(note2!.score).toBe(3) // only type match expect(note2!.reason).toContain('Same type') }) it('should give +3 per shared tag', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'snippet', // different type to isolate tag scoring title: 'XYZ Title', content: 'different content', tags: [ { tag: { id: 'tag-1', name: 'docker' } }, { tag: { id: 'tag-2', name: 'containers' } }, ], }), ]) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') expect(note2).toBeDefined() expect(note2!.score).toBe(6) // 2 tags * 3 = 6 expect(note2!.reason).toContain('Tags: docker, containers') }) it('should cap title keyword boost at +3', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Docker Kubernetes', content: 'different' })) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'snippet', title: 'Docker Kubernetes Python Ruby', // 4 shared keywords but capped at 3 content: 'different', tags: [], }), ]) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') expect(note2).toBeDefined() // Type mismatch = 0, Tags = 0, Title keywords capped at +3 expect(note2!.score).toBeLessThanOrEqual(3) }) it('should cap content keyword boost at +2', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Different', content: 'docker kubernetes python ruby' })) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'snippet', title: 'Title', content: 'docker kubernetes python', // 3 shared but capped at 2 tags: [], }), ]) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') expect(note2).toBeDefined() }) it('should add usage-based boost with view count', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), ]) ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 15, clicks: 0, relatedClicks: 0 }) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') // Base: 3 (type) + floor(15/5)=3 + 2 (recency) = 8 expect(note2!.score).toBe(8) }) it('should cap usage view boost at +3', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), ]) ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 50, clicks: 0, relatedClicks: 0 }) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') // Base: 3 (type) + min(floor(50/5),3)=3 + 2 (recency) = 8 expect(note2!.score).toBe(8) }) it('should add +2 recency boost when note has any views', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), ]) ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 5, clicks: 0, relatedClicks: 0 }) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') // Base: 3 (type) + floor(5/5)=1 + 2 (recency) = 6 expect(note2!.score).toBe(6) }) it('should use relatedClicks for recency boost', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), ]) ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 3 }) const result = await getRelatedNotes('note-1') const note2 = result.find((n) => n.id === 'note-2') // Base: 3 (type) + 0 (views) + 2 (related clicks recency) = 5 expect(note2!.score).toBe(5) }) it('should respect limit parameter', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }), createMockOtherNote({ id: 'note-3', type: 'command', title: 'Kubernetes', tags: [{ tag: { id: 't2', name: 'kubernetes' } }] }), createMockOtherNote({ id: 'note-4', type: 'command', title: 'Git', tags: [{ tag: { id: 't3', name: 'git' } }] }), ]) const result = await getRelatedNotes('note-1', 2) expect(result).toHaveLength(2) }) it('should include reason field', async () => { ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }), ]) const result = await getRelatedNotes('note-1') expect(result[0].reason).toBeDefined() expect(typeof result[0].reason).toBe('string') expect(result[0].reason.length).toBeGreaterThan(0) }) }) })