test: add comprehensive tests for utilities, hooks, and components
Add 118 new tests covering: - API key generation and validation (17 tests) - URL generation utilities (21 tests) - Content similarity algorithms (39 tests) - Webhook placeholder replacement (15 tests) - useIsMobile hook with resize handling (14 tests) - CopyButton component with clipboard/analytics (12 tests) Total test count: 245 passing tests
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { CopyButton } from "@/components/prompts/copy-button";
|
||||
|
||||
// Mock dependencies - use inline functions since vi.mock is hoisted
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
copied: "Copied!",
|
||||
failedToCopy: "Failed to copy",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
analyticsPrompt: {
|
||||
copy: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after vi.mock calls
|
||||
import { toast } from "sonner";
|
||||
import { analyticsPrompt } from "@/lib/analytics";
|
||||
|
||||
describe("CopyButton", () => {
|
||||
const mockClipboard = {
|
||||
writeText: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: mockClipboard,
|
||||
});
|
||||
mockClipboard.writeText.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should render copy button", () => {
|
||||
render(<CopyButton content="test content" />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy content to clipboard when clicked", async () => {
|
||||
const content = "Hello, World!";
|
||||
render(<CopyButton content={content} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith(content);
|
||||
});
|
||||
|
||||
it("should show success toast on successful copy", async () => {
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith("Copied!");
|
||||
});
|
||||
|
||||
it("should track analytics with promptId when provided", async () => {
|
||||
render(<CopyButton content="test" promptId="prompt-123" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(analyticsPrompt.copy).toHaveBeenCalledWith("prompt-123");
|
||||
});
|
||||
|
||||
it("should track analytics with undefined when promptId not provided", async () => {
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(analyticsPrompt.copy).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("should show Check icon after successful copy", async () => {
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
// After copy, the Check icon should be rendered with green color
|
||||
const button = screen.getByRole("button");
|
||||
const svg = button.querySelector("svg");
|
||||
expect(svg).toHaveClass("text-green-500");
|
||||
});
|
||||
|
||||
it("should show error toast when clipboard fails", async () => {
|
||||
mockClipboard.writeText.mockRejectedValueOnce(new Error("Clipboard error"));
|
||||
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to copy");
|
||||
});
|
||||
|
||||
it("should not show Check icon when clipboard fails", async () => {
|
||||
mockClipboard.writeText.mockRejectedValueOnce(new Error("Clipboard error"));
|
||||
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
// Should not have the green check icon
|
||||
const button = screen.getByRole("button");
|
||||
const svg = button.querySelector("svg");
|
||||
expect(svg).not.toHaveClass("text-green-500");
|
||||
});
|
||||
|
||||
it("should handle empty content", async () => {
|
||||
render(<CopyButton content="" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should handle content with special characters", async () => {
|
||||
const specialContent = '<script>alert("xss")</script>';
|
||||
render(<CopyButton content={specialContent} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith(specialContent);
|
||||
});
|
||||
|
||||
it("should handle multiline content", async () => {
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
render(<CopyButton content={multilineContent} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith(multilineContent);
|
||||
});
|
||||
|
||||
it("should be clickable multiple times", async () => {
|
||||
render(<CopyButton content="test" />);
|
||||
|
||||
// First click
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second click
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
});
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = vi.spyOn(window, "addEventListener");
|
||||
removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original window width
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalInnerWidth,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setWindowWidth(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
}
|
||||
|
||||
it("should return false for desktop width (>= 768)", () => {
|
||||
setWindowWidth(1024);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for mobile width (< 768)", () => {
|
||||
setWindowWidth(375);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false at exactly 768px (default breakpoint)", () => {
|
||||
setWindowWidth(768);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true at 767px (just below default breakpoint)", () => {
|
||||
setWindowWidth(767);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should use custom breakpoint", () => {
|
||||
setWindowWidth(500);
|
||||
const { result } = renderHook(() => useIsMobile(400));
|
||||
expect(result.current).toBe(false); // 500 >= 400
|
||||
});
|
||||
|
||||
it("should return true below custom breakpoint", () => {
|
||||
setWindowWidth(300);
|
||||
const { result } = renderHook(() => useIsMobile(400));
|
||||
expect(result.current).toBe(true); // 300 < 400
|
||||
});
|
||||
|
||||
it("should add resize event listener on mount", () => {
|
||||
setWindowWidth(1024);
|
||||
renderHook(() => useIsMobile());
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove resize event listener on unmount", () => {
|
||||
setWindowWidth(1024);
|
||||
const { unmount } = renderHook(() => useIsMobile());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("should update when window is resized", () => {
|
||||
setWindowWidth(1024);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Simulate resize to mobile width
|
||||
act(() => {
|
||||
setWindowWidth(375);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should update when resized from mobile to desktop", () => {
|
||||
setWindowWidth(375);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Simulate resize to desktop width
|
||||
act(() => {
|
||||
setWindowWidth(1024);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should re-register event listener when breakpoint changes", () => {
|
||||
setWindowWidth(500);
|
||||
const { rerender } = renderHook(
|
||||
({ breakpoint }) => useIsMobile(breakpoint),
|
||||
{ initialProps: { breakpoint: 768 } }
|
||||
);
|
||||
|
||||
// Initial registration
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change breakpoint
|
||||
rerender({ breakpoint: 400 });
|
||||
|
||||
// Should have removed old listener and added new one
|
||||
expect(removeEventListenerSpy).toHaveBeenCalled();
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should handle multiple resize events", () => {
|
||||
setWindowWidth(1024);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Multiple resizes
|
||||
act(() => {
|
||||
setWindowWidth(375);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1200);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(320);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle edge case of 0 width", () => {
|
||||
setWindowWidth(0);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very large width", () => {
|
||||
setWindowWidth(10000);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateApiKey, isValidApiKeyFormat } from "@/lib/api-key";
|
||||
|
||||
describe("generateApiKey", () => {
|
||||
it("should generate a key with pchat_ prefix", () => {
|
||||
const key = generateApiKey();
|
||||
expect(key.startsWith("pchat_")).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate a key with correct total length", () => {
|
||||
const key = generateApiKey();
|
||||
// pchat_ (6 chars) + 64 hex chars = 70 total
|
||||
expect(key.length).toBe(70);
|
||||
});
|
||||
|
||||
it("should generate unique keys", () => {
|
||||
const keys = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
keys.add(generateApiKey());
|
||||
}
|
||||
expect(keys.size).toBe(100);
|
||||
});
|
||||
|
||||
it("should only contain valid hex characters after prefix", () => {
|
||||
const key = generateApiKey();
|
||||
const randomPart = key.slice(6); // Remove pchat_ prefix
|
||||
expect(randomPart).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate keys that pass validation", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const key = generateApiKey();
|
||||
expect(isValidApiKeyFormat(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidApiKeyFormat", () => {
|
||||
it("should return true for valid API key", () => {
|
||||
const validKey = "pchat_" + "a".repeat(64);
|
||||
expect(isValidApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for key with mixed hex characters", () => {
|
||||
const validKey = "pchat_0123456789abcdef".padEnd(70, "0");
|
||||
expect(isValidApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for key without pchat_ prefix", () => {
|
||||
const invalidKey = "wrong_" + "a".repeat(64);
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key with missing prefix", () => {
|
||||
const invalidKey = "a".repeat(64);
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key that is too short", () => {
|
||||
const shortKey = "pchat_" + "a".repeat(32);
|
||||
expect(isValidApiKeyFormat(shortKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key that is too long", () => {
|
||||
const longKey = "pchat_" + "a".repeat(100);
|
||||
expect(isValidApiKeyFormat(longKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key with invalid characters", () => {
|
||||
const invalidKey = "pchat_" + "g".repeat(64); // 'g' is not hex
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key with uppercase hex", () => {
|
||||
const invalidKey = "pchat_" + "A".repeat(64);
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty string", () => {
|
||||
expect(isValidApiKeyFormat("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for just the prefix", () => {
|
||||
expect(isValidApiKeyFormat("pchat_")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key with spaces", () => {
|
||||
const invalidKey = "pchat_" + "a".repeat(32) + " " + "a".repeat(31);
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for key with special characters", () => {
|
||||
const invalidKey = "pchat_" + "a".repeat(63) + "!";
|
||||
expect(isValidApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizeContent,
|
||||
calculateSimilarity,
|
||||
isSimilarContent,
|
||||
getContentFingerprint,
|
||||
} from "@/lib/similarity";
|
||||
|
||||
describe("normalizeContent", () => {
|
||||
it("should convert to lowercase", () => {
|
||||
expect(normalizeContent("HELLO WORLD")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("should remove extra whitespace", () => {
|
||||
expect(normalizeContent("hello world")).toBe("hello world");
|
||||
expect(normalizeContent(" hello world ")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("should remove punctuation", () => {
|
||||
expect(normalizeContent("hello, world!")).toBe("hello world");
|
||||
expect(normalizeContent("what's up?")).toBe("whats up");
|
||||
});
|
||||
|
||||
it("should remove ${variable} patterns", () => {
|
||||
expect(normalizeContent("Hello ${name}!")).toBe("hello");
|
||||
expect(normalizeContent("${greeting} ${name}")).toBe("");
|
||||
});
|
||||
|
||||
it("should remove ${variable:default} patterns", () => {
|
||||
expect(normalizeContent("Hello ${name:John}!")).toBe("hello");
|
||||
});
|
||||
|
||||
it("should remove [placeholder] patterns", () => {
|
||||
expect(normalizeContent("Hello [NAME]!")).toBe("hello");
|
||||
expect(normalizeContent("Enter your [input here]")).toBe("enter your");
|
||||
});
|
||||
|
||||
it("should remove <placeholder> patterns", () => {
|
||||
expect(normalizeContent("Hello <name>")).toBe("hello");
|
||||
expect(normalizeContent("<greeting> <name>")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle mixed patterns", () => {
|
||||
const input = "Hello ${name}, your [ROLE] is <admin>";
|
||||
expect(normalizeContent(input)).toBe("hello your is");
|
||||
});
|
||||
|
||||
it("should return empty string for only placeholders", () => {
|
||||
expect(normalizeContent("${var1} ${var2}").trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should preserve numbers", () => {
|
||||
expect(normalizeContent("Test 123")).toBe("test 123");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(normalizeContent("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle string with only whitespace", () => {
|
||||
expect(normalizeContent(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSimilarity", () => {
|
||||
it("should return 1 for identical content", () => {
|
||||
expect(calculateSimilarity("hello world", "hello world")).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 1 for identical content after normalization", () => {
|
||||
expect(calculateSimilarity("Hello World!", "hello world")).toBe(1);
|
||||
expect(calculateSimilarity("HELLO WORLD", "hello world")).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 0 for completely different content", () => {
|
||||
const similarity = calculateSimilarity("abc def ghi", "xyz uvw rst");
|
||||
expect(similarity).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it("should return 0 when one content is empty", () => {
|
||||
expect(calculateSimilarity("hello", "")).toBe(0);
|
||||
expect(calculateSimilarity("", "world")).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 1 for two empty strings (after normalization)", () => {
|
||||
expect(calculateSimilarity("", "")).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle content that becomes empty after normalization", () => {
|
||||
expect(calculateSimilarity("${var}", "[placeholder]")).toBe(1);
|
||||
});
|
||||
|
||||
it("should return high similarity for similar content", () => {
|
||||
const similarity = calculateSimilarity(
|
||||
"Write a poem about nature",
|
||||
"Write a poem about nature and trees"
|
||||
);
|
||||
expect(similarity).toBeGreaterThan(0.6);
|
||||
});
|
||||
|
||||
it("should return moderate similarity for partially similar content", () => {
|
||||
const similarity = calculateSimilarity(
|
||||
"Write a poem about nature",
|
||||
"Create a story about animals"
|
||||
);
|
||||
expect(similarity).toBeGreaterThan(0.1);
|
||||
expect(similarity).toBeLessThan(0.6);
|
||||
});
|
||||
|
||||
it("should ignore variable placeholders in similarity", () => {
|
||||
const similarity = calculateSimilarity(
|
||||
"Hello ${name}, how are you?",
|
||||
"Hello [NAME], how are you?"
|
||||
);
|
||||
expect(similarity).toBe(1);
|
||||
});
|
||||
|
||||
it("should be symmetric", () => {
|
||||
const content1 = "The quick brown fox";
|
||||
const content2 = "A quick brown dog";
|
||||
expect(calculateSimilarity(content1, content2)).toBe(
|
||||
calculateSimilarity(content2, content1)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSimilarContent", () => {
|
||||
it("should return true for identical content", () => {
|
||||
expect(isSimilarContent("hello world", "hello world")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for highly similar content above threshold", () => {
|
||||
// Use content that's almost identical (just minor variation)
|
||||
expect(
|
||||
isSimilarContent(
|
||||
"Write a creative story about a dragon in a castle",
|
||||
"Write a creative story about a dragon in a castle today"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for content below default threshold", () => {
|
||||
expect(
|
||||
isSimilarContent("Write a poem about love", "Create a business plan")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should respect custom threshold", () => {
|
||||
const content1 = "The quick brown fox jumps";
|
||||
const content2 = "The quick brown dog jumps";
|
||||
|
||||
// These might be similar at 0.5 threshold but not at 0.95
|
||||
const similarity = calculateSimilarity(content1, content2);
|
||||
|
||||
expect(isSimilarContent(content1, content2, 0.5)).toBe(similarity >= 0.5);
|
||||
expect(isSimilarContent(content1, content2, 0.99)).toBe(similarity >= 0.99);
|
||||
});
|
||||
|
||||
it("should return true with threshold of 0", () => {
|
||||
expect(isSimilarContent("abc", "xyz", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle edge case threshold of 1", () => {
|
||||
expect(isSimilarContent("hello", "hello", 1)).toBe(true);
|
||||
expect(isSimilarContent("hello", "hello!", 1)).toBe(true); // After normalization
|
||||
expect(isSimilarContent("hello", "world", 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContentFingerprint", () => {
|
||||
it("should return normalized content as fingerprint", () => {
|
||||
const fingerprint = getContentFingerprint("Hello World!");
|
||||
expect(fingerprint).toBe("hello world");
|
||||
});
|
||||
|
||||
it("should truncate to 500 characters", () => {
|
||||
const longContent = "a".repeat(1000);
|
||||
const fingerprint = getContentFingerprint(longContent);
|
||||
expect(fingerprint.length).toBe(500);
|
||||
});
|
||||
|
||||
it("should not truncate short content", () => {
|
||||
const shortContent = "short text";
|
||||
const fingerprint = getContentFingerprint(shortContent);
|
||||
expect(fingerprint).toBe("short text");
|
||||
});
|
||||
|
||||
it("should return same fingerprint for normalized-identical content", () => {
|
||||
const fp1 = getContentFingerprint("Hello World!");
|
||||
const fp2 = getContentFingerprint("HELLO WORLD");
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
|
||||
it("should remove placeholders before fingerprinting", () => {
|
||||
const fp = getContentFingerprint("Hello ${name}!");
|
||||
expect(fp).toBe("hello");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getContentFingerprint("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle content that normalizes to empty", () => {
|
||||
expect(getContentFingerprint("${var}")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("similarity edge cases", () => {
|
||||
it("should handle very long content", () => {
|
||||
// Create long content with many unique words for better Jaccard similarity
|
||||
const words = Array.from({ length: 100 }, (_, i) => `word${i}`).join(" ");
|
||||
const longContent1 = words;
|
||||
const longContent2 = words + " extra";
|
||||
|
||||
// Should not throw and should return high similarity
|
||||
const similarity = calculateSimilarity(longContent1, longContent2);
|
||||
expect(similarity).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it("should handle unicode characters", () => {
|
||||
const similarity = calculateSimilarity(
|
||||
"Hello 世界 emoji 🎉",
|
||||
"Hello 世界 emoji 🎉"
|
||||
);
|
||||
expect(similarity).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle newlines and tabs", () => {
|
||||
const content1 = "Hello\nWorld\tTest";
|
||||
const content2 = "Hello World Test";
|
||||
const similarity = calculateSimilarity(content1, content2);
|
||||
expect(similarity).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle repeated words", () => {
|
||||
const content1 = "test test test test";
|
||||
const content2 = "test test";
|
||||
const similarity = calculateSimilarity(content1, content2);
|
||||
// Both normalize to similar content with repeated "test"
|
||||
expect(similarity).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getPromptUrl, getPromptEditUrl, getPromptChangesUrl } from "@/lib/urls";
|
||||
|
||||
describe("getPromptUrl", () => {
|
||||
it("should return URL with just ID when no slug provided", () => {
|
||||
expect(getPromptUrl("abc123")).toBe("/prompts/abc123");
|
||||
});
|
||||
|
||||
it("should return URL with ID and slug when slug provided", () => {
|
||||
expect(getPromptUrl("abc123", "my-prompt")).toBe("/prompts/abc123_my-prompt");
|
||||
});
|
||||
|
||||
it("should return URL with just ID when slug is null", () => {
|
||||
expect(getPromptUrl("abc123", null)).toBe("/prompts/abc123");
|
||||
});
|
||||
|
||||
it("should return URL with just ID when slug is undefined", () => {
|
||||
expect(getPromptUrl("abc123", undefined)).toBe("/prompts/abc123");
|
||||
});
|
||||
|
||||
it("should return URL with just ID when slug is empty string", () => {
|
||||
expect(getPromptUrl("abc123", "")).toBe("/prompts/abc123");
|
||||
});
|
||||
|
||||
it("should handle slug with special characters", () => {
|
||||
expect(getPromptUrl("abc123", "my-cool-prompt")).toBe("/prompts/abc123_my-cool-prompt");
|
||||
});
|
||||
|
||||
it("should handle numeric ID", () => {
|
||||
expect(getPromptUrl("12345", "test")).toBe("/prompts/12345_test");
|
||||
});
|
||||
|
||||
it("should handle UUID-style ID", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
expect(getPromptUrl(uuid, "slug")).toBe(`/prompts/${uuid}_slug`);
|
||||
});
|
||||
|
||||
it("should handle slug with numbers", () => {
|
||||
expect(getPromptUrl("id1", "prompt-2024")).toBe("/prompts/id1_prompt-2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPromptEditUrl", () => {
|
||||
it("should return edit URL with just ID when no slug", () => {
|
||||
expect(getPromptEditUrl("abc123")).toBe("/prompts/abc123/edit");
|
||||
});
|
||||
|
||||
it("should return edit URL with ID and slug", () => {
|
||||
expect(getPromptEditUrl("abc123", "my-prompt")).toBe("/prompts/abc123_my-prompt/edit");
|
||||
});
|
||||
|
||||
it("should return edit URL with just ID when slug is null", () => {
|
||||
expect(getPromptEditUrl("abc123", null)).toBe("/prompts/abc123/edit");
|
||||
});
|
||||
|
||||
it("should return edit URL with just ID when slug is undefined", () => {
|
||||
expect(getPromptEditUrl("abc123", undefined)).toBe("/prompts/abc123/edit");
|
||||
});
|
||||
|
||||
it("should append /edit to the base prompt URL", () => {
|
||||
const baseUrl = getPromptUrl("test", "slug");
|
||||
const editUrl = getPromptEditUrl("test", "slug");
|
||||
expect(editUrl).toBe(`${baseUrl}/edit`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPromptChangesUrl", () => {
|
||||
it("should return changes URL with just ID when no slug", () => {
|
||||
expect(getPromptChangesUrl("abc123")).toBe("/prompts/abc123/changes/new");
|
||||
});
|
||||
|
||||
it("should return changes URL with ID and slug", () => {
|
||||
expect(getPromptChangesUrl("abc123", "my-prompt")).toBe("/prompts/abc123_my-prompt/changes/new");
|
||||
});
|
||||
|
||||
it("should return changes URL with just ID when slug is null", () => {
|
||||
expect(getPromptChangesUrl("abc123", null)).toBe("/prompts/abc123/changes/new");
|
||||
});
|
||||
|
||||
it("should return changes URL with just ID when slug is undefined", () => {
|
||||
expect(getPromptChangesUrl("abc123", undefined)).toBe("/prompts/abc123/changes/new");
|
||||
});
|
||||
|
||||
it("should append /changes/new to the base prompt URL", () => {
|
||||
const baseUrl = getPromptUrl("test", "slug");
|
||||
const changesUrl = getPromptChangesUrl("test", "slug");
|
||||
expect(changesUrl).toBe(`${baseUrl}/changes/new`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL generation consistency", () => {
|
||||
it("should maintain consistent format across all URL functions", () => {
|
||||
const id = "test-id";
|
||||
const slug = "test-slug";
|
||||
|
||||
const baseUrl = getPromptUrl(id, slug);
|
||||
const editUrl = getPromptEditUrl(id, slug);
|
||||
const changesUrl = getPromptChangesUrl(id, slug);
|
||||
|
||||
expect(editUrl.startsWith(baseUrl)).toBe(true);
|
||||
expect(changesUrl.startsWith(baseUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle same ID with different slugs", () => {
|
||||
const id = "same-id";
|
||||
|
||||
const url1 = getPromptUrl(id, "slug-one");
|
||||
const url2 = getPromptUrl(id, "slug-two");
|
||||
const url3 = getPromptUrl(id, null);
|
||||
|
||||
expect(url1).not.toBe(url2);
|
||||
expect(url1).not.toBe(url3);
|
||||
expect(url2).not.toBe(url3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { WEBHOOK_PLACEHOLDERS, SLACK_PRESET_PAYLOAD, triggerWebhooks } from "@/lib/webhook";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Mock the db module
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
webhookConfig: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("WEBHOOK_PLACEHOLDERS", () => {
|
||||
it("should have all required placeholders", () => {
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_ID).toBe("{{PROMPT_ID}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_TITLE).toBe("{{PROMPT_TITLE}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_DESCRIPTION).toBe("{{PROMPT_DESCRIPTION}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_CONTENT).toBe("{{PROMPT_CONTENT}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_TYPE).toBe("{{PROMPT_TYPE}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_URL).toBe("{{PROMPT_URL}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.PROMPT_MEDIA_URL).toBe("{{PROMPT_MEDIA_URL}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.AUTHOR_USERNAME).toBe("{{AUTHOR_USERNAME}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.AUTHOR_NAME).toBe("{{AUTHOR_NAME}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.AUTHOR_AVATAR).toBe("{{AUTHOR_AVATAR}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.CATEGORY_NAME).toBe("{{CATEGORY_NAME}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.TAGS).toBe("{{TAGS}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.TIMESTAMP).toBe("{{TIMESTAMP}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.SITE_URL).toBe("{{SITE_URL}}");
|
||||
expect(WEBHOOK_PLACEHOLDERS.CHATGPT_URL).toBe("{{CHATGPT_URL}}");
|
||||
});
|
||||
|
||||
it("should have consistent placeholder format", () => {
|
||||
Object.values(WEBHOOK_PLACEHOLDERS).forEach((placeholder) => {
|
||||
expect(placeholder).toMatch(/^\{\{[A-Z_]+\}\}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have unique placeholder values", () => {
|
||||
const values = Object.values(WEBHOOK_PLACEHOLDERS);
|
||||
const uniqueValues = new Set(values);
|
||||
expect(uniqueValues.size).toBe(values.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLACK_PRESET_PAYLOAD", () => {
|
||||
it("should be valid JSON when placeholders are replaced", () => {
|
||||
// Replace all placeholders with test values
|
||||
let payload = SLACK_PRESET_PAYLOAD;
|
||||
Object.values(WEBHOOK_PLACEHOLDERS).forEach((placeholder) => {
|
||||
payload = payload.replace(new RegExp(placeholder.replace(/[{}]/g, "\\$&"), "g"), "test");
|
||||
});
|
||||
|
||||
expect(() => JSON.parse(payload)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should contain expected Slack block types", () => {
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain('"type": "header"');
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain('"type": "actions"');
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain('"type": "section"');
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain('"type": "context"');
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain('"type": "divider"');
|
||||
});
|
||||
|
||||
it("should contain all relevant placeholders", () => {
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.PROMPT_TITLE);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.PROMPT_URL);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.PROMPT_CONTENT);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.PROMPT_DESCRIPTION);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.AUTHOR_USERNAME);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.AUTHOR_NAME);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.CATEGORY_NAME);
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain(WEBHOOK_PLACEHOLDERS.TAGS);
|
||||
});
|
||||
|
||||
it("should have View Prompt button", () => {
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain("View Prompt");
|
||||
});
|
||||
|
||||
it("should have Run in ChatGPT button", () => {
|
||||
expect(SLACK_PRESET_PAYLOAD).toContain("Run in ChatGPT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerWebhooks", () => {
|
||||
const mockPromptData = {
|
||||
id: "prompt-123",
|
||||
title: "Test Prompt",
|
||||
description: "A test description",
|
||||
content: "Test content here",
|
||||
type: "text",
|
||||
mediaUrl: null,
|
||||
isPrivate: false,
|
||||
author: {
|
||||
username: "testuser",
|
||||
name: "Test User",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
},
|
||||
category: {
|
||||
name: "Testing",
|
||||
slug: "testing",
|
||||
},
|
||||
tags: [
|
||||
{ tag: { name: "test", slug: "test" } },
|
||||
{ tag: { name: "example", slug: "example" } },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockFetch.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("should not make fetch calls when no webhooks configured", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([]);
|
||||
|
||||
await triggerWebhooks("PROMPT_CREATED", mockPromptData);
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call fetch for each enabled webhook", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "wh1",
|
||||
name: "Webhook 1",
|
||||
url: "https://example.com/hook1",
|
||||
method: "POST",
|
||||
payload: '{"test": "{{PROMPT_ID}}"}',
|
||||
headers: {},
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "wh2",
|
||||
name: "Webhook 2",
|
||||
url: "https://example.com/hook2",
|
||||
method: "POST",
|
||||
payload: '{"data": "{{PROMPT_TITLE}}"}',
|
||||
headers: {},
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhooks("PROMPT_CREATED", mockPromptData);
|
||||
|
||||
// Give time for the fire-and-forget promises to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should replace placeholders in payload", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "wh1",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/hook",
|
||||
method: "POST",
|
||||
payload: '{"id": "{{PROMPT_ID}}", "title": "{{PROMPT_TITLE}}"}',
|
||||
headers: {},
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhooks("PROMPT_CREATED", mockPromptData);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/hook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining("prompt-123"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should include custom headers", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "wh1",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/hook",
|
||||
method: "POST",
|
||||
payload: "{}",
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhooks("PROMPT_CREATED", mockPromptData);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer token123",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "wh1",
|
||||
name: "Failing Webhook",
|
||||
url: "https://example.com/hook",
|
||||
method: "POST",
|
||||
payload: "{}",
|
||||
headers: {},
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
// Should not throw
|
||||
await expect(triggerWebhooks("PROMPT_CREATED", mockPromptData)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockRejectedValue(new Error("DB Error"));
|
||||
|
||||
// Should not throw
|
||||
await expect(triggerWebhooks("PROMPT_CREATED", mockPromptData)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should use correct HTTP method from config", async () => {
|
||||
vi.mocked(db.webhookConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "wh1",
|
||||
name: "PUT Webhook",
|
||||
url: "https://example.com/hook",
|
||||
method: "PUT",
|
||||
payload: "{}",
|
||||
headers: {},
|
||||
events: ["PROMPT_CREATED"],
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhooks("PROMPT_CREATED", mockPromptData);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user