Files
recall/__tests__/api.integration.test.ts
Daniel Arroyo a67442e9ed test: MVP-4 Sprint 4 - Version history tests
- Add 11 tests for versions.ts (create, get, restore, edge cases)
- Add noteVersion mock to api.integration.test.ts
2026-03-22 17:45:53 -03:00

754 lines
24 KiB
TypeScript

/**
* Integration Tests for All API Endpoints
*
* Tests success and error cases for all routes:
* - /api/notes (GET, POST)
* - /api/notes/[id] (GET, PUT, DELETE)
* - /api/notes/quick (POST)
* - /api/tags (GET)
* - /api/search (GET)
* - /api/export-import (POST)
*/
import { NextRequest } from 'next/server'
// Complete mock Prisma client
const mockPrisma = {
note: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
tag: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
upsert: jest.fn(),
create: jest.fn(),
},
noteTag: {
create: jest.fn(),
deleteMany: jest.fn(),
},
backlink: {
deleteMany: jest.fn(),
create: jest.fn(),
createMany: jest.fn(),
},
noteVersion: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
},
$transaction: jest.fn((callback) => callback(mockPrisma)),
}
// Mock prisma before imports
jest.mock('@/lib/prisma', () => ({
prisma: mockPrisma,
}))
// Mock string-similarity used in search
jest.mock('string-similarity', () => ({
compareTwoStrings: jest.fn().mockReturnValue(0.5),
}))
describe('API Integration Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset transaction mock to properly chain
mockPrisma.$transaction.mockImplementation(async (callback) => {
return callback(mockPrisma)
})
})
// Helper to parse wrapped response
function getData(response: { json: () => Promise<{ data?: unknown; error?: unknown; success: boolean }> }) {
return response.json().then((r) => r.data ?? r)
}
function expectSuccess(response: { status: number; json: () => Promise<{ success: boolean }> }) {
expect(response.status).toBeLessThan(400)
return response.json().then((r) => expect(r.success).toBe(true))
}
// ============================================
// GET /api/notes - List all notes
// ============================================
describe('GET /api/notes', () => {
it('returns empty array when no notes exist', async () => {
mockPrisma.note.findMany.mockResolvedValue([])
const { GET } = await import('@/app/api/notes/route')
const response = await GET(new NextRequest('http://localhost/api/notes'))
const data = await getData(response)
expect(response.status).toBe(200)
expect(data).toEqual([])
})
it('returns all notes with tags', async () => {
const mockNotes = [
{
id: '1',
title: 'Note 1',
content: 'Content 1',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
tags: [{ tag: { id: 't1', name: 'javascript' } }],
},
]
mockPrisma.note.findMany.mockResolvedValue(mockNotes)
const { GET } = await import('@/app/api/notes/route')
const response = await GET(new NextRequest('http://localhost/api/notes'))
const data = await getData(response)
expect(response.status).toBe(200)
expect(Array.isArray(data)).toBe(true)
})
it('filters notes with search query', async () => {
mockPrisma.note.findMany.mockResolvedValue([])
const { GET } = await import('@/app/api/notes/route')
const response = await GET(new NextRequest('http://localhost/api/notes?q=test'))
expect(response.status).toBe(200)
})
})
// ============================================
// POST /api/notes - Create note
// ============================================
describe('POST /api/notes', () => {
it('creates a note without tags', async () => {
const newNote = {
id: '1',
title: 'New Note',
content: 'Note content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.create.mockResolvedValue(newNote)
mockPrisma.backlink.deleteMany.mockResolvedValue([])
const { POST } = await import('@/app/api/notes/route')
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'New Note',
content: 'Note content',
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.success).toBe(true)
expect(data.data.title).toBe('New Note')
})
it('creates a note with tags', async () => {
const newNote = {
id: '1',
title: 'Tagged Note',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [{ tag: { id: '1', name: 'javascript' } }],
}
mockPrisma.note.create.mockResolvedValue(newNote)
mockPrisma.tag.upsert.mockResolvedValue({ id: '1', name: 'javascript' })
mockPrisma.noteTag.create.mockResolvedValue({ noteId: '1', tagId: '1' })
mockPrisma.backlink.deleteMany.mockResolvedValue([])
const { POST } = await import('@/app/api/notes/route')
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Tagged Note',
content: 'Content',
tags: ['JavaScript'],
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.success).toBe(true)
})
it('returns 400 when title is missing', async () => {
const { POST } = await import('@/app/api/notes/route')
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: '',
content: 'Some content',
}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('returns 400 when content is missing', async () => {
const { POST } = await import('@/app/api/notes/route')
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Valid Title',
content: '',
}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
// ============================================
// GET /api/notes/[id] - Get single note
// ============================================
describe('GET /api/notes/[id]', () => {
it('returns note when found', async () => {
const mockNote = {
id: '1',
title: 'Test Note',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.findUnique.mockResolvedValue(mockNote)
const { GET } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/1')
const response = await GET(request, { params: Promise.resolve({ id: '1' }) })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('1')
})
it('returns 404 when note not found', async () => {
mockPrisma.note.findUnique.mockResolvedValue(null)
const { GET } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/nonexistent')
const response = await GET(request, { params: Promise.resolve({ id: 'nonexistent' }) })
expect(response.status).toBe(404)
})
})
// ============================================
// PUT /api/notes/[id] - Update note
// ============================================
describe('PUT /api/notes/[id]', () => {
it('updates note title', async () => {
const existingNote = {
id: '1',
title: 'Old Title',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
const updatedNote = {
...existingNote,
title: 'New Title',
}
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
mockPrisma.note.update.mockResolvedValue(updatedNote)
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
mockPrisma.backlink.deleteMany.mockResolvedValue([])
const { PUT } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/1', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: '1', title: 'New Title' }),
})
const response = await PUT(request, { params: Promise.resolve({ id: '1' }) })
expect(response.status).toBe(200)
})
it('returns 404 when note to update not found', async () => {
mockPrisma.note.findUnique.mockResolvedValue(null)
const { PUT } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/nonexistent', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'nonexistent', title: 'New Title' }),
})
const response = await PUT(request, { params: Promise.resolve({ id: 'nonexistent' }) })
expect(response.status).toBe(404)
})
it('returns 400 for invalid update data', async () => {
const existingNote = {
id: '1',
title: 'Test',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
const { PUT } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/1', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: '1', title: '', content: '' }),
})
const response = await PUT(request, { params: Promise.resolve({ id: '1' }) })
expect(response.status).toBe(400)
})
})
// ============================================
// DELETE /api/notes/[id] - Delete note
// ============================================
describe('DELETE /api/notes/[id]', () => {
it('deletes note successfully', async () => {
const existingNote = {
id: '1',
title: 'Test',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
mockPrisma.backlink.deleteMany.mockResolvedValue([])
mockPrisma.note.delete.mockResolvedValue({ id: '1' })
const { DELETE } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/1', {
method: 'DELETE',
})
const response = await DELETE(request, { params: Promise.resolve({ id: '1' }) })
expect(response.status).toBe(200)
})
it('returns 404 when deleting non-existent note', async () => {
mockPrisma.note.findUnique.mockResolvedValue(null)
const { DELETE } = await import('@/app/api/notes/[id]/route')
const request = new NextRequest('http://localhost/api/notes/nonexistent', {
method: 'DELETE',
})
const response = await DELETE(request, { params: Promise.resolve({ id: 'nonexistent' }) })
expect(response.status).toBe(404)
})
})
// ============================================
// POST /api/notes/quick - Quick add note
// ============================================
describe('POST /api/notes/quick', () => {
it('creates note from plain text', async () => {
const createdNote = {
id: '1',
title: 'Quick Note',
content: 'Quick Note',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.create.mockResolvedValue(createdNote)
const { POST } = await import('@/app/api/notes/quick/route')
const request = new NextRequest('http://localhost/api/notes/quick', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: 'Quick Note',
})
const response = await POST(request)
const json = await response.json()
const data = json.data ?? json
expect(response.status).toBe(201)
expect(data).toHaveProperty('title', 'Quick Note')
})
it('creates note with type prefix', async () => {
const createdNote = {
id: '1',
title: 'Deploy script',
content: 'deploy script content',
type: 'command',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
}
mockPrisma.note.create.mockResolvedValue(createdNote)
const { POST } = await import('@/app/api/notes/quick/route')
const request = new NextRequest('http://localhost/api/notes/quick', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: 'cmd: Deploy script\ndeploy script content',
})
const response = await POST(request)
const json = await response.json()
const data = json.data ?? json
expect(response.status).toBe(201)
expect(data).toHaveProperty('type', 'command')
})
it('returns 400 when text is empty', async () => {
const { POST } = await import('@/app/api/notes/quick/route')
const request = new NextRequest('http://localhost/api/notes/quick', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: '',
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('returns 400 for JSON with missing text', async () => {
const { POST } = await import('@/app/api/notes/quick/route')
const request = new NextRequest('http://localhost/api/notes/quick', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
// ============================================
// GET /api/tags - List tags / suggestions
// ============================================
describe('GET /api/tags', () => {
it('returns all tags when no query', async () => {
const mockTags = [
{ id: '1', name: 'javascript' },
{ id: '2', name: 'python' },
{ id: '3', name: 'typescript' },
]
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
const { GET } = await import('@/app/api/tags/route')
const request = new NextRequest('http://localhost/api/tags')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data).toEqual(mockTags)
})
it('returns tag suggestions when q param provided', async () => {
const mockTags = [
{ id: '1', name: 'javascript' },
{ id: '2', name: 'java' },
]
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
const { GET } = await import('@/app/api/tags/route')
const request = new NextRequest('http://localhost/api/tags?q=java')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('returns empty array when no tags exist', async () => {
mockPrisma.tag.findMany.mockResolvedValue([])
const { GET } = await import('@/app/api/tags/route')
const request = new NextRequest('http://localhost/api/tags')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data).toEqual([])
})
})
// ============================================
// GET /api/tags/suggest - Suggest tags based on content
// ============================================
describe('GET /api/tags/suggest', () => {
it('suggests tags based on title keywords', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Docker%20deployment&content=')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('suggests tags based on content keywords', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=&content=Docker%20and%20Kubernetes%20deployment')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data))
})
it('combines title and content for suggestions', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Python%20script&content=SQL%20database%20query')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('returns empty array for generic content', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Note&content=content')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('handles empty parameters gracefully', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
// ============================================
// GET /api/search - Search notes
// ============================================
describe('GET /api/search', () => {
it('returns search results with query', async () => {
const mockNotes = [
{
id: '1',
title: 'JavaScript Guide',
content: 'Learning JavaScript basics',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
]
mockPrisma.note.findMany.mockResolvedValue(mockNotes)
const { GET } = await import('@/app/api/search/route')
const request = new NextRequest('http://localhost/api/search?q=javascript')
const response = await GET(request)
expect(response.status).toBe(200)
})
it('filters by type', async () => {
mockPrisma.note.findMany.mockResolvedValue([])
const { GET } = await import('@/app/api/search/route')
const request = new NextRequest('http://localhost/api/search?type=command')
const response = await GET(request)
expect(response.status).toBe(200)
})
it('filters by tag', async () => {
mockPrisma.note.findMany.mockResolvedValue([])
const { GET } = await import('@/app/api/search/route')
const request = new NextRequest('http://localhost/api/search?tag=python')
const response = await GET(request)
expect(response.status).toBe(200)
})
})
// ============================================
// POST /api/export-import - Import notes
// ============================================
describe('POST /api/export-import', () => {
it('imports valid notes array', async () => {
mockPrisma.note.findUnique.mockResolvedValue(null)
mockPrisma.note.findFirst.mockResolvedValue(null)
mockPrisma.note.create.mockResolvedValue({
id: '1',
title: 'Imported Note',
content: 'Content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
})
mockPrisma.tag.upsert.mockResolvedValue({ id: '1', name: 'imported' })
mockPrisma.noteTag.create.mockResolvedValue({ noteId: '1', tagId: '1' })
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
const { POST } = await import('@/app/api/export-import/route')
const request = new NextRequest('http://localhost/api/export-import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([
{
title: 'Imported Note',
content: 'Content',
type: 'note',
tags: ['imported'],
},
]),
})
const response = await POST(request)
expect(response.status).toBe(201)
})
it('returns 400 for non-array input', async () => {
const { POST } = await import('@/app/api/export-import/route')
const request = new NextRequest('http://localhost/api/export-import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Single note' }),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('returns 400 for invalid note in array', async () => {
const { POST } = await import('@/app/api/export-import/route')
const request = new NextRequest('http://localhost/api/export-import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([
{
title: '',
content: 'Invalid note',
},
]),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('updates existing note when id matches', async () => {
const existingNote = {
id: '1',
title: 'Old Title',
content: 'Old content',
type: 'note',
isFavorite: false,
isPinned: false,
createdAt: new Date(),
updatedAt: new Date(),
}
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
mockPrisma.note.update.mockResolvedValue({
...existingNote,
title: 'Updated Title',
})
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
const { POST } = await import('@/app/api/export-import/route')
const request = new NextRequest('http://localhost/api/export-import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([
{
id: '1',
title: 'Updated Title',
content: 'Updated content',
type: 'note',
},
]),
})
const response = await POST(request)
expect(response.status).toBe(201)
})
})
})