Skip to content

Commit 7b39cbd

Browse files
committed
Add Vitest test suite for platforms, auth, limits, and generate API
1 parent 6671a99 commit 7b39cbd

6 files changed

Lines changed: 674 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { POST } from "../route";
3+
4+
const mockPrisma = {
5+
user: {
6+
findUnique: vi.fn(),
7+
update: vi.fn(),
8+
},
9+
post: {
10+
create: vi.fn(),
11+
},
12+
$transaction: vi.fn(),
13+
};
14+
15+
const mockSession = {
16+
getSessionEmail: vi.fn(),
17+
};
18+
19+
vi.mock("@/lib/db", () => ({
20+
prisma: mockPrisma,
21+
}));
22+
23+
vi.mock("@/lib/session", () => ({
24+
getSessionEmail: mockSession.getSessionEmail,
25+
}));
26+
27+
function createMockRequest(body: Record<string, unknown>, ip = "127.0.0.1") {
28+
return {
29+
headers: {
30+
get: (key: string) => (key === "x-forwarded-for" ? ip : null),
31+
},
32+
json: () => Promise.resolve(body),
33+
} as unknown as Request;
34+
}
35+
36+
describe("POST /api/generate", () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
process.env.OPENAI_API_KEY = "";
40+
});
41+
42+
afterEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
it("returns 401 when not authenticated", async () => {
47+
mockSession.getSessionEmail.mockResolvedValue(null);
48+
49+
const req = createMockRequest({ topic: "test" });
50+
const res = await POST(req);
51+
const data = await res.json();
52+
53+
expect(res.status).toBe(401);
54+
expect(data.error).toBe("Unauthorized");
55+
});
56+
57+
it("returns 404 when user not found", async () => {
58+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
59+
mockPrisma.user.findUnique.mockResolvedValue(null);
60+
61+
const req = createMockRequest({ topic: "test" });
62+
const res = await POST(req);
63+
const data = await res.json();
64+
65+
expect(res.status).toBe(404);
66+
expect(data.error).toBe("User not found");
67+
});
68+
69+
it("returns 400 when topic is empty", async () => {
70+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
71+
mockPrisma.user.findUnique.mockResolvedValue({
72+
id: "user-1",
73+
email: "test@example.com",
74+
plan: "starter",
75+
postsUsed: 0,
76+
isAdmin: false,
77+
isLifetime: false,
78+
});
79+
80+
const req = createMockRequest({ topic: "" });
81+
const res = await POST(req);
82+
const data = await res.json();
83+
84+
expect(res.status).toBe(400);
85+
expect(data.error).toBe("Topic required");
86+
});
87+
88+
it("returns 403 when plan limit reached", async () => {
89+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
90+
mockPrisma.user.findUnique.mockResolvedValue({
91+
id: "user-1",
92+
email: "test@example.com",
93+
plan: "starter",
94+
postsUsed: 5,
95+
isAdmin: false,
96+
isLifetime: false,
97+
});
98+
99+
const req = createMockRequest({ topic: "AI trends" });
100+
const res = await POST(req);
101+
const data = await res.json();
102+
103+
expect(res.status).toBe(403);
104+
expect(data.error).toContain("plan limit reached");
105+
});
106+
107+
it("bypasses limit for admin users", async () => {
108+
mockSession.getSessionEmail.mockResolvedValue("admin@example.com");
109+
mockPrisma.user.findUnique.mockResolvedValue({
110+
id: "admin-1",
111+
email: "admin@example.com",
112+
plan: "starter",
113+
postsUsed: 100,
114+
isAdmin: true,
115+
isLifetime: false,
116+
});
117+
mockPrisma.$transaction.mockResolvedValue([
118+
{ postsUsed: 100 },
119+
{ id: "post-1" },
120+
]);
121+
122+
const req = createMockRequest({ topic: "Admin test post" });
123+
const res = await POST(req);
124+
const data = await res.json();
125+
126+
expect(res.status).toBe(200);
127+
expect(data.success).toBe(true);
128+
});
129+
130+
it("returns 403 when LinkedIn used with non-agency plan", async () => {
131+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
132+
mockPrisma.user.findUnique.mockResolvedValue({
133+
id: "user-1",
134+
email: "test@example.com",
135+
plan: "pro",
136+
postsUsed: 0,
137+
isAdmin: false,
138+
isLifetime: false,
139+
});
140+
141+
const req = createMockRequest({ topic: "Professional update", platform: "LinkedIn" });
142+
const res = await POST(req);
143+
const data = await res.json();
144+
145+
expect(res.status).toBe(403);
146+
expect(data.error).toBe("LinkedIn requires Agency plan");
147+
});
148+
149+
it("allows LinkedIn for agency users", async () => {
150+
mockSession.getSessionEmail.mockResolvedValue("agency@example.com");
151+
mockPrisma.user.findUnique.mockResolvedValue({
152+
id: "agency-1",
153+
email: "agency@example.com",
154+
plan: "agency",
155+
postsUsed: 0,
156+
isAdmin: false,
157+
isLifetime: false,
158+
});
159+
mockPrisma.$transaction.mockResolvedValue([
160+
{ postsUsed: 1 },
161+
{ id: "post-1" },
162+
]);
163+
164+
const req = createMockRequest({ topic: "Industry insight", platform: "LinkedIn" });
165+
const res = await POST(req);
166+
const data = await res.json();
167+
168+
expect(res.status).toBe(200);
169+
expect(data.success).toBe(true);
170+
});
171+
172+
it("normalizes platform from lowercase to proper case", async () => {
173+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
174+
mockPrisma.user.findUnique.mockResolvedValue({
175+
id: "user-1",
176+
email: "test@example.com",
177+
plan: "pro",
178+
postsUsed: 0,
179+
isAdmin: false,
180+
isLifetime: false,
181+
});
182+
mockPrisma.$transaction.mockResolvedValue([
183+
{ postsUsed: 1 },
184+
{ id: "post-1" },
185+
]);
186+
187+
const req = createMockRequest({ topic: "Test post", platform: "instagram" });
188+
const res = await POST(req);
189+
const data = await res.json();
190+
191+
expect(res.status).toBe(200);
192+
expect(data.platform).toBe("Instagram");
193+
});
194+
195+
it("increments postsUsed for non-admin users", async () => {
196+
mockSession.getSessionEmail.mockResolvedValue("test@example.com");
197+
mockPrisma.user.findUnique.mockResolvedValue({
198+
id: "user-1",
199+
email: "test@example.com",
200+
plan: "pro",
201+
postsUsed: 10,
202+
isAdmin: false,
203+
isLifetime: false,
204+
});
205+
mockPrisma.$transaction.mockResolvedValue([
206+
{ postsUsed: 11 },
207+
{ id: "post-1" },
208+
]);
209+
210+
const req = createMockRequest({ topic: "Test content" });
211+
await POST(req);
212+
213+
expect(mockPrisma.$transaction).toHaveBeenCalled();
214+
const txCall = mockPrisma.$transaction.mock.calls[0][0];
215+
const updateCall = txCall.find((c: any) => c?.type === "user" || true);
216+
expect(mockPrisma.user.update).toHaveBeenCalledWith({
217+
where: { id: "user-1" },
218+
data: { postsUsed: 11 },
219+
});
220+
});
221+
222+
it("does not increment postsUsed for admin users", async () => {
223+
mockSession.getSessionEmail.mockResolvedValue("admin@example.com");
224+
mockPrisma.user.findUnique.mockResolvedValue({
225+
id: "admin-1",
226+
email: "admin@example.com",
227+
plan: "starter",
228+
postsUsed: 50,
229+
isAdmin: true,
230+
isLifetime: false,
231+
});
232+
mockPrisma.$transaction.mockResolvedValue([
233+
{ postsUsed: 50 },
234+
{ id: "post-1" },
235+
]);
236+
237+
const req = createMockRequest({ topic: "Admin post" });
238+
await POST(req);
239+
240+
expect(mockPrisma.user.update).toHaveBeenCalledWith({
241+
where: { id: "admin-1" },
242+
data: { postsUsed: 50 },
243+
});
244+
});
245+
});

lib/__tests__/auth.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { isAdminEmail, canUseLinkedIn } from "../auth";
3+
4+
vi.mock("@/lib/session", () => ({
5+
getSessionEmail: vi.fn(),
6+
}));
7+
8+
vi.mock("@/lib/db", () => ({
9+
prisma: {
10+
user: {
11+
findUnique: vi.fn(),
12+
},
13+
},
14+
}));
15+
16+
describe("isAdminEmail", () => {
17+
const originalEnv = process.env;
18+
19+
beforeEach(() => {
20+
process.env = { ...originalEnv };
21+
delete process.env.ADMIN_EMAILS;
22+
delete process.env.ADMIN_EMAIL;
23+
});
24+
25+
it("returns false for null email", () => {
26+
expect(isAdminEmail(null)).toBe(false);
27+
});
28+
29+
it("returns false for undefined email", () => {
30+
expect(isAdminEmail(undefined)).toBe(false);
31+
});
32+
33+
it("returns false for empty string", () => {
34+
expect(isAdminEmail("")).toBe(false);
35+
});
36+
37+
it("returns true when email matches ADMIN_EMAILS list", () => {
38+
process.env.ADMIN_EMAILS = "admin@test.com,ceo@test.com";
39+
expect(isAdminEmail("admin@test.com")).toBe(true);
40+
expect(isAdminEmail("ceo@test.com")).toBe(true);
41+
});
42+
43+
it("handles case-insensitive comparison", () => {
44+
process.env.ADMIN_EMAILS = "Admin@Test.com";
45+
expect(isAdminEmail("admin@test.com")).toBe(true);
46+
expect(isAdminEmail("ADMIN@TEST.COM")).toBe(true);
47+
});
48+
49+
it("returns true when email matches ADMIN_EMAIL single env", () => {
50+
process.env.ADMIN_EMAIL = "founder@test.com";
51+
expect(isAdminEmail("founder@test.com")).toBe(true);
52+
});
53+
54+
it("prefers ADMIN_EMAILS over ADMIN_EMAIL", () => {
55+
process.env.ADMIN_EMAILS = "ceo@test.com";
56+
process.env.ADMIN_EMAIL = "founder@test.com";
57+
expect(isAdminEmail("ceo@test.com")).toBe(true);
58+
expect(isAdminEmail("founder@test.com")).toBe(false);
59+
});
60+
61+
it("trims whitespace from emails", () => {
62+
process.env.ADMIN_EMAIL = " admin@test.com ";
63+
expect(isAdminEmail(" admin@test.com ")).toBe(true);
64+
});
65+
});
66+
67+
describe("canUseLinkedIn", () => {
68+
it("returns true for admin", () => {
69+
expect(canUseLinkedIn("starter", true)).toBe(true);
70+
});
71+
72+
it("returns true for agency plan", () => {
73+
expect(canUseLinkedIn("agency", false)).toBe(true);
74+
});
75+
76+
it("returns false for starter plan", () => {
77+
expect(canUseLinkedIn("starter", false)).toBe(false);
78+
});
79+
80+
it("returns false for pro plan", () => {
81+
expect(canUseLinkedIn("pro", false)).toBe(false);
82+
});
83+
84+
it("returns false for null plan", () => {
85+
expect(canUseLinkedIn(null, false)).toBe(false);
86+
});
87+
88+
it("returns false for undefined plan", () => {
89+
expect(canUseLinkedIn(undefined, false)).toBe(false);
90+
});
91+
});

0 commit comments

Comments
 (0)