## 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>
224 lines
6.6 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|
|
})
|