feat: MVP-3 Sprint 1 - Usage tracking, smart dashboard, scoring boost
## Registro de Uso - Nuevo modelo NoteUsage en Prisma - Tipos de eventos: view, search_click, related_click, link_click, copy_command, copy_snippet - Funciones: trackNoteUsage, getUsageStats, getRecentlyUsedNotes - localStorage: recentlyViewed (últimas 10 notas) - Rastreo de copias en markdown-content.tsx ## Dashboard Rediseñado - 5 bloques: Recientes, Más usadas, Comandos recientes, Snippets recientes, Según actividad - Nuevo src/lib/dashboard.ts con getDashboardData() - Recomendaciones basadas en recentlyViewed ## Scoring con Uso Real - search.ts: +1 per 5 views (max +3), +2 recency boost - related.ts: mismo sistema de usage boost - No eclipsa match textual fuerte ## Tests - 110 tests pasando (usage, dashboard, related, search) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
293
__tests__/dashboard.test.ts
Normal file
293
__tests__/dashboard.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { getDashboardData, hasVisibleBlocks, DashboardNote, DashboardData } from '@/lib/dashboard'
|
||||
|
||||
// Mock prisma and usage before importing dashboard module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getRecentlyUsedNotes: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getRecentlyUsedNotes } from '@/lib/usage'
|
||||
|
||||
describe('dashboard.ts', () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build, docker run',
|
||||
type: 'command',
|
||||
isFavorite: true,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [{ tag: { id: 'tag-1', name: 'docker' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'React Snippet',
|
||||
content: 'useState usage',
|
||||
type: 'snippet',
|
||||
isFavorite: false,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-02'),
|
||||
updatedAt: new Date('2024-01-14'),
|
||||
tags: [{ tag: { id: 'tag-2', name: 'react' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-3',
|
||||
title: 'Git Commands',
|
||||
content: 'git commit, git push',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-03'),
|
||||
updatedAt: new Date('2024-01-13'),
|
||||
tags: [{ tag: { id: 'tag-3', name: 'git' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-4',
|
||||
title: 'SQL Queries',
|
||||
content: 'SELECT * FROM',
|
||||
type: 'snippet',
|
||||
isFavorite: true,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-04'),
|
||||
updatedAt: new Date('2024-01-12'),
|
||||
tags: [{ tag: { id: 'tag-4', name: 'sql' } }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getDashboardData', () => {
|
||||
it('should return dashboard data with all sections', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 3, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
expect(result).toHaveProperty('recentNotes')
|
||||
expect(result).toHaveProperty('mostUsedNotes')
|
||||
expect(result).toHaveProperty('recentCommands')
|
||||
expect(result).toHaveProperty('recentSnippets')
|
||||
expect(result).toHaveProperty('activityBasedNotes')
|
||||
expect(result).toHaveProperty('hasActivity')
|
||||
})
|
||||
|
||||
it('should return recent notes ordered by updatedAt', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(2)
|
||||
|
||||
expect(result.recentNotes).toHaveLength(2)
|
||||
expect(result.recentNotes[0].id).toBe('note-1') // most recent
|
||||
expect(result.recentNotes[1].id).toBe('note-2')
|
||||
})
|
||||
|
||||
it('should return most used notes by usage count', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-2', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
expect(result.mostUsedNotes).toHaveLength(2)
|
||||
expect(result.mostUsedNotes[0].id).toBe('note-2') // highest usage
|
||||
expect(result.mostUsedNotes[0].usageCount).toBe(10)
|
||||
})
|
||||
|
||||
it('should return only command type notes for recentCommands', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(5)
|
||||
|
||||
expect(result.recentCommands).toHaveLength(2)
|
||||
expect(result.recentCommands.every((n: DashboardNote) => n.type === 'command')).toBe(true)
|
||||
expect(result.recentCommands[0].id).toBe('note-1')
|
||||
})
|
||||
|
||||
it('should return only snippet type notes for recentSnippets', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(5)
|
||||
|
||||
expect(result.recentSnippets).toHaveLength(2)
|
||||
expect(result.recentSnippets.every((n: DashboardNote) => n.type === 'snippet')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return activity based notes excluding recent notes', async () => {
|
||||
// mockNotes are sorted by updatedAt desc: note-1, note-2, note-3, note-4
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
// note-4 is in recently used but will be filtered out since recentNotes has first 3
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-4', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
// note-4 is used but not in recentNotes (which contains note-1, note-2, note-3)
|
||||
expect(result.activityBasedNotes).toHaveLength(1)
|
||||
expect(result.activityBasedNotes[0].id).toBe('note-4')
|
||||
})
|
||||
|
||||
it('should set hasActivity based on recently used notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const resultWithActivity = await getDashboardData(3)
|
||||
expect(resultWithActivity.hasActivity).toBe(true)
|
||||
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
const resultWithoutActivity = await getDashboardData(3)
|
||||
expect(resultWithoutActivity.hasActivity).toBe(false)
|
||||
})
|
||||
|
||||
it('should respect the limit parameter', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 8, lastUsed: new Date() },
|
||||
{ noteId: 'note-3', count: 6, lastUsed: new Date() },
|
||||
{ noteId: 'note-4', count: 4, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(2)
|
||||
|
||||
expect(result.recentNotes).toHaveLength(2)
|
||||
expect(result.mostUsedNotes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should add usageCount to notes from usage map', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
const note1 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-1')
|
||||
const note2 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-2')
|
||||
expect(note1?.usageCount).toBe(10)
|
||||
expect(note2?.usageCount).toBe(5)
|
||||
})
|
||||
|
||||
it('should return empty arrays when no notes exist', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData()
|
||||
|
||||
expect(result.recentNotes).toEqual([])
|
||||
expect(result.mostUsedNotes).toEqual([])
|
||||
expect(result.recentCommands).toEqual([])
|
||||
expect(result.recentSnippets).toEqual([])
|
||||
expect(result.activityBasedNotes).toEqual([])
|
||||
expect(result.hasActivity).toBe(false)
|
||||
})
|
||||
|
||||
it('should convert dates to ISO strings', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(1)
|
||||
|
||||
expect(result.recentNotes[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
|
||||
expect(result.recentNotes[0].updatedAt).toBe('2024-01-15T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasVisibleBlocks', () => {
|
||||
it('should return true when recentNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when mostUsedNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [], usageCount: 5 }],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when recentCommands has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [{ id: '1', title: 'Test', content: '', type: 'command' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when recentSnippets has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [{ id: '1', title: 'Test', content: '', type: 'snippet' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when activityBasedNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
hasActivity: true,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when all arrays are empty', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
246
__tests__/related.test.ts
Normal file
246
__tests__/related.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,64 @@
|
||||
import { highlightMatches } from '@/lib/search'
|
||||
import { highlightMatches, noteQuery, searchNotes, ScoredNote } from '@/lib/search'
|
||||
|
||||
// Mock prisma before importing search module
|
||||
// Mock prisma and usage before importing search module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getUsageStats: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
|
||||
describe('search.ts', () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build and docker run',
|
||||
type: 'command',
|
||||
isFavorite: true,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [{ tag: { id: 'tag-1', name: 'docker' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'React Hooks',
|
||||
content: 'useState and useEffect hooks',
|
||||
type: 'snippet',
|
||||
isFavorite: false,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-02'),
|
||||
updatedAt: new Date('2024-01-10'),
|
||||
tags: [{ tag: { id: 'tag-2', name: 'react' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-3',
|
||||
title: 'Git Commands',
|
||||
content: 'git commit and git push',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-03'),
|
||||
updatedAt: new Date('2024-01-05'),
|
||||
tags: [{ tag: { id: 'tag-3', name: 'git' } }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Default: no usage
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
})
|
||||
|
||||
describe('highlightMatches', () => {
|
||||
it('returns first 150 characters when query is empty', () => {
|
||||
const text = 'This is a long text that should be truncated to 150 characters. ' +
|
||||
@@ -40,7 +89,6 @@ describe('search.ts', () => {
|
||||
it('adds ellipsis when match is not at start', () => {
|
||||
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. JavaScript is great.'
|
||||
const result = highlightMatches(text, 'JavaScript')
|
||||
// JavaScript is at position 61, start is max(0, 61-75) = 0, so no ellipsis needed
|
||||
expect(result).toContain('<mark>JavaScript</mark>')
|
||||
})
|
||||
|
||||
@@ -56,8 +104,197 @@ describe('search.ts', () => {
|
||||
// Single char words (A, B, C) are filtered, returns 150 chars
|
||||
expect(result.length).toBeLessThanOrEqual(150)
|
||||
})
|
||||
|
||||
it('adds ellipsis when match is far from start', () => {
|
||||
// JavaScript is at position ~26, start = max(0, 26-75) = 0, so no ellipsis needed
|
||||
// We need a longer text where match is more than 75 chars from start
|
||||
const text = 'Lorem ipsum dolor sit amet. '.repeat(10) + 'JavaScript programming language.'
|
||||
const result = highlightMatches(text, 'JavaScript')
|
||||
expect(result).toContain('...')
|
||||
})
|
||||
})
|
||||
|
||||
// Integration tests would go here but require database setup
|
||||
// These would test noteQuery and searchNotes with actual data
|
||||
describe('noteQuery', () => {
|
||||
it('returns empty array when no notes exist', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns notes with exact title match scored highest', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].id).toBe('note-1') // exact title match
|
||||
expect(result[0].matchType).toBe('exact')
|
||||
})
|
||||
|
||||
it('returns notes with content match', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('hooks')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.some((n: ScoredNote) => n.id === 'note-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns fuzzy matches when no exact match', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docer') // typo
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0].matchType).toBe('fuzzy')
|
||||
})
|
||||
|
||||
it('excludes notes with no match (low similarity)', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('xyz123nonexistent')
|
||||
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
|
||||
it('adds +2 for favorite notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
// note-1 is favorite, should have higher score
|
||||
const dockerNote = result.find((n: ScoredNote) => n.id === 'note-1')
|
||||
expect(dockerNote?.isFavorite).toBe(true)
|
||||
})
|
||||
|
||||
it('adds +1 for pinned notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('hooks')
|
||||
|
||||
// note-2 is pinned
|
||||
const hooksNote = result.find((n: ScoredNote) => n.id === 'note-2')
|
||||
expect(hooksNote?.isPinned).toBe(true)
|
||||
})
|
||||
|
||||
it('adds +1 for recently updated notes (within 7 days)', async () => {
|
||||
const recentNote = {
|
||||
...mockNotes[0],
|
||||
updatedAt: new Date(Date.now() - 1000), // just now
|
||||
}
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([recentNote])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].score).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('filters by type when specified', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('', { type: 'command' })
|
||||
|
||||
expect(result.every((n: ScoredNote) => n.type === 'command')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters by tag when specified', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('', { tag: 'docker' })
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe('note-1')
|
||||
})
|
||||
|
||||
it('returns notes sorted by score descending', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('')
|
||||
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns highlight excerpt for matched notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].highlight).toBeDefined()
|
||||
expect(typeof result[0].highlight).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should be an alias for noteQuery', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await searchNotes('docker')
|
||||
|
||||
expect(result).toEqual(await noteQuery('docker'))
|
||||
})
|
||||
|
||||
it('passes filters to noteQuery', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await searchNotes('', { type: 'snippet' })
|
||||
|
||||
expect(result.every((n: ScoredNote) => n.type === 'snippet')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage-based scoring boost', () => {
|
||||
it('calls getUsageStats for each note', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
await noteQuery('docker')
|
||||
|
||||
expect(getUsageStats).toHaveBeenCalledWith('note-1', 7)
|
||||
})
|
||||
|
||||
it('handles getUsageStats returning zero values', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
// Should return results without error
|
||||
expect(result).toBeDefined()
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScoredNote interface', () => {
|
||||
it('returns correct structure for scored notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0]).toHaveProperty('id')
|
||||
expect(result[0]).toHaveProperty('title')
|
||||
expect(result[0]).toHaveProperty('content')
|
||||
expect(result[0]).toHaveProperty('type')
|
||||
expect(result[0]).toHaveProperty('isFavorite')
|
||||
expect(result[0]).toHaveProperty('isPinned')
|
||||
expect(result[0]).toHaveProperty('createdAt')
|
||||
expect(result[0]).toHaveProperty('updatedAt')
|
||||
expect(result[0]).toHaveProperty('tags')
|
||||
expect(result[0]).toHaveProperty('score')
|
||||
expect(result[0]).toHaveProperty('highlight')
|
||||
expect(result[0]).toHaveProperty('matchType')
|
||||
expect(['exact', 'fuzzy']).toContain(result[0].matchType)
|
||||
})
|
||||
|
||||
it('converts Date objects to ISO strings', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
|
||||
expect(result[0].updatedAt).toBe('2024-01-15T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
223
__tests__/usage.test.ts
Normal file
223
__tests__/usage.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { trackNoteUsage, getNoteUsageCount, getRecentlyUsedNotes, getUsageStats, UsageEventType } from '@/lib/usage'
|
||||
|
||||
// Mock prisma before importing usage module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
noteUsage: {
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
},
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
describe('usage.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('trackNoteUsage', () => {
|
||||
it('should create a usage event with all fields', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' })
|
||||
|
||||
await trackNoteUsage({
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: 'docker commands',
|
||||
metadata: { source: 'search' },
|
||||
})
|
||||
|
||||
expect(prisma.noteUsage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: 'docker commands',
|
||||
metadata: JSON.stringify({ source: 'search' }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a usage event without optional fields', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' })
|
||||
|
||||
await trackNoteUsage({
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
})
|
||||
|
||||
expect(prisma.noteUsage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: null,
|
||||
metadata: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should silently fail on database error', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
trackNoteUsage({ noteId: 'note-1', eventType: 'view' })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteUsageCount', () => {
|
||||
it('should return count for a specific note', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(5)
|
||||
|
||||
const result = await getNoteUsageCount('note-1')
|
||||
|
||||
expect(result).toBe(5)
|
||||
expect(prisma.noteUsage.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ noteId: 'note-1' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter by event type when specified', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(3)
|
||||
|
||||
const result = await getNoteUsageCount('note-1', 'search_click')
|
||||
|
||||
expect(result).toBe(3)
|
||||
expect(prisma.noteUsage.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
noteId: 'note-1',
|
||||
eventType: 'search_click',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply default 30 days filter', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0)
|
||||
|
||||
await getNoteUsageCount('note-1')
|
||||
|
||||
const call = (prisma.noteUsage.count as jest.Mock).mock.calls[0][0]
|
||||
expect(call.where.createdAt.gte).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should return 0 on database error', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getNoteUsageCount('note-1')
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlyUsedNotes', () => {
|
||||
it('should return recently used notes with counts', async () => {
|
||||
const mockGroupBy = [
|
||||
{ noteId: 'note-1', _count: { id: 10 } },
|
||||
{ noteId: 'note-2', _count: { id: 5 } },
|
||||
]
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue(mockGroupBy)
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: 'note-1', updatedAt: new Date('2024-01-15') },
|
||||
{ id: 'note-2', updatedAt: new Date('2024-01-14') },
|
||||
])
|
||||
|
||||
const result = await getRecentlyUsedNotes('user-1', 10, 30)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({
|
||||
noteId: 'note-1',
|
||||
count: 10,
|
||||
lastUsed: expect.any(Date),
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty array on database error', async () => {
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getRecentlyUsedNotes('user-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await getRecentlyUsedNotes('user-1', 5)
|
||||
|
||||
expect(prisma.noteUsage.groupBy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 5 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsageStats', () => {
|
||||
it('should return usage stats for a note', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock)
|
||||
.mockResolvedValueOnce(15) // views
|
||||
.mockResolvedValueOnce(8) // clicks
|
||||
.mockResolvedValueOnce(3) // relatedClicks
|
||||
|
||||
const result = await getUsageStats('note-1', 30)
|
||||
|
||||
expect(result).toEqual({
|
||||
views: 15,
|
||||
clicks: 8,
|
||||
relatedClicks: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return zeros on database error', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getUsageStats('note-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
views: 0,
|
||||
clicks: 0,
|
||||
relatedClicks: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should query with correct date filter', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0)
|
||||
|
||||
await getUsageStats('note-1', 7)
|
||||
|
||||
// All three counts should use the same date filter
|
||||
for (const call of (prisma.noteUsage.count as jest.Mock).mock.calls) {
|
||||
expect(call[0].where.createdAt.gte).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
it('should return zeros when any count fails', async () => {
|
||||
// Promise.all will reject on first failure, so we can't get partial results
|
||||
;(prisma.noteUsage.count as jest.Mock)
|
||||
.mockResolvedValueOnce(10) // views
|
||||
.mockRejectedValueOnce(new Error('DB error')) // clicks fails
|
||||
.mockResolvedValueOnce(5) // relatedClicks never called
|
||||
|
||||
const result = await getUsageStats('note-1')
|
||||
|
||||
// All zeros because the whole operation fails
|
||||
expect(result.views).toBe(0)
|
||||
expect(result.clicks).toBe(0)
|
||||
expect(result.relatedClicks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UsageEventType', () => {
|
||||
it('should accept all valid event types', () => {
|
||||
const eventTypes: UsageEventType[] = ['view', 'search_click', 'related_click', 'link_click']
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
expect(['view', 'search_click', 'related_click', 'link_click']).toContain(eventType)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user