Files
recall/__tests__/usage.test.ts
Daniel Arroyo 05b8f3910d 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>
2026-03-22 16:03:14 -03:00

224 lines
6.6 KiB
TypeScript

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)
})
})
})
})