diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1173ecca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + DATABASE_URL: "postgresql://test:test@localhost:5432/test" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test diff --git a/src/__tests__/api/admin-categories.test.ts b/src/__tests__/api/admin-categories.test.ts new file mode 100644 index 00000000..2d588fe1 --- /dev/null +++ b/src/__tests__/api/admin-categories.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/admin/categories/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + category: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidateTag: vi.fn(), +})); + +describe("POST /api/admin/categories", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 401 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 400 if name is missing", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Name and slug are required"); + }); + + it("should return 400 if slug is missing", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ name: "Test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Name and slug are required"); + }); + + it("should create category with required fields", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.category.create).mockResolvedValue({ + id: "1", + name: "Test Category", + slug: "test-category", + description: null, + icon: null, + parentId: null, + pinned: false, + } as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ name: "Test Category", slug: "test-category" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.name).toBe("Test Category"); + expect(data.slug).toBe("test-category"); + }); + + it("should create category with optional fields", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.category.create).mockResolvedValue({ + id: "1", + name: "Test Category", + slug: "test-category", + description: "A test category", + icon: "📚", + parentId: "parent-1", + pinned: true, + } as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ + name: "Test Category", + slug: "test-category", + description: "A test category", + icon: "📚", + parentId: "parent-1", + pinned: true, + }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.description).toBe("A test category"); + expect(data.icon).toBe("📚"); + expect(data.pinned).toBe(true); + }); + + it("should call db.category.create with correct data", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.category.create).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/admin/categories", { + method: "POST", + body: JSON.stringify({ + name: "My Category", + slug: "my-category", + description: "Description", + icon: "🎯", + parentId: "parent-id", + pinned: true, + }), + }); + await POST(request); + + expect(db.category.create).toHaveBeenCalledWith({ + data: { + name: "My Category", + slug: "my-category", + description: "Description", + icon: "🎯", + parentId: "parent-id", + pinned: true, + }, + }); + }); +}); diff --git a/src/__tests__/api/admin-prompts.test.ts b/src/__tests__/api/admin-prompts.test.ts new file mode 100644 index 00000000..11f977c9 --- /dev/null +++ b/src/__tests__/api/admin-prompts.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET } from "@/app/api/admin/prompts/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + prompt: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("GET /api/admin/prompts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/admin/prompts"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 403 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/admin/prompts"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should return prompts with pagination for admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([ + { + id: "1", + title: "Test Prompt", + slug: "test-prompt", + type: "TEXT", + isPrivate: false, + isUnlisted: false, + isFeatured: false, + viewCount: 100, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + author: { id: "user1", username: "user", name: "User", avatar: null }, + category: null, + _count: { votes: 5, reports: 0 }, + }, + ] as never); + vi.mocked(db.prompt.count).mockResolvedValue(1); + + const request = new Request("http://localhost:3000/api/admin/prompts"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.prompts).toHaveLength(1); + expect(data.pagination.total).toBe(1); + }); + + it("should apply search filter", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?search=test"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ title: expect.any(Object) }), + expect.objectContaining({ content: expect.any(Object) }), + ]), + }), + }) + ); + }); + + it("should apply filter for unlisted prompts", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?filter=unlisted"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isUnlisted: true }), + }) + ); + }); + + it("should apply filter for private prompts", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?filter=private"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isPrivate: true }), + }) + ); + }); + + it("should apply filter for featured prompts", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?filter=featured"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isFeatured: true }), + }) + ); + }); + + it("should handle pagination parameters", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(50); + + const request = new Request("http://localhost:3000/api/admin/prompts?page=2&limit=10"); + const response = await GET(request); + const data = await response.json(); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 10, + }) + ); + expect(data.pagination.page).toBe(2); + expect(data.pagination.limit).toBe(10); + expect(data.pagination.totalPages).toBe(5); + }); + + it("should limit max items per page to 100", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?limit=500"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 100, + }) + ); + }); + + it("should handle sorting parameters", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?sortBy=title&sortOrder=asc"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { title: "asc" }, + }) + ); + }); + + it("should default to createdAt desc for invalid sort field", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/prompts?sortBy=invalid"); + await GET(request); + + expect(db.prompt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { createdAt: "desc" }, + }) + ); + }); +}); diff --git a/src/__tests__/api/admin-tags.test.ts b/src/__tests__/api/admin-tags.test.ts new file mode 100644 index 00000000..1087b354 --- /dev/null +++ b/src/__tests__/api/admin-tags.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/admin/tags/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + tag: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("POST /api/admin/tags", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 401 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 400 if name is missing", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ slug: "test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Name and slug are required"); + }); + + it("should return 400 if slug is missing", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "Test" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Name and slug are required"); + }); + + it("should create tag with required fields", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.tag.create).mockResolvedValue({ + id: "1", + name: "JavaScript", + slug: "javascript", + color: "#6366f1", + } as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "JavaScript", slug: "javascript" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.name).toBe("JavaScript"); + expect(data.slug).toBe("javascript"); + expect(data.color).toBe("#6366f1"); + }); + + it("should create tag with custom color", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.tag.create).mockResolvedValue({ + id: "1", + name: "Python", + slug: "python", + color: "#3776ab", + } as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "Python", slug: "python", color: "#3776ab" }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.color).toBe("#3776ab"); + }); + + it("should use default color when not provided", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.tag.create).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/admin/tags", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: "test" }), + }); + await POST(request); + + expect(db.tag.create).toHaveBeenCalledWith({ + data: { + name: "Test", + slug: "test", + color: "#6366f1", + }, + }); + }); +}); diff --git a/src/__tests__/api/admin-users.test.ts b/src/__tests__/api/admin-users.test.ts new file mode 100644 index 00000000..9a224ae6 --- /dev/null +++ b/src/__tests__/api/admin-users.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET } from "@/app/api/admin/users/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + user: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("GET /api/admin/users", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/admin/users"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 403 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/admin/users"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should return users with pagination for admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([ + { + id: "1", + email: "test@example.com", + username: "testuser", + name: "Test User", + avatar: null, + role: "USER", + verified: true, + flagged: false, + flaggedAt: null, + flaggedReason: null, + dailyGenerationLimit: 10, + generationCreditsRemaining: 5, + createdAt: new Date(), + _count: { prompts: 3 }, + }, + ] as never); + vi.mocked(db.user.count).mockResolvedValue(1); + + const request = new Request("http://localhost:3000/api/admin/users"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.users).toHaveLength(1); + expect(data.pagination.total).toBe(1); + }); + + it("should apply search filter", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?search=john"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ email: expect.any(Object) }), + expect.objectContaining({ username: expect.any(Object) }), + expect.objectContaining({ name: expect.any(Object) }), + ]), + }), + }) + ); + }); + + it("should filter by admin role", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?filter=admin"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ role: "ADMIN" }), + }) + ); + }); + + it("should filter by verified status", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?filter=verified"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ verified: true }), + }) + ); + }); + + it("should filter by unverified status", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?filter=unverified"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ verified: false }), + }) + ); + }); + + it("should filter by flagged status", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?filter=flagged"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ flagged: true }), + }) + ); + }); + + it("should handle pagination", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(100); + + const request = new Request("http://localhost:3000/api/admin/users?page=3&limit=25"); + const response = await GET(request); + const data = await response.json(); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 50, + take: 25, + }) + ); + expect(data.pagination.page).toBe(3); + expect(data.pagination.totalPages).toBe(4); + }); + + it("should sort by username ascending", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const request = new Request("http://localhost:3000/api/admin/users?sortBy=username&sortOrder=asc"); + await GET(request); + + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { username: "asc" }, + }) + ); + }); +}); diff --git a/src/__tests__/api/comment-flag.test.ts b/src/__tests__/api/comment-flag.test.ts new file mode 100644 index 00000000..aa3eb08a --- /dev/null +++ b/src/__tests__/api/comment-flag.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/prompts/[id]/comments/[commentId]/flag/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { getConfig } from "@/lib/config"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + comment: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/lib/config", () => ({ + getConfig: vi.fn(), +})); + +describe("POST /api/prompts/[id]/comments/[commentId]/flag", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConfig).mockResolvedValue({ features: { comments: true } } as never); + }); + + it("should return 403 if comments feature is disabled", async () => { + vi.mocked(getConfig).mockResolvedValue({ features: { comments: false } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("feature_disabled"); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 403 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should return 404 for non-existent comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should return 404 if comment belongs to different prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "different-prompt", + flagged: false, + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should flag an unflagged comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + flagged: false, + } as never); + vi.mocked(db.comment.update).mockResolvedValue({ flagged: true } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.flagged).toBe(true); + }); + + it("should unflag a flagged comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + flagged: true, + } as never); + vi.mocked(db.comment.update).mockResolvedValue({ flagged: false } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.flagged).toBe(false); + }); + + it("should set flaggedAt and flaggedBy when flagging", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + flagged: false, + } as never); + vi.mocked(db.comment.update).mockResolvedValue({ flagged: true } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { + flagged: true, + flaggedAt: expect.any(Date), + flaggedBy: "admin1", + }, + }); + }); + + it("should clear flaggedAt and flaggedBy when unflagging", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + flagged: true, + } as never); + vi.mocked(db.comment.update).mockResolvedValue({ flagged: false } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/flag", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { + flagged: false, + flaggedAt: null, + flaggedBy: null, + }, + }); + }); +}); diff --git a/src/__tests__/api/comment-operations.test.ts b/src/__tests__/api/comment-operations.test.ts new file mode 100644 index 00000000..4f733b69 --- /dev/null +++ b/src/__tests__/api/comment-operations.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DELETE } from "@/app/api/prompts/[id]/comments/[commentId]/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { getConfig } from "@/lib/config"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + comment: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/lib/config", () => ({ + getConfig: vi.fn(), +})); + +describe("DELETE /api/prompts/[id]/comments/[commentId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConfig).mockResolvedValue({ features: { comments: true } } as never); + }); + + it("should return 403 if comments feature is disabled", async () => { + vi.mocked(getConfig).mockResolvedValue({ features: { comments: false } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("feature_disabled"); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 404 for non-existent comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should return 404 if comment belongs to different prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "different-prompt", // Different from params + authorId: "user1", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should return 403 if user is not author or admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + authorId: "other-user", // Different author + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should allow author to delete own comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + authorId: "user1", // Same as session user + } as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.deleted).toBe(true); + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it("should allow admin to delete any comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + authorId: "other-user", // Different user, but admin can delete + } as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.deleted).toBe(true); + }); + + it("should soft delete by setting deletedAt timestamp", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + authorId: "user1", + } as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456", { + method: "DELETE", + }); + await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { deletedAt: expect.any(Date) }, + }); + }); +}); diff --git a/src/__tests__/api/comment-vote.test.ts b/src/__tests__/api/comment-vote.test.ts new file mode 100644 index 00000000..7699a7a1 --- /dev/null +++ b/src/__tests__/api/comment-vote.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST, DELETE } from "@/app/api/prompts/[id]/comments/[commentId]/vote/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { getConfig } from "@/lib/config"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + comment: { + findUnique: vi.fn(), + update: vi.fn(), + }, + commentVote: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/lib/config", () => ({ + getConfig: vi.fn(), +})); + +describe("POST /api/prompts/[id]/comments/[commentId]/vote", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConfig).mockResolvedValue({ features: { comments: true } } as never); + }); + + it("should return 403 if comments feature is disabled", async () => { + vi.mocked(getConfig).mockResolvedValue({ features: { comments: false } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("feature_disabled"); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 400 for invalid vote value", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 5 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 400 for missing vote value", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({}), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 400 for value of 0", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 0 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 404 for non-existent comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should return 404 if comment belongs to different prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "different-prompt", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should create new upvote when no existing vote", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + } as never); + vi.mocked(db.commentVote.findUnique) + .mockResolvedValueOnce(null) // No existing vote + .mockResolvedValueOnce({ value: 1 } as never); // After create + vi.mocked(db.commentVote.create).mockResolvedValue({} as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([{ value: 1 }] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(1); + expect(data.userVote).toBe(1); + expect(db.commentVote.create).toHaveBeenCalledWith({ + data: { + userId: "user1", + commentId: "456", + value: 1, + }, + }); + }); + + it("should create new downvote when no existing vote", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + } as never); + vi.mocked(db.commentVote.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ value: -1 } as never); + vi.mocked(db.commentVote.create).mockResolvedValue({} as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([{ value: -1 }] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: -1 }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(-1); + expect(data.userVote).toBe(-1); + }); + + it("should toggle off when voting same value twice", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + } as never); + vi.mocked(db.commentVote.findUnique) + .mockResolvedValueOnce({ value: 1 } as never) // Existing upvote + .mockResolvedValueOnce(null); // After deletion + vi.mocked(db.commentVote.delete).mockResolvedValue({} as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), // Same as existing + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(0); + expect(data.userVote).toBe(0); + expect(db.commentVote.delete).toHaveBeenCalled(); + }); + + it("should switch vote when voting opposite value", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + } as never); + vi.mocked(db.commentVote.findUnique) + .mockResolvedValueOnce({ value: 1 } as never) // Existing upvote + .mockResolvedValueOnce({ value: -1 } as never); // After update + vi.mocked(db.commentVote.update).mockResolvedValue({} as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([{ value: -1 }] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: -1 }), // Opposite of existing + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(-1); + expect(data.userVote).toBe(-1); + expect(db.commentVote.update).toHaveBeenCalled(); + }); + + it("should update cached score on comment", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.comment.findUnique).mockResolvedValue({ + id: "456", + promptId: "123", + } as never); + vi.mocked(db.commentVote.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ value: 1 } as never); + vi.mocked(db.commentVote.create).mockResolvedValue({} as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([ + { value: 1 }, + { value: 1 }, + { value: -1 }, + ] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "POST", + body: JSON.stringify({ value: 1 }), + }); + await POST(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { score: 1 }, // 1 + 1 - 1 = 1 + }); + }); +}); + +describe("DELETE /api/prompts/[id]/comments/[commentId]/vote", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConfig).mockResolvedValue({ features: { comments: true } } as never); + }); + + it("should return 403 if comments feature is disabled", async () => { + vi.mocked(getConfig).mockResolvedValue({ features: { comments: false } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("feature_disabled"); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should remove vote and return updated score", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.commentVote.deleteMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([{ value: 1 }] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(1); + expect(data.userVote).toBe(0); + }); + + it("should handle removing non-existent vote gracefully", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.commentVote.deleteMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.score).toBe(0); + expect(data.userVote).toBe(0); + }); + + it("should update cached score on comment after removing vote", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.commentVote.deleteMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(db.commentVote.findMany).mockResolvedValue([ + { value: 1 }, + { value: -1 }, + ] as never); + vi.mocked(db.comment.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/comments/456/vote", { + method: "DELETE", + }); + await DELETE(request, { + params: Promise.resolve({ id: "123", commentId: "456" }), + }); + + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "456" }, + data: { score: 0 }, // 1 - 1 = 0 + }); + }); +}); diff --git a/src/__tests__/api/pin.test.ts b/src/__tests__/api/pin.test.ts new file mode 100644 index 00000000..d78b65cd --- /dev/null +++ b/src/__tests__/api/pin.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST, DELETE } from "@/app/api/prompts/[id]/pin/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + prompt: { + findUnique: vi.fn(), + }, + pinnedPrompt: { + findUnique: vi.fn(), + count: vi.fn(), + aggregate: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("POST /api/prompts/[id]/pin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 401 if session has no user id", async () => { + vi.mocked(auth).mockResolvedValue({ user: {} } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 404 for non-existent prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Prompt not found"); + }); + + it("should return 403 when pinning another user's prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "other-user", + isPrivate: false, + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("You can only pin your own prompts"); + }); + + it("should return 400 if prompt already pinned", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + isPrivate: false, + } as never); + vi.mocked(db.pinnedPrompt.findUnique).mockResolvedValue({ + userId: "user1", + promptId: "123", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Prompt already pinned"); + }); + + it("should return 400 if pin limit (3) reached", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + isPrivate: false, + } as never); + vi.mocked(db.pinnedPrompt.findUnique).mockResolvedValue(null); + vi.mocked(db.pinnedPrompt.count).mockResolvedValue(3); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("You can only pin up to 3 prompts"); + }); + + it("should create pin with order 0 when no existing pins", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + isPrivate: false, + } as never); + vi.mocked(db.pinnedPrompt.findUnique).mockResolvedValue(null); + vi.mocked(db.pinnedPrompt.count).mockResolvedValue(0); + vi.mocked(db.pinnedPrompt.aggregate).mockResolvedValue({ + _max: { order: null }, + } as never); + vi.mocked(db.pinnedPrompt.create).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.pinned).toBe(true); + expect(db.pinnedPrompt.create).toHaveBeenCalledWith({ + data: { + userId: "user1", + promptId: "123", + order: 0, + }, + }); + }); + + it("should increment order for subsequent pins", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + isPrivate: false, + } as never); + vi.mocked(db.pinnedPrompt.findUnique).mockResolvedValue(null); + vi.mocked(db.pinnedPrompt.count).mockResolvedValue(2); + vi.mocked(db.pinnedPrompt.aggregate).mockResolvedValue({ + _max: { order: 1 }, + } as never); + vi.mocked(db.pinnedPrompt.create).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.pinnedPrompt.create).toHaveBeenCalledWith({ + data: { + userId: "user1", + promptId: "123", + order: 2, + }, + }); + }); + + it("should return success: true, pinned: true on successful pin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + isPrivate: false, + } as never); + vi.mocked(db.pinnedPrompt.findUnique).mockResolvedValue(null); + vi.mocked(db.pinnedPrompt.count).mockResolvedValue(0); + vi.mocked(db.pinnedPrompt.aggregate).mockResolvedValue({ + _max: { order: null }, + } as never); + vi.mocked(db.pinnedPrompt.create).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ success: true, pinned: true }); + }); +}); + +describe("DELETE /api/prompts/[id]/pin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should remove pin successfully", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.pinnedPrompt.deleteMany).mockResolvedValue({ count: 1 } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.pinned).toBe(false); + }); + + it("should call deleteMany with correct parameters", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.pinnedPrompt.deleteMany).mockResolvedValue({ count: 1 } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "DELETE", + }); + await DELETE(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.pinnedPrompt.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user1", + promptId: "123", + }, + }); + }); + + it("should handle unpinning non-pinned prompt gracefully", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.pinnedPrompt.deleteMany).mockResolvedValue({ count: 0 } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ success: true, pinned: false }); + }); + + it("should return success: true, pinned: false on successful unpin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.pinnedPrompt.deleteMany).mockResolvedValue({ count: 1 } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/pin", { + method: "DELETE", + }); + const response = await DELETE(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ success: true, pinned: false }); + }); +}); diff --git a/src/__tests__/api/prompt-connections.test.ts b/src/__tests__/api/prompt-connections.test.ts new file mode 100644 index 00000000..50aaeab3 --- /dev/null +++ b/src/__tests__/api/prompt-connections.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET, POST } from "@/app/api/prompts/[id]/connections/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + prompt: { + findUnique: vi.fn(), + }, + promptConnection: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidateTag: vi.fn(), +})); + +describe("GET /api/prompts/[id]/connections", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 404 for non-existent prompt", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/connections"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Prompt not found"); + }); + + it("should return empty connections for prompt with none", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + id: "123", + isPrivate: false, + authorId: "user1", + } as never); + vi.mocked(db.promptConnection.findMany).mockResolvedValue([]); + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/connections"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.outgoing).toEqual([]); + expect(data.incoming).toEqual([]); + }); + + it("should return outgoing and incoming connections", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + id: "123", + isPrivate: false, + authorId: "user1", + } as never); + vi.mocked(db.promptConnection.findMany) + .mockResolvedValueOnce([ + { + id: "conn1", + label: "next", + order: 0, + target: { id: "target1", title: "Target Prompt", slug: "target", isPrivate: false, authorId: "user1" }, + }, + ] as never) + .mockResolvedValueOnce([ + { + id: "conn2", + label: "previous", + order: 0, + source: { id: "source1", title: "Source Prompt", slug: "source", isPrivate: false, authorId: "user2" }, + }, + ] as never); + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.outgoing).toHaveLength(1); + expect(data.incoming).toHaveLength(1); + }); + + it("should filter out private prompts the user cannot see", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + id: "123", + isPrivate: false, + authorId: "user1", + } as never); + vi.mocked(db.promptConnection.findMany) + .mockResolvedValueOnce([ + { + id: "conn1", + label: "next", + target: { id: "target1", title: "Private", slug: "private", isPrivate: true, authorId: "other-user" }, + }, + ] as never) + .mockResolvedValueOnce([]); + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(data.outgoing).toHaveLength(0); + }); + + it("should show private prompts owned by the user", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + id: "123", + isPrivate: false, + authorId: "user1", + } as never); + vi.mocked(db.promptConnection.findMany) + .mockResolvedValueOnce([ + { + id: "conn1", + label: "next", + target: { id: "target1", title: "My Private", slug: "private", isPrivate: true, authorId: "user1" }, + }, + ] as never) + .mockResolvedValueOnce([]); + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(data.outgoing).toHaveLength(1); + }); +}); + +describe("POST /api/prompts/[id]/connections", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 404 if source prompt not found", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Source prompt not found"); + }); + + it("should return 403 if user does not own source prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ authorId: "other-user" } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("You can only add connections to your own prompts"); + }); + + it("should return 404 if target prompt not found", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) // Source + .mockResolvedValueOnce(null); // Target + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Target prompt not found"); + }); + + it("should return 403 if user does not own target prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) // Source + .mockResolvedValueOnce({ id: "456", authorId: "other-user" } as never); // Target + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("You can only connect to your own prompts"); + }); + + it("should return 400 for self-connection", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) + .mockResolvedValueOnce({ id: "123", authorId: "user1" } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "123", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Cannot connect a prompt to itself"); + }); + + it("should return 400 if connection already exists", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) + .mockResolvedValueOnce({ id: "456", authorId: "user1" } as never); + vi.mocked(db.promptConnection.findUnique).mockResolvedValue({ id: "existing" } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Connection already exists"); + }); + + it("should create connection successfully", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) + .mockResolvedValueOnce({ id: "456", authorId: "user1" } as never); + vi.mocked(db.promptConnection.findUnique).mockResolvedValue(null); + vi.mocked(db.promptConnection.findFirst).mockResolvedValue(null); + vi.mocked(db.promptConnection.create).mockResolvedValue({ + id: "conn1", + sourceId: "123", + targetId: "456", + label: "next", + order: 0, + target: { id: "456", title: "Target", slug: "target" }, + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.label).toBe("next"); + }); + + it("should auto-increment order when not provided", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "user1" } as never) + .mockResolvedValueOnce({ id: "456", authorId: "user1" } as never); + vi.mocked(db.promptConnection.findUnique).mockResolvedValue(null); + vi.mocked(db.promptConnection.findFirst).mockResolvedValue({ order: 2 } as never); + vi.mocked(db.promptConnection.create).mockResolvedValue({ + id: "conn1", + order: 3, + target: {}, + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "next" }), + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.promptConnection.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ order: 3 }), + }) + ); + }); + + it("should return 400 for missing required fields", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456" }), // Missing label + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(response.status).toBe(400); + }); + + it("should allow admin to create connections for any prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique) + .mockResolvedValueOnce({ authorId: "other-user" } as never) + .mockResolvedValueOnce({ id: "456", authorId: "another-user" } as never); + vi.mocked(db.promptConnection.findUnique).mockResolvedValue(null); + vi.mocked(db.promptConnection.findFirst).mockResolvedValue(null); + vi.mocked(db.promptConnection.create).mockResolvedValue({ + id: "conn1", + target: {}, + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/connections", { + method: "POST", + body: JSON.stringify({ targetId: "456", label: "admin-link" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(response.status).toBe(201); + }); +}); diff --git a/src/__tests__/api/prompt-feature.test.ts b/src/__tests__/api/prompt-feature.test.ts new file mode 100644 index 00000000..9747bb19 --- /dev/null +++ b/src/__tests__/api/prompt-feature.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/prompts/[id]/feature/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + user: { + findUnique: vi.fn(), + }, + prompt: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("POST /api/prompts/[id]/feature", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 403 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "USER" } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Forbidden"); + }); + + it("should return 404 for non-existent prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "ADMIN" } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Prompt not found"); + }); + + it("should toggle featured status from false to true", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "ADMIN" } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ isFeatured: false } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isFeatured: true } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.isFeatured).toBe(true); + }); + + it("should toggle featured status from true to false", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "ADMIN" } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ isFeatured: true } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isFeatured: false } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.isFeatured).toBe(false); + }); + + it("should set featuredAt when featuring a prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "ADMIN" } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ isFeatured: false } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isFeatured: true } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.prompt.update).toHaveBeenCalledWith({ + where: { id: "123" }, + data: { + isFeatured: true, + featuredAt: expect.any(Date), + }, + }); + }); + + it("should clear featuredAt when unfeaturing a prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "ADMIN" } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ isFeatured: true } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isFeatured: false } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/feature", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.prompt.update).toHaveBeenCalledWith({ + where: { id: "123" }, + data: { + isFeatured: false, + featuredAt: null, + }, + }); + }); +}); diff --git a/src/__tests__/api/prompt-unlist.test.ts b/src/__tests__/api/prompt-unlist.test.ts new file mode 100644 index 00000000..d1173d05 --- /dev/null +++ b/src/__tests__/api/prompt-unlist.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/prompts/[id]/unlist/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + prompt: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidateTag: vi.fn(), +})); + +describe("POST /api/prompts/[id]/unlist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 403 if user is not admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should return 404 for non-existent prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should toggle unlisted status from false to true", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ id: "123", isUnlisted: false } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isUnlisted: true } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.isUnlisted).toBe(true); + expect(data.message).toBe("Prompt unlisted"); + }); + + it("should toggle unlisted status from true to false (relist)", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ id: "123", isUnlisted: true } as never); + vi.mocked(db.prompt.update).mockResolvedValue({ isUnlisted: false } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.isUnlisted).toBe(false); + expect(data.message).toBe("Prompt relisted"); + }); + + it("should set unlistedAt when unlisting", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ id: "123", isUnlisted: false } as never); + vi.mocked(db.prompt.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.prompt.update).toHaveBeenCalledWith({ + where: { id: "123" }, + data: { + isUnlisted: true, + unlistedAt: expect.any(Date), + }, + }); + }); + + it("should clear unlistedAt when relisting", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ id: "123", isUnlisted: true } as never); + vi.mocked(db.prompt.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/prompts/123/unlist", { + method: "POST", + }); + await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.prompt.update).toHaveBeenCalledWith({ + where: { id: "123" }, + data: { + isUnlisted: false, + unlistedAt: null, + }, + }); + }); +}); diff --git a/src/__tests__/api/user-api-key.test.ts b/src/__tests__/api/user-api-key.test.ts new file mode 100644 index 00000000..2d0ae7a8 --- /dev/null +++ b/src/__tests__/api/user-api-key.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET, POST, DELETE, PATCH } from "@/app/api/user/api-key/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { generateApiKey } from "@/lib/api-key"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/lib/api-key", () => ({ + generateApiKey: vi.fn(), +})); + +describe("GET /api/user/api-key", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 401 if session has no user id", async () => { + vi.mocked(auth).mockResolvedValue({ user: {} } as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 404 if user not found", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue(null); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("User not found"); + }); + + it("should return hasApiKey: false when no key exists", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ + apiKey: null, + mcpPromptsPublicByDefault: true, + } as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.hasApiKey).toBe(false); + expect(data.apiKey).toBeNull(); + }); + + it("should return hasApiKey: true and key when exists", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ + apiKey: "pchat_abc123def456", + mcpPromptsPublicByDefault: true, + } as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.hasApiKey).toBe(true); + expect(data.apiKey).toBe("pchat_abc123def456"); + }); + + it("should return mcpPromptsPublicByDefault setting", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.findUnique).mockResolvedValue({ + apiKey: "pchat_abc123", + mcpPromptsPublicByDefault: false, + } as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.mcpPromptsPublicByDefault).toBe(false); + }); +}); + +describe("POST /api/user/api-key", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const response = await POST(); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should generate and return new API key", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(generateApiKey).mockReturnValue("pchat_newkey123"); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + const response = await POST(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.apiKey).toBe("pchat_newkey123"); + }); + + it("should update user with new key", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(generateApiKey).mockReturnValue("pchat_newkey123"); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + await POST(); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: { apiKey: "pchat_newkey123" }, + }); + }); + + it("should call generateApiKey function", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(generateApiKey).mockReturnValue("pchat_test"); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + await POST(); + + expect(generateApiKey).toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/user/api-key", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const response = await DELETE(); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should set apiKey to null", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + await DELETE(); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: { apiKey: null }, + }); + }); + + it("should return success: true", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + const response = await DELETE(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + }); +}); + +describe("PATCH /api/user/api-key", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({ mcpPromptsPublicByDefault: true }), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 400 for missing mcpPromptsPublicByDefault", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({}), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid request"); + }); + + it("should return 400 for non-boolean value", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({ mcpPromptsPublicByDefault: "true" }), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid request"); + }); + + it("should return 400 for number value", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({ mcpPromptsPublicByDefault: 1 }), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid request"); + }); + + it("should update mcpPromptsPublicByDefault to true", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({ mcpPromptsPublicByDefault: true }), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: { mcpPromptsPublicByDefault: true }, + }); + }); + + it("should update mcpPromptsPublicByDefault to false", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.user.update).mockResolvedValue({} as never); + + const request = new Request("http://localhost:3000/api/user/api-key", { + method: "PATCH", + body: JSON.stringify({ mcpPromptsPublicByDefault: false }), + }); + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: { mcpPromptsPublicByDefault: false }, + }); + }); +}); diff --git a/src/__tests__/api/user-notifications.test.ts b/src/__tests__/api/user-notifications.test.ts new file mode 100644 index 00000000..d4e8edb1 --- /dev/null +++ b/src/__tests__/api/user-notifications.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET, POST } from "@/app/api/user/notifications/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + changeRequest: { + count: vi.fn(), + }, + notification: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + prompt: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("GET /api/user/notifications", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return default response if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + pendingChangeRequests: 0, + unreadComments: 0, + commentNotifications: [], + }); + }); + + it("should return default response if session has no user id", async () => { + vi.mocked(auth).mockResolvedValue({ user: {} } as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.pendingChangeRequests).toBe(0); + expect(data.unreadComments).toBe(0); + }); + + it("should return notifications for authenticated user", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.changeRequest.count).mockResolvedValue(3); + vi.mocked(db.notification.findMany).mockResolvedValue([ + { + id: "notif1", + type: "COMMENT", + createdAt: new Date(), + promptId: "prompt1", + actor: { + id: "user2", + name: "Commenter", + username: "commenter", + avatar: null, + }, + }, + ] as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([ + { id: "prompt1", title: "Test Prompt" }, + ] as never); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.pendingChangeRequests).toBe(3); + expect(data.unreadComments).toBe(1); + expect(data.commentNotifications).toHaveLength(1); + expect(data.commentNotifications[0].promptTitle).toBe("Test Prompt"); + }); + + it("should include actor info in notifications", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.changeRequest.count).mockResolvedValue(0); + vi.mocked(db.notification.findMany).mockResolvedValue([ + { + id: "notif1", + type: "REPLY", + createdAt: new Date(), + promptId: "prompt1", + actor: { + id: "user2", + name: "Reply User", + username: "replyuser", + avatar: "avatar.jpg", + }, + }, + ] as never); + vi.mocked(db.prompt.findMany).mockResolvedValue([ + { id: "prompt1", title: "My Prompt" }, + ] as never); + + const response = await GET(); + const data = await response.json(); + + expect(data.commentNotifications[0].actor.name).toBe("Reply User"); + expect(data.commentNotifications[0].actor.avatar).toBe("avatar.jpg"); + }); + + it("should return empty notifications array when none exist", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.changeRequest.count).mockResolvedValue(0); + vi.mocked(db.notification.findMany).mockResolvedValue([]); + vi.mocked(db.prompt.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(data.commentNotifications).toEqual([]); + expect(data.unreadComments).toBe(0); + }); +}); + +describe("POST /api/user/notifications", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/user/notifications", { + method: "POST", + body: JSON.stringify({}), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + + it("should mark specific notifications as read", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.notification.updateMany).mockResolvedValue({ count: 2 } as never); + + const request = new Request("http://localhost:3000/api/user/notifications", { + method: "POST", + body: JSON.stringify({ notificationIds: ["notif1", "notif2"] }), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(db.notification.updateMany).toHaveBeenCalledWith({ + where: { + id: { in: ["notif1", "notif2"] }, + userId: "user1", + }, + data: { read: true }, + }); + }); + + it("should mark all notifications as read when no ids provided", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.notification.updateMany).mockResolvedValue({ count: 5 } as never); + + const request = new Request("http://localhost:3000/api/user/notifications", { + method: "POST", + body: JSON.stringify({}), + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(db.notification.updateMany).toHaveBeenCalledWith({ + where: { + userId: "user1", + read: false, + }, + data: { read: true }, + }); + }); + + it("should mark all notifications as read when notificationIds is not an array", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.notification.updateMany).mockResolvedValue({ count: 3 } as never); + + const request = new Request("http://localhost:3000/api/user/notifications", { + method: "POST", + body: JSON.stringify({ notificationIds: "invalid" }), + }); + const response = await POST(request); + + expect(db.notification.updateMany).toHaveBeenCalledWith({ + where: { + userId: "user1", + read: false, + }, + data: { read: true }, + }); + }); +}); diff --git a/src/__tests__/api/versions.test.ts b/src/__tests__/api/versions.test.ts new file mode 100644 index 00000000..2ef1bbe4 --- /dev/null +++ b/src/__tests__/api/versions.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET, POST } from "@/app/api/prompts/[id]/versions/route"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; + +// Mock dependencies +vi.mock("@/lib/db", () => ({ + db: { + prompt: { + findUnique: vi.fn(), + update: vi.fn(), + }, + promptVersion: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("GET /api/prompts/[id]/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return empty array for prompt with no versions", async () => { + vi.mocked(db.promptVersion.findMany).mockResolvedValue([]); + + const request = new Request("http://localhost:3000/api/prompts/123/versions"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([]); + }); + + it("should return versions ordered by version desc", async () => { + vi.mocked(db.promptVersion.findMany).mockResolvedValue([ + { + id: "v3", + version: 3, + content: "Version 3 content", + changeNote: "Version 3", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + { + id: "v2", + version: 2, + content: "Version 2 content", + changeNote: "Version 2", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + { + id: "v1", + version: 1, + content: "Version 1 content", + changeNote: "Version 1", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(3); + expect(data[0].version).toBe(3); + expect(data[1].version).toBe(2); + expect(data[2].version).toBe(1); + }); + + it("should include author info in response", async () => { + vi.mocked(db.promptVersion.findMany).mockResolvedValue([ + { + id: "v1", + version: 1, + content: "Content", + changeNote: "Initial", + createdAt: new Date(), + author: { name: "Test User", username: "testuser" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions"); + const response = await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data[0].author.name).toBe("Test User"); + expect(data[0].author.username).toBe("testuser"); + }); + + it("should call findMany with correct parameters", async () => { + vi.mocked(db.promptVersion.findMany).mockResolvedValue([]); + + const request = new Request("http://localhost:3000/api/prompts/123/versions"); + await GET(request, { + params: Promise.resolve({ id: "123" }), + }); + + expect(db.promptVersion.findMany).toHaveBeenCalledWith({ + where: { promptId: "123" }, + orderBy: { version: "desc" }, + include: { + author: { + select: { + name: true, + username: true, + }, + }, + }, + }); + }); +}); + +describe("POST /api/prompts/[id]/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("unauthorized"); + }); + + it("should return 404 for non-existent prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue(null); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("not_found"); + }); + + it("should return 403 if user does not own the prompt", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "other-user", + content: "Original content", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("forbidden"); + }); + + it("should return 400 for empty content", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 400 for missing content", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({}), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 400 when content is same as current version", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Same content", + } as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "Same content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("no_change"); + }); + + it("should create version with incrementing version number", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + vi.mocked(db.promptVersion.findFirst).mockResolvedValue({ + version: 2, + } as never); + vi.mocked(db.$transaction).mockResolvedValue([ + { + id: "v3", + version: 3, + content: "New content", + changeNote: "Version 3", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.version).toBe(3); + }); + + it("should use default changeNote when not provided", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + vi.mocked(db.promptVersion.findFirst).mockResolvedValue(null); + vi.mocked(db.$transaction).mockImplementation(async (ops) => { + // Capture the create call to verify changeNote + return [ + { + id: "v1", + version: 1, + content: "New content", + changeNote: "Version 1", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + ]; + }); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.changeNote).toBe("Version 1"); + }); + + it("should use custom changeNote when provided", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + vi.mocked(db.promptVersion.findFirst).mockResolvedValue(null); + vi.mocked(db.$transaction).mockResolvedValue([ + { + id: "v1", + version: 1, + content: "New content", + changeNote: "Fixed typo in instructions", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ + content: "New content", + changeNote: "Fixed typo in instructions", + }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.changeNote).toBe("Fixed typo in instructions"); + }); + + it("should start at version 1 when no previous versions exist", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + vi.mocked(db.promptVersion.findFirst).mockResolvedValue(null); + vi.mocked(db.$transaction).mockResolvedValue([ + { + id: "v1", + version: 1, + content: "New content", + changeNote: "Version 1", + createdAt: new Date(), + author: { name: "User", username: "user" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.version).toBe(1); + }); + + it("should return created version with author info", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1" } } as never); + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + authorId: "user1", + content: "Original content", + } as never); + vi.mocked(db.promptVersion.findFirst).mockResolvedValue(null); + vi.mocked(db.$transaction).mockResolvedValue([ + { + id: "v1", + version: 1, + content: "New content", + changeNote: "Version 1", + createdAt: new Date(), + author: { name: "Test User", username: "testuser" }, + }, + ] as never); + + const request = new Request("http://localhost:3000/api/prompts/123/versions", { + method: "POST", + body: JSON.stringify({ content: "New content" }), + }); + const response = await POST(request, { + params: Promise.resolve({ id: "123" }), + }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.author.name).toBe("Test User"); + expect(data.author.username).toBe("testuser"); + }); +}); diff --git a/src/__tests__/hooks/use-debounce.test.ts b/src/__tests__/hooks/use-debounce.test.ts new file mode 100644 index 00000000..da9dd629 --- /dev/null +++ b/src/__tests__/hooks/use-debounce.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDebounce } from "@/lib/hooks/use-debounce"; + +describe("useDebounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns initial value immediately", () => { + const { result } = renderHook(() => useDebounce("initial", 500)); + expect(result.current).toBe("initial"); + }); + + it("does not update value before delay", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 500 } } + ); + + rerender({ value: "updated", delay: 500 }); + + // Advance time, but not past the delay + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe("initial"); + }); + + it("updates value after delay", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 500 } } + ); + + rerender({ value: "updated", delay: 500 }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe("updated"); + }); + + it("resets timer on rapid value changes", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 500 } } + ); + + // First update + rerender({ value: "update1", delay: 500 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe("initial"); + + // Second update before delay completes + rerender({ value: "update2", delay: 500 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe("initial"); + + // Complete delay for second update + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe("update2"); + }); + + it("works with different data types", () => { + // Number + const { result: numResult, rerender: numRerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 0, delay: 100 } } + ); + + numRerender({ value: 42, delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(numResult.current).toBe(42); + + // Object + const initialObj = { key: "value" }; + const updatedObj = { key: "updated" }; + + const { result: objResult, rerender: objRerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: initialObj, delay: 100 } } + ); + + objRerender({ value: updatedObj, delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(objResult.current).toEqual(updatedObj); + + // Array + const { result: arrResult, rerender: arrRerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: [1, 2, 3], delay: 100 } } + ); + + arrRerender({ value: [4, 5, 6], delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(arrResult.current).toEqual([4, 5, 6]); + }); + + it("works with boolean values", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: false, delay: 100 } } + ); + + expect(result.current).toBe(false); + + rerender({ value: true, delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe(true); + }); + + it("works with null and undefined", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: null as string | null, delay: 100 } } + ); + + expect(result.current).toBeNull(); + + rerender({ value: "defined", delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe("defined"); + + rerender({ value: null, delay: 100 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBeNull(); + }); + + it("handles delay changes", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 500 } } + ); + + // Change value and delay simultaneously + rerender({ value: "updated", delay: 200 }); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe("updated"); + }); + + it("handles zero delay", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 0 } } + ); + + rerender({ value: "updated", delay: 0 }); + + act(() => { + vi.advanceTimersByTime(0); + }); + expect(result.current).toBe("updated"); + }); + + it("cleans up timeout on unmount", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + const { unmount, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "initial", delay: 500 } } + ); + + rerender({ value: "updated", delay: 500 }); + unmount(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + describe("real-world search scenarios", () => { + it("debounces search input effectively", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: "", delay: 300 } } + ); + + // Simulate typing "hello" + rerender({ value: "h", delay: 300 }); + act(() => vi.advanceTimersByTime(50)); + + rerender({ value: "he", delay: 300 }); + act(() => vi.advanceTimersByTime(50)); + + rerender({ value: "hel", delay: 300 }); + act(() => vi.advanceTimersByTime(50)); + + rerender({ value: "hell", delay: 300 }); + act(() => vi.advanceTimersByTime(50)); + + rerender({ value: "hello", delay: 300 }); + + // Still initial value (user typing fast) + expect(result.current).toBe(""); + + // Wait for debounce + act(() => vi.advanceTimersByTime(300)); + expect(result.current).toBe("hello"); + }); + }); +}); diff --git a/src/__tests__/lib/skill-files.test.ts b/src/__tests__/lib/skill-files.test.ts new file mode 100644 index 00000000..5d2e562b --- /dev/null +++ b/src/__tests__/lib/skill-files.test.ts @@ -0,0 +1,528 @@ +import { describe, it, expect } from "vitest"; +import { + parseSkillFiles, + serializeSkillFiles, + getLanguageFromFilename, + validateFilename, + isValidKebabCase, + parseSkillFrontmatter, + validateSkillFrontmatter, + generateSkillContentWithFrontmatter, + suggestFilename, + DEFAULT_SKILL_FILE, + DEFAULT_SKILL_CONTENT, + type SkillFile, +} from "@/lib/skill-files"; + +describe("parseSkillFiles", () => { + it("returns default content for empty string", () => { + const result = parseSkillFiles(""); + expect(result).toHaveLength(1); + expect(result[0].filename).toBe(DEFAULT_SKILL_FILE); + expect(result[0].content).toBe(DEFAULT_SKILL_CONTENT); + }); + + it("returns default content for whitespace-only string", () => { + const result = parseSkillFiles(" \n\t "); + expect(result).toHaveLength(1); + expect(result[0].filename).toBe(DEFAULT_SKILL_FILE); + }); + + it("parses single file content without separators", () => { + const content = "# My Skill\n\nSome content here"; + const result = parseSkillFiles(content); + expect(result).toHaveLength(1); + expect(result[0].filename).toBe(DEFAULT_SKILL_FILE); + expect(result[0].content).toBe(content); + }); + + it("parses multiple files with separators", () => { + const content = "# Main Skill\n\x1FFILE:helper.ts\x1E\nconst x = 1;\n\x1FFILE:config.json\x1E\n{}"; + const result = parseSkillFiles(content); + + expect(result).toHaveLength(3); + expect(result[0].filename).toBe(DEFAULT_SKILL_FILE); + expect(result[0].content).toBe("# Main Skill"); + expect(result[1].filename).toBe("helper.ts"); + expect(result[1].content).toBe("const x = 1;"); + expect(result[2].filename).toBe("config.json"); + expect(result[2].content).toBe("{}"); + }); + + it("ignores SKILL.md in file separators (already handled as first file)", () => { + const content = "# Main\n\x1FFILE:SKILL.md\x1E\nshould be ignored"; + const result = parseSkillFiles(content); + + expect(result).toHaveLength(1); + expect(result[0].filename).toBe(DEFAULT_SKILL_FILE); + }); + + it("handles empty file content after separator", () => { + const content = "# Main\n\x1FFILE:empty.ts\x1E\n"; + const result = parseSkillFiles(content); + + expect(result).toHaveLength(2); + expect(result[1].filename).toBe("empty.ts"); + expect(result[1].content).toBe(""); + }); +}); + +describe("serializeSkillFiles", () => { + it("returns default content for empty array", () => { + expect(serializeSkillFiles([])).toBe(DEFAULT_SKILL_CONTENT); + }); + + it("serializes single SKILL.md file", () => { + const files: SkillFile[] = [ + { filename: DEFAULT_SKILL_FILE, content: "# My Skill" } + ]; + expect(serializeSkillFiles(files)).toBe("# My Skill"); + }); + + it("serializes multiple files with separators", () => { + const files: SkillFile[] = [ + { filename: DEFAULT_SKILL_FILE, content: "# Main" }, + { filename: "helper.ts", content: "const x = 1;" }, + ]; + const result = serializeSkillFiles(files); + + expect(result).toContain("# Main"); + expect(result).toContain("\x1FFILE:helper.ts\x1E"); + expect(result).toContain("const x = 1;"); + }); + + it("puts SKILL.md content first regardless of array order", () => { + const files: SkillFile[] = [ + { filename: "other.ts", content: "other" }, + { filename: DEFAULT_SKILL_FILE, content: "# Main" }, + ]; + const result = serializeSkillFiles(files); + + expect(result.startsWith("# Main")).toBe(true); + }); + + it("uses default content if SKILL.md is missing", () => { + const files: SkillFile[] = [ + { filename: "other.ts", content: "other" } + ]; + const result = serializeSkillFiles(files); + + expect(result.startsWith(DEFAULT_SKILL_CONTENT)).toBe(true); + }); + + it("is reversible with parseSkillFiles", () => { + const files: SkillFile[] = [ + { filename: DEFAULT_SKILL_FILE, content: "# Main Skill" }, + { filename: "helper.ts", content: "export const x = 1;" }, + { filename: "config.json", content: '{ "key": "value" }' }, + ]; + + const serialized = serializeSkillFiles(files); + const parsed = parseSkillFiles(serialized); + + expect(parsed).toHaveLength(3); + expect(parsed[0]).toEqual(files[0]); + expect(parsed[1]).toEqual(files[1]); + expect(parsed[2]).toEqual(files[2]); + }); +}); + +describe("getLanguageFromFilename", () => { + describe("markdown files", () => { + it("detects .md files", () => { + expect(getLanguageFromFilename("README.md")).toBe("markdown"); + expect(getLanguageFromFilename("SKILL.md")).toBe("markdown"); + }); + + it("detects .mdx files", () => { + expect(getLanguageFromFilename("page.mdx")).toBe("markdown"); + }); + }); + + describe("JavaScript/TypeScript", () => { + it("detects JavaScript files", () => { + expect(getLanguageFromFilename("index.js")).toBe("javascript"); + expect(getLanguageFromFilename("component.jsx")).toBe("javascript"); + expect(getLanguageFromFilename("module.mjs")).toBe("javascript"); + expect(getLanguageFromFilename("common.cjs")).toBe("javascript"); + }); + + it("detects TypeScript files", () => { + expect(getLanguageFromFilename("index.ts")).toBe("typescript"); + expect(getLanguageFromFilename("component.tsx")).toBe("typescript"); + }); + }); + + describe("web languages", () => { + it("detects HTML files", () => { + expect(getLanguageFromFilename("index.html")).toBe("html"); + expect(getLanguageFromFilename("page.htm")).toBe("html"); + }); + + it("detects CSS files", () => { + expect(getLanguageFromFilename("styles.css")).toBe("css"); + expect(getLanguageFromFilename("styles.scss")).toBe("scss"); + expect(getLanguageFromFilename("styles.less")).toBe("less"); + }); + }); + + describe("data formats", () => { + it("detects JSON files", () => { + expect(getLanguageFromFilename("config.json")).toBe("json"); + }); + + it("detects YAML files", () => { + expect(getLanguageFromFilename("config.yaml")).toBe("yaml"); + expect(getLanguageFromFilename("config.yml")).toBe("yaml"); + }); + + it("detects XML files", () => { + expect(getLanguageFromFilename("data.xml")).toBe("xml"); + }); + + it("detects TOML files", () => { + expect(getLanguageFromFilename("config.toml")).toBe("toml"); + }); + }); + + describe("programming languages", () => { + it("detects Python files", () => { + expect(getLanguageFromFilename("script.py")).toBe("python"); + }); + + it("detects Go files", () => { + expect(getLanguageFromFilename("main.go")).toBe("go"); + }); + + it("detects Rust files", () => { + expect(getLanguageFromFilename("lib.rs")).toBe("rust"); + }); + + it("detects Java files", () => { + expect(getLanguageFromFilename("Main.java")).toBe("java"); + }); + + it("detects C/C++ files", () => { + expect(getLanguageFromFilename("main.c")).toBe("c"); + expect(getLanguageFromFilename("main.cpp")).toBe("cpp"); + expect(getLanguageFromFilename("header.h")).toBe("c"); + }); + }); + + describe("shell and config files", () => { + it("detects shell files", () => { + expect(getLanguageFromFilename("script.sh")).toBe("shell"); + expect(getLanguageFromFilename("script.bash")).toBe("shell"); + expect(getLanguageFromFilename(".env")).toBe("shell"); + }); + + it("detects config files", () => { + expect(getLanguageFromFilename("settings.ini")).toBe("ini"); + expect(getLanguageFromFilename(".editorconfig")).toBe("ini"); + }); + }); + + describe("special filenames", () => { + it("detects Dockerfile", () => { + expect(getLanguageFromFilename("Dockerfile")).toBe("dockerfile"); + expect(getLanguageFromFilename("dockerfile")).toBe("dockerfile"); + expect(getLanguageFromFilename("Dockerfile.dev")).toBe("dockerfile"); + }); + + it("detects Makefile", () => { + expect(getLanguageFromFilename("Makefile")).toBe("makefile"); + expect(getLanguageFromFilename("makefile")).toBe("makefile"); + expect(getLanguageFromFilename("GNUmakefile")).toBe("makefile"); + }); + }); + + describe("unknown extensions", () => { + it("returns plaintext for unknown extensions", () => { + expect(getLanguageFromFilename("file.unknown")).toBe("plaintext"); + expect(getLanguageFromFilename("noextension")).toBe("plaintext"); + }); + + it("returns plaintext for .txt files", () => { + expect(getLanguageFromFilename("notes.txt")).toBe("plaintext"); + }); + }); +}); + +describe("validateFilename", () => { + const existingFiles = ["existing.ts", "config.json"]; + + describe("empty and invalid input", () => { + it("rejects empty filename", () => { + expect(validateFilename("", existingFiles)).toBe("filenameEmpty"); + expect(validateFilename(" ", existingFiles)).toBe("filenameEmpty"); + }); + + it("rejects invalid characters", () => { + expect(validateFilename("file.ts", existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename("file:name.ts", existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename('file"name.ts', existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename("file|name.ts", existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename("file?name.ts", existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename("file*name.ts", existingFiles)).toBe("filenameInvalidChars"); + expect(validateFilename("file\\name.ts", existingFiles)).toBe("filenameInvalidChars"); + }); + }); + + describe("path validation", () => { + it("rejects paths starting with slash", () => { + expect(validateFilename("/src/file.ts", existingFiles)).toBe("pathStartEndSlash"); + }); + + it("rejects paths ending with slash", () => { + expect(validateFilename("src/file/", existingFiles)).toBe("pathStartEndSlash"); + }); + + it("rejects consecutive slashes", () => { + expect(validateFilename("src//file.ts", existingFiles)).toBe("pathConsecutiveSlashes"); + }); + + it("rejects parent directory references", () => { + expect(validateFilename("../file.ts", existingFiles)).toBe("pathContainsDotDot"); + expect(validateFilename("src/../file.ts", existingFiles)).toBe("pathContainsDotDot"); + }); + + it("allows valid directory paths", () => { + expect(validateFilename("src/utils/helper.ts", existingFiles)).toBeNull(); + }); + }); + + describe("reserved names", () => { + it("rejects SKILL.md as filename", () => { + expect(validateFilename(DEFAULT_SKILL_FILE, existingFiles)).toBe("filenameReserved"); + }); + }); + + describe("duplicates", () => { + it("rejects duplicate filenames (case-insensitive)", () => { + expect(validateFilename("existing.ts", existingFiles)).toBe("filenameDuplicate"); + expect(validateFilename("EXISTING.TS", existingFiles)).toBe("filenameDuplicate"); + expect(validateFilename("Existing.Ts", existingFiles)).toBe("filenameDuplicate"); + }); + }); + + describe("length limits", () => { + it("rejects paths over 200 characters", () => { + const longPath = "a".repeat(201); + expect(validateFilename(longPath, existingFiles)).toBe("pathTooLong"); + }); + + it("allows paths up to 200 characters", () => { + const maxPath = "a".repeat(200); + expect(validateFilename(maxPath, existingFiles)).toBeNull(); + }); + }); + + describe("valid filenames", () => { + it("accepts valid simple filenames", () => { + expect(validateFilename("newfile.ts", existingFiles)).toBeNull(); + expect(validateFilename("readme.md", existingFiles)).toBeNull(); + }); + + it("accepts valid paths with directories", () => { + expect(validateFilename("src/components/button.tsx", existingFiles)).toBeNull(); + }); + }); +}); + +describe("isValidKebabCase", () => { + it("accepts valid kebab-case names", () => { + expect(isValidKebabCase("my-skill")).toBe(true); + expect(isValidKebabCase("another-great-skill")).toBe(true); + expect(isValidKebabCase("skill123")).toBe(true); + expect(isValidKebabCase("my-skill-v2")).toBe(true); + }); + + it("requires starting with a letter", () => { + expect(isValidKebabCase("123-skill")).toBe(false); + expect(isValidKebabCase("-my-skill")).toBe(false); + }); + + it("rejects uppercase letters", () => { + expect(isValidKebabCase("My-Skill")).toBe(false); + expect(isValidKebabCase("mySkill")).toBe(false); + }); + + it("rejects underscores and spaces", () => { + expect(isValidKebabCase("my_skill")).toBe(false); + expect(isValidKebabCase("my skill")).toBe(false); + }); + + it("rejects empty string", () => { + expect(isValidKebabCase("")).toBe(false); + }); +}); + +describe("parseSkillFrontmatter", () => { + it("parses valid frontmatter", () => { + const content = `--- +name: my-skill +description: A test skill +--- + +# My Skill`; + + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ + name: "my-skill", + description: "A test skill", + }); + }); + + it("returns null for missing frontmatter", () => { + const content = "# My Skill\n\nNo frontmatter here"; + expect(parseSkillFrontmatter(content)).toBeNull(); + }); + + it("handles partial frontmatter", () => { + const content = `--- +name: my-skill +--- + +# My Skill`; + + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ name: "my-skill" }); + }); + + it("handles multi-file content", () => { + const content = `--- +name: multi-file-skill +description: Has multiple files +--- + +# Main\n\x1FFILE:helper.ts\x1E\nconst x = 1;`; + + const result = parseSkillFrontmatter(content); + expect(result?.name).toBe("multi-file-skill"); + expect(result?.description).toBe("Has multiple files"); + }); +}); + +describe("validateSkillFrontmatter", () => { + it("returns null for valid frontmatter", () => { + const content = `--- +name: valid-skill +description: A valid description +--- + +# Content`; + + expect(validateSkillFrontmatter(content)).toBeNull(); + }); + + it("returns error for missing frontmatter", () => { + const content = "# No frontmatter"; + expect(validateSkillFrontmatter(content)).toBe("frontmatterMissing"); + }); + + it("returns error for missing name", () => { + const content = `--- +description: Has description but no name +---`; + + expect(validateSkillFrontmatter(content)).toBe("frontmatterNameRequired"); + }); + + it("returns error for default placeholder name", () => { + const content = `--- +name: my-skill-name +description: Real description +---`; + + expect(validateSkillFrontmatter(content)).toBe("frontmatterNameRequired"); + }); + + it("returns error for invalid name format", () => { + const content = `--- +name: Invalid Name +description: Real description +---`; + + expect(validateSkillFrontmatter(content)).toBe("frontmatterNameInvalidFormat"); + }); + + it("returns error for missing description", () => { + const content = `--- +name: valid-name +---`; + + expect(validateSkillFrontmatter(content)).toBe("frontmatterDescriptionRequired"); + }); + + it("returns error for default placeholder description", () => { + const content = `--- +name: valid-name +description: A clear description of what this skill does and when to use it +---`; + + expect(validateSkillFrontmatter(content)).toBe("frontmatterDescriptionRequired"); + }); +}); + +describe("generateSkillContentWithFrontmatter", () => { + it("generates content with frontmatter from title and description", () => { + const result = generateSkillContentWithFrontmatter("My Test Skill", "A description"); + + expect(result).toContain("---"); + expect(result).toContain("name: my-test-skill"); + expect(result).toContain("description: A description"); + expect(result).toContain("# My Test Skill"); + }); + + it("converts title to kebab-case", () => { + const result = generateSkillContentWithFrontmatter("Complex Title With Numbers 123", "desc"); + expect(result).toContain("name: complex-title-with-numbers-123"); + }); + + it("handles special characters in title", () => { + const result = generateSkillContentWithFrontmatter("Café Helper", "desc"); + expect(result).toContain("name: cafe-helper"); + }); + + it("uses default description when empty", () => { + const result = generateSkillContentWithFrontmatter("Test", ""); + expect(result).toContain("description: A clear description of what this skill does"); + }); + + it("uses default title when empty", () => { + const result = generateSkillContentWithFrontmatter("", "desc"); + expect(result).toContain("# My Skill"); + }); +}); + +describe("suggestFilename", () => { + it("suggests README.md first for empty list", () => { + expect(suggestFilename([])).toBe("README.md"); + }); + + it("skips already existing files", () => { + expect(suggestFilename(["README.md"])).toBe("config.json"); + expect(suggestFilename(["README.md", "config.json"])).toBe("schema.json"); + }); + + it("is case-insensitive when checking existing files", () => { + expect(suggestFilename(["readme.md"])).toBe("config.json"); + expect(suggestFilename(["README.MD"])).toBe("config.json"); + }); + + it("falls back to numbered files when all suggestions taken", () => { + const allTaken = [ + "README.md", "config.json", "schema.json", "template.md", + "example.ts", "utils.ts", "types.ts", "constants.ts" + ]; + expect(suggestFilename(allTaken)).toBe("file1.md"); + }); + + it("increments number for fallback files", () => { + const withFile1 = [ + "README.md", "config.json", "schema.json", "template.md", + "example.ts", "utils.ts", "types.ts", "constants.ts", "file1.md" + ]; + expect(suggestFilename(withFile1)).toBe("file2.md"); + }); +}); diff --git a/src/__tests__/lib/slug.test.ts b/src/__tests__/lib/slug.test.ts new file mode 100644 index 00000000..0e7e5ca2 --- /dev/null +++ b/src/__tests__/lib/slug.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { slugify } from "@/lib/slug"; + +describe("slugify", () => { + describe("basic transformations", () => { + it("converts text to lowercase", () => { + expect(slugify("Hello World")).toBe("hello-world"); + expect(slugify("UPPERCASE")).toBe("uppercase"); + }); + + it("trims whitespace", () => { + expect(slugify(" hello world ")).toBe("hello-world"); + expect(slugify("\thello\n")).toBe("hello"); + }); + + it("replaces spaces with hyphens", () => { + expect(slugify("hello world")).toBe("hello-world"); + expect(slugify("multiple spaces")).toBe("multiple-spaces"); + }); + + it("replaces underscores with hyphens", () => { + expect(slugify("hello_world")).toBe("hello-world"); + expect(slugify("multiple__underscores")).toBe("multiple-underscores"); + }); + }); + + describe("special character handling", () => { + it("removes non-word characters", () => { + expect(slugify("hello@world")).toBe("helloworld"); + expect(slugify("test!@#$%^&*()")).toBe("test"); + expect(slugify("hello.world")).toBe("helloworld"); + }); + + it("preserves hyphens in the middle", () => { + expect(slugify("hello-world")).toBe("hello-world"); + expect(slugify("already-slugified")).toBe("already-slugified"); + }); + + it("removes leading and trailing hyphens", () => { + expect(slugify("-hello-")).toBe("hello"); + expect(slugify("---test---")).toBe("test"); + expect(slugify("-multiple--hyphens-")).toBe("multiple-hyphens"); + }); + + it("collapses multiple hyphens into one", () => { + expect(slugify("hello---world")).toBe("hello-world"); + expect(slugify("test - - test")).toBe("test-test"); + }); + }); + + describe("length limiting", () => { + it("limits output to 100 characters", () => { + const longText = "a".repeat(150); + expect(slugify(longText).length).toBe(100); + }); + + it("preserves shorter text fully", () => { + const shortText = "short title"; + expect(slugify(shortText)).toBe("short-title"); + }); + }); + + describe("edge cases", () => { + it("handles empty string", () => { + expect(slugify("")).toBe(""); + }); + + it("handles string with only special characters", () => { + expect(slugify("@#$%^&*")).toBe(""); + }); + + it("handles mixed content", () => { + expect(slugify("Hello, World! How are you?")).toBe("hello-world-how-are-you"); + }); + + it("handles numbers", () => { + expect(slugify("Test 123")).toBe("test-123"); + expect(slugify("123 Test")).toBe("123-test"); + }); + + it("handles mixed case with numbers and special chars", () => { + expect(slugify("Product #1: The Best!")).toBe("product-1-the-best"); + }); + }); + + describe("real-world examples", () => { + it("handles prompt titles", () => { + expect(slugify("Act as a JavaScript Console")).toBe("act-as-a-javascript-console"); + expect(slugify("Act as an English Translator")).toBe("act-as-an-english-translator"); + }); + + it("handles titles with quotes", () => { + expect(slugify("Act as a \"Code Reviewer\"")).toBe("act-as-a-code-reviewer"); + }); + + it("handles titles with apostrophes", () => { + expect(slugify("Developer's Guide")).toBe("developers-guide"); + }); + }); +}); diff --git a/src/__tests__/lib/works-best-with.test.ts b/src/__tests__/lib/works-best-with.test.ts new file mode 100644 index 00000000..b5e69949 --- /dev/null +++ b/src/__tests__/lib/works-best-with.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "vitest"; +import { + getModelInfo, + isValidModelSlug, + getModelsByProvider, + validateBestWithModels, + validateBestWithMCP, + AI_MODELS, +} from "@/lib/works-best-with"; + +describe("getModelInfo", () => { + it("returns model info for valid slugs", () => { + expect(getModelInfo("gpt-4o")).toEqual({ name: "GPT-4o", provider: "OpenAI" }); + expect(getModelInfo("claude-4-opus")).toEqual({ name: "Claude 4 Opus", provider: "Anthropic" }); + expect(getModelInfo("gemini-2-5-pro")).toEqual({ name: "Gemini 2.5 Pro", provider: "Google" }); + }); + + it("returns null for invalid slugs", () => { + expect(getModelInfo("invalid-model")).toBeNull(); + expect(getModelInfo("")).toBeNull(); + expect(getModelInfo("gpt-99")).toBeNull(); + }); + + it("returns correct info for image generation models", () => { + expect(getModelInfo("dall-e-3")).toEqual({ name: "DALL·E 3", provider: "OpenAI" }); + expect(getModelInfo("midjourney")).toEqual({ name: "Midjourney", provider: "Midjourney" }); + }); + + it("returns correct info for video generation models", () => { + expect(getModelInfo("sora 2")).toEqual({ name: "Sora 2", provider: "OpenAI" }); + expect(getModelInfo("runway-gen4")).toEqual({ name: "Runway Gen-4", provider: "Runway" }); + }); +}); + +describe("isValidModelSlug", () => { + it("returns true for valid model slugs", () => { + expect(isValidModelSlug("gpt-4o")).toBe(true); + expect(isValidModelSlug("claude-3-5-sonnet")).toBe(true); + expect(isValidModelSlug("gemini-2-5-flash")).toBe(true); + expect(isValidModelSlug("grok-3")).toBe(true); + }); + + it("returns false for invalid slugs", () => { + expect(isValidModelSlug("invalid")).toBe(false); + expect(isValidModelSlug("gpt-10")).toBe(false); + expect(isValidModelSlug("")).toBe(false); + }); + + it("validates all defined model slugs", () => { + for (const slug of Object.keys(AI_MODELS)) { + expect(isValidModelSlug(slug)).toBe(true); + } + }); +}); + +describe("getModelsByProvider", () => { + it("returns models grouped by provider", () => { + const grouped = getModelsByProvider(); + + expect(grouped).toHaveProperty("OpenAI"); + expect(grouped).toHaveProperty("Anthropic"); + expect(grouped).toHaveProperty("Google"); + expect(grouped).toHaveProperty("xAI"); + }); + + it("includes all OpenAI models under OpenAI provider", () => { + const grouped = getModelsByProvider(); + const openaiModels = grouped["OpenAI"]; + + expect(openaiModels).toBeDefined(); + expect(openaiModels.some(m => m.slug === "gpt-4o")).toBe(true); + expect(openaiModels.some(m => m.slug === "dall-e-3")).toBe(true); + expect(openaiModels.some(m => m.slug === "sora 2")).toBe(true); + }); + + it("includes all Anthropic models under Anthropic provider", () => { + const grouped = getModelsByProvider(); + const anthropicModels = grouped["Anthropic"]; + + expect(anthropicModels).toBeDefined(); + expect(anthropicModels.some(m => m.slug === "claude-4-opus")).toBe(true); + expect(anthropicModels.some(m => m.slug === "claude-3-5-sonnet")).toBe(true); + }); + + it("each model entry has slug and name", () => { + const grouped = getModelsByProvider(); + + for (const provider of Object.keys(grouped)) { + for (const model of grouped[provider]) { + expect(model).toHaveProperty("slug"); + expect(model).toHaveProperty("name"); + expect(typeof model.slug).toBe("string"); + expect(typeof model.name).toBe("string"); + } + } + }); + + it("covers all models in AI_MODELS", () => { + const grouped = getModelsByProvider(); + let totalModels = 0; + + for (const provider of Object.keys(grouped)) { + totalModels += grouped[provider].length; + } + + expect(totalModels).toBe(Object.keys(AI_MODELS).length); + }); +}); + +describe("validateBestWithModels", () => { + describe("valid inputs", () => { + it("accepts empty array", () => { + const result = validateBestWithModels([]); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts single valid model", () => { + const result = validateBestWithModels(["gpt-4o"]); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts up to 3 valid models", () => { + const result = validateBestWithModels(["gpt-4o", "claude-4-opus", "gemini-2-5-pro"]); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe("invalid inputs", () => { + it("rejects more than 3 models", () => { + const result = validateBestWithModels(["gpt-4o", "claude-4-opus", "gemini-2-5-pro", "grok-3"]); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Maximum 3 models allowed"); + }); + + it("rejects unknown model slugs", () => { + const result = validateBestWithModels(["gpt-4o", "unknown-model"]); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Unknown model: unknown-model"); + }); + + it("reports multiple errors", () => { + const result = validateBestWithModels(["invalid1", "invalid2", "invalid3", "invalid4"]); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(1); + expect(result.errors).toContain("Maximum 3 models allowed"); + expect(result.errors).toContain("Unknown model: invalid1"); + }); + }); +}); + +describe("validateBestWithMCP", () => { + describe("valid inputs", () => { + it("accepts null", () => { + const result = validateBestWithMCP(null); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts undefined", () => { + const result = validateBestWithMCP(undefined); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts valid config with command only", () => { + const result = validateBestWithMCP({ command: "npx @modelcontextprotocol/server-filesystem" }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts valid config with command and tools", () => { + const result = validateBestWithMCP({ + command: "npx @modelcontextprotocol/server-filesystem", + tools: ["read_file", "write_file"] + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts config with empty tools array", () => { + const result = validateBestWithMCP({ + command: "some-command", + tools: [] + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe("invalid inputs", () => { + it("rejects non-object values", () => { + expect(validateBestWithMCP("string").valid).toBe(false); + expect(validateBestWithMCP(123).valid).toBe(false); + expect(validateBestWithMCP([]).valid).toBe(false); + expect(validateBestWithMCP(true).valid).toBe(false); + }); + + it("rejects missing command", () => { + const result = validateBestWithMCP({}); + expect(result.valid).toBe(false); + expect(result.errors).toContain("MCP config.command is required and must be a string"); + }); + + it("rejects non-string command", () => { + const result = validateBestWithMCP({ command: 123 }); + expect(result.valid).toBe(false); + expect(result.errors).toContain("MCP config.command is required and must be a string"); + }); + + it("rejects non-array tools", () => { + const result = validateBestWithMCP({ command: "cmd", tools: "not-array" }); + expect(result.valid).toBe(false); + expect(result.errors).toContain("MCP config.tools must be an array"); + }); + + it("rejects tools array with non-string elements", () => { + const result = validateBestWithMCP({ command: "cmd", tools: ["valid", 123, "also-valid"] }); + expect(result.valid).toBe(false); + expect(result.errors).toContain("MCP config.tools must be an array of strings"); + }); + }); + + describe("edge cases", () => { + it("handles object with extra properties", () => { + const result = validateBestWithMCP({ + command: "cmd", + tools: ["tool1"], + extraProp: "ignored" + }); + expect(result.valid).toBe(true); + }); + + it("handles deeply nested invalid tools", () => { + const result = validateBestWithMCP({ + command: "cmd", + tools: [["nested"]] + }); + expect(result.valid).toBe(false); + }); + }); +});