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:
Claude
2026-01-07 08:13:06 +00:00
parent 0ce1a0e35d
commit 11edc02ca5
6 changed files with 1090 additions and 0 deletions
@@ -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);
});
});
+176
View File
@@ -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);
});
});
+96
View File
@@ -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);
});
});
+242
View File
@@ -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);
});
});
+115
View File
@@ -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);
});
});
+278
View File
@@ -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",
})
);
});
});