Cleanup pass 2
This commit is contained in:
@@ -22,7 +22,6 @@ const config: ComponentTestSuiteConfig<AskOrganizerProps> = {
|
||||
subtitle: "Subtitle",
|
||||
description: "Description",
|
||||
buttonText: "Button",
|
||||
buttonHref: "/link",
|
||||
className: "custom",
|
||||
variant: "centered",
|
||||
},
|
||||
@@ -72,11 +71,9 @@ describe("AskOrganizer (behavioral tests)", () => {
|
||||
});
|
||||
|
||||
it("renders button with custom text", () => {
|
||||
render(
|
||||
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
||||
);
|
||||
render(<AskOrganizer title="Test" buttonText="Contact" />);
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
screen.getByRole("button", {
|
||||
name: /contact/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
@@ -17,6 +17,18 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function confirmDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Discard" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function declineDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Keep editing" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the screen with optional starting state and exposes the latest
|
||||
* `state` to the test harness so we can assert the persistence side of
|
||||
@@ -152,9 +164,6 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
|
||||
it("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockImplementation(() => {
|
||||
throw new Error("confirm should not run when customize session is clean");
|
||||
});
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
@@ -171,20 +180,20 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
(within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement).disabled,
|
||||
).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
(within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Cancel customize with edits restores snapshot after confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
@@ -216,24 +225,23 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await confirmDiscardCustomizeEdits();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Saved principle");
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(
|
||||
within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Saved principle");
|
||||
});
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Saved principle");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dirty Escape close stays open when user declines discard confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={() => {
|
||||
@@ -255,11 +263,10 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await screen.findByRole("button", { name: "Keep editing" });
|
||||
await declineDiscardCustomizeEdits();
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists customized policy title for a custom UUID card on Save", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
@@ -11,6 +11,12 @@ import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalRe
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
async function confirmDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Discard" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the screen with a Customize-style preset selection and exposes the
|
||||
* latest `state` to the test via `onState`. Used by the edit-modal save
|
||||
@@ -521,15 +527,11 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
try {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
confirmSpy.mockRestore();
|
||||
}
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await confirmDiscardCustomizeEdits();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
import LanguageSwitcher from "../../app/components/localization/LanguageSwitcher";
|
||||
|
||||
type Props = React.ComponentProps<typeof LanguageSwitcher>;
|
||||
|
||||
const config: ComponentTestSuiteConfig<Props> = {
|
||||
component: LanguageSwitcher,
|
||||
name: "LanguageSwitcher",
|
||||
props: {} as Props,
|
||||
primaryRole: "combobox",
|
||||
testCases: {
|
||||
renders: true,
|
||||
accessibility: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("LanguageSwitcher", () => {
|
||||
componentTestSuite<Props>(config);
|
||||
});
|
||||
@@ -57,37 +57,35 @@ describe("methodCardCustomizeSession", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", () => {
|
||||
const spy = vi.spyOn(window, "confirm");
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", async () => {
|
||||
const confirmFn = vi.fn();
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
await confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
null,
|
||||
{ x: 1 },
|
||||
null,
|
||||
null,
|
||||
"msg",
|
||||
confirmFn,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
expect(confirmFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirmDiscard runs confirm when dirty", () => {
|
||||
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
it("confirmDiscard runs confirm when dirty", async () => {
|
||||
const confirmFn = vi.fn().mockResolvedValue(false);
|
||||
const draft = { n: 1 };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
await confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
snap,
|
||||
{ n: 2 },
|
||||
null,
|
||||
HEADER_0,
|
||||
"Discard?",
|
||||
confirmFn,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(spy).toHaveBeenCalledWith("Discard?");
|
||||
spy.mockRestore();
|
||||
expect(confirmFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { markdownToHtml } from "../../lib/content";
|
||||
|
||||
describe("Markdown Processing", () => {
|
||||
describe("markdownToHtml", () => {
|
||||
it("converts basic markdown to HTML", () => {
|
||||
const markdown = "# Heading\n\nThis is a paragraph.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Heading</h1>");
|
||||
expect(result).toContain("This is a paragraph.");
|
||||
});
|
||||
|
||||
it("converts bold text", () => {
|
||||
const markdown = "This is **bold** text.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
});
|
||||
|
||||
it("converts italic text", () => {
|
||||
const markdown = "This is *italic* text.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<em>italic</em>");
|
||||
});
|
||||
|
||||
it("converts links", () => {
|
||||
const markdown = "Visit [Google](https://google.com) for search.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain('<a href="https://google.com">Google</a>');
|
||||
});
|
||||
|
||||
it("converts line breaks to <br> tags", () => {
|
||||
const markdown = "Line 1\nLine 2\nLine 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("Line 1<br>");
|
||||
expect(result).toContain("Line 2<br>");
|
||||
expect(result).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("converts multiple line breaks to paragraph breaks", () => {
|
||||
const markdown = "Paragraph 1\n\nParagraph 2\n\nParagraph 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<p>Paragraph 1</p>");
|
||||
expect(result).toContain("<p>Paragraph 2</p>");
|
||||
expect(result).toContain("<p>Paragraph 3</p>");
|
||||
});
|
||||
|
||||
it("adds md-gap class to paragraphs", () => {
|
||||
const markdown = "Paragraph 1\n\nParagraph 2";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain('<p class="md-gap">Paragraph 1</p>');
|
||||
expect(result).toContain('<p class="md-gap">Paragraph 2</p>');
|
||||
});
|
||||
|
||||
it("converts unordered lists", () => {
|
||||
const markdown = "- Item 1\n- Item 2\n- Item 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>Item 1</li>");
|
||||
expect(result).toContain("<li>Item 2</li>");
|
||||
expect(result).toContain("<li>Item 3</li>");
|
||||
expect(result).toContain("</ul>");
|
||||
});
|
||||
|
||||
it("converts ordered lists", () => {
|
||||
const markdown = "1. First item\n2. Second item\n3. Third item";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<ol>");
|
||||
expect(result).toContain("<li>First item</li>");
|
||||
expect(result).toContain("<li>Second item</li>");
|
||||
expect(result).toContain("<li>Third item</li>");
|
||||
expect(result).toContain("</ol>");
|
||||
});
|
||||
|
||||
it("converts code blocks", () => {
|
||||
const markdown = "```javascript\nconst x = 1;\n```";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<pre>");
|
||||
expect(result).toContain("<code>");
|
||||
expect(result).toContain("const x = 1;");
|
||||
});
|
||||
|
||||
it("converts inline code", () => {
|
||||
const markdown = "Use `console.log()` to debug.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<code>console.log()</code>");
|
||||
});
|
||||
|
||||
it("converts blockquotes", () => {
|
||||
const markdown = "> This is a quote\n> with multiple lines";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<blockquote>");
|
||||
expect(result).toContain("This is a quote");
|
||||
expect(result).toContain("with multiple lines");
|
||||
expect(result).toContain("</blockquote>");
|
||||
});
|
||||
|
||||
it("converts horizontal rules", () => {
|
||||
const markdown = "Text above\n\n---\n\nText below";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<hr>");
|
||||
});
|
||||
|
||||
it("handles mixed content", () => {
|
||||
const markdown =
|
||||
"# Title\n\nThis is a **bold** paragraph with a [link](https://example.com).\n\n- List item 1\n- List item 2\n\nAnother paragraph with `code`.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Title</h1>");
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
expect(result).toContain('<a href="https://example.com">link</a>');
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>List item 1</li>");
|
||||
expect(result).toContain("<li>List item 2</li>");
|
||||
expect(result).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
const result = markdownToHtml("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("handles whitespace-only input", () => {
|
||||
const result = markdownToHtml(" \n\n ");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("preserves HTML entities", () => {
|
||||
const markdown = "Use < and > for HTML tags.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<");
|
||||
expect(result).toContain(">");
|
||||
});
|
||||
|
||||
it("handles complex nested structures", () => {
|
||||
const markdown =
|
||||
"# Main Title\n\n## Subtitle\n\nThis is a paragraph with **bold** and *italic* text.\n\n1. First item with `code`\n2. Second item with [link](https://example.com)\n\n> This is a quote\n> with **bold** text\n\n```javascript\nconst example = 'test';\n```";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Main Title</h1>");
|
||||
expect(result).toContain("<h2>Subtitle</h2>");
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
expect(result).toContain("<em>italic</em>");
|
||||
expect(result).toContain("<ol>");
|
||||
expect(result).toContain("<code>code</code>");
|
||||
expect(result).toContain('<a href="https://example.com">link</a>');
|
||||
expect(result).toContain("<blockquote>");
|
||||
expect(result).toContain("<pre>");
|
||||
});
|
||||
|
||||
it("handles malformed markdown gracefully", () => {
|
||||
const markdown = "**Unclosed bold\n\n*Unclosed italic\n\n[Unclosed link";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
// Should not throw an error and should handle gracefully
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts headings of different levels", () => {
|
||||
const markdown = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>H1</h1>");
|
||||
expect(result).toContain("<h2>H2</h2>");
|
||||
expect(result).toContain("<h3>H3</h3>");
|
||||
expect(result).toContain("<h4>H4</h4>");
|
||||
expect(result).toContain("<h5>H5</h5>");
|
||||
expect(result).toContain("<h6>H6</h6>");
|
||||
});
|
||||
|
||||
it("handles tables", () => {
|
||||
const markdown =
|
||||
"| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<table>");
|
||||
expect(result).toContain("<thead>");
|
||||
expect(result).toContain("<th>Header 1</th>");
|
||||
expect(result).toContain("<th>Header 2</th>");
|
||||
expect(result).toContain("<tbody>");
|
||||
expect(result).toContain("<td>Cell 1</td>");
|
||||
expect(result).toContain("<td>Cell 2</td>");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const deleteManyMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const sendMagicLinkEmailMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
magicLinkToken: {
|
||||
deleteMany: (...args: unknown[]) => deleteManyMock(...args),
|
||||
create: (...args: unknown[]) => createMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashSessionToken: (token: string) => `hash-${token}`,
|
||||
newSessionToken: () => "plain-token",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/mail", () => ({
|
||||
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { POST } from "../../app/api/auth/magic-link/request/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
deleteManyMock.mockReset();
|
||||
createMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
sendMagicLinkEmailMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
deleteManyMock.mockResolvedValue(undefined);
|
||||
createMock.mockResolvedValue(undefined);
|
||||
sendMagicLinkEmailMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/magic-link/request", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "a@b.c" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("invalid_json");
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "not-an-email" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("creates a token and sends mail for a valid email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "Member@Example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(deleteManyMock).toHaveBeenCalledWith({
|
||||
where: { email: "member@example.com" },
|
||||
});
|
||||
expect(createMock).toHaveBeenCalled();
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledWith(
|
||||
"member@example.com",
|
||||
expect.stringContaining("/api/auth/magic-link/verify?token="),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 502 and rolls back the token when mail fails", async () => {
|
||||
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "a@b.c" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("mail_failed");
|
||||
expect(deleteManyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/auth/session/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/session", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(getSessionUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns user null when there is no session", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ user: null });
|
||||
});
|
||||
|
||||
it("returns the signed-in user id and email", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce({
|
||||
id: "u1",
|
||||
email: "member@example.com",
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
user: { id: "u1", email: "member@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id on the response", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session", {
|
||||
headers: { "x-request-id": "req_session-1" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.headers.get("x-request-id")).toBe("req_session-1");
|
||||
});
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getBlogPostFiles,
|
||||
parseBlogPost,
|
||||
getAllBlogPosts,
|
||||
getBlogPostBySlug,
|
||||
getRelatedBlogPosts,
|
||||
getAllTags,
|
||||
getBlogPostsByTag,
|
||||
} from "../../lib/content.js";
|
||||
|
||||
// Mock fs and path modules
|
||||
vi.mock("fs", () => ({
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("path", () => ({
|
||||
join: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Content Processing", () => {
|
||||
let mockReaddirSync, mockReadFileSync, mockPathJoin;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get references to the mocked functions
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
mockReaddirSync = fs.readdirSync;
|
||||
mockReadFileSync = fs.readFileSync;
|
||||
mockPathJoin = path.join;
|
||||
|
||||
// Mock process.cwd to return a predictable path
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/mock/project/root");
|
||||
|
||||
// Mock path.join to return predictable paths
|
||||
if (mockPathJoin && mockPathJoin.mockImplementation) {
|
||||
mockPathJoin.mockImplementation((...args) => args.join("/"));
|
||||
}
|
||||
});
|
||||
|
||||
describe("getBlogPostFiles", () => {
|
||||
it("should return markdown files from content directory", () => {
|
||||
const mockFiles = ["post1.md", "post2.mdx", "image.png", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual(["post1.md", "post2.mdx", "post3.md"]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle directory read errors gracefully", () => {
|
||||
mockReaddirSync.mockImplementation(() => {
|
||||
throw new Error("Directory not found");
|
||||
});
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual([]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlogPost", () => {
|
||||
it("should parse a valid markdown file", () => {
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
tags: ["test"]
|
||||
related: []
|
||||
---
|
||||
# Test Content
|
||||
This is the content.`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("test-post.md");
|
||||
expect(result).toMatchObject({
|
||||
slug: "test-post",
|
||||
frontmatter: {
|
||||
title: "Test Post",
|
||||
description:
|
||||
"A test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["test"],
|
||||
related: [],
|
||||
},
|
||||
content: "\n# Test Content\nThis is the content.",
|
||||
filePath: "test-post.md",
|
||||
});
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog/test-post.md",
|
||||
"utf8"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid frontmatter", () => {
|
||||
const mockContent = `---
|
||||
title: "" # Invalid title
|
||||
description: "A test description"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("invalid-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle file read errors gracefully", () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const result = parseBlogPost("non-existent-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllBlogPosts", () => {
|
||||
it("should return all valid blog posts sorted by date", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock fs.readFileSync for each post
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getAllBlogPosts();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].slug).toBe("post2"); // Latest date
|
||||
expect(result[1].slug).toBe("post1");
|
||||
expect(result[2].slug).toBe("post3"); // Oldest date
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostBySlug", () => {
|
||||
it("should return blog post for valid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getBlogPostBySlug("test-post");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.slug).toBe("test-post");
|
||||
});
|
||||
|
||||
it("should return null for invalid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostBySlug("invalid-slug");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRelatedBlogPosts", () => {
|
||||
it("should return related posts when slugs are provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock content for all posts
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
related: ["post2"]
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", ["post2", "post3"], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2");
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
|
||||
it("should fallback to recent posts when no related slugs provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", [], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2"); // Should be the most recent after excluding 'post1'
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllTags", () => {
|
||||
it("should return unique tags from all posts", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getAllTags();
|
||||
expect(result).toEqual(expect.arrayContaining(["tagA", "tagB", "tagC"]));
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostsByTag", () => {
|
||||
it("should return posts with matching tag", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getBlogPostsByTag("tagA");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].slug).toBe("post1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findFirstRuleMock = vi.fn();
|
||||
const findFirstStakeholderMock = vi.fn();
|
||||
const countMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const createInviteMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const sendInviteEmailMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => true,
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
publishedRule: {
|
||||
findFirst: (...args: unknown[]) => findFirstRuleMock(...args),
|
||||
},
|
||||
ruleStakeholder: {
|
||||
findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args),
|
||||
count: (...args: unknown[]) => countMock(...args),
|
||||
delete: (...args: unknown[]) => deleteMock(...args),
|
||||
update: (...args: unknown[]) => updateMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/ruleStakeholderInviteOps", () => ({
|
||||
createRuleStakeholderInviteAndSendMail: (...args: unknown[]) =>
|
||||
createInviteMock(...args),
|
||||
stakeholderInviteVerifyUrl: (origin: string, token: string) =>
|
||||
`${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashSessionToken: (token: string) => `hash-${token}`,
|
||||
newSessionToken: () => "invite-token",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/mail", () => ({
|
||||
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
|
||||
sendInviteEmailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/route";
|
||||
import { POST as POST_RESEND } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route";
|
||||
import { POST } from "../../app/api/rules/[id]/stakeholders/route";
|
||||
|
||||
const owner = { id: "owner-1", email: "owner@example.com" };
|
||||
const routeCtx = { params: Promise.resolve({ id: "rule-1" }) };
|
||||
const memberCtx = {
|
||||
params: Promise.resolve({ id: "rule-1", stakeholderId: "st-1" }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
findFirstRuleMock.mockReset();
|
||||
findFirstStakeholderMock.mockReset();
|
||||
countMock.mockReset();
|
||||
deleteMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
createInviteMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
sendInviteEmailMock.mockReset();
|
||||
getSessionUserMock.mockResolvedValue(owner);
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
findFirstRuleMock.mockResolvedValue({ id: "rule-1", title: "My rule" });
|
||||
createInviteMock.mockResolvedValue({ ok: true });
|
||||
sendInviteEmailMock.mockResolvedValue(undefined);
|
||||
updateMock.mockResolvedValue(undefined);
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
countMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("POST /api/rules/[id]/stakeholders", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "inv@example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 400 when inviting the owner email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "Owner@Example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("creates an invite and returns 201", async () => {
|
||||
findFirstStakeholderMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "st-new",
|
||||
email: "inv@example.com",
|
||||
invitedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "inv@example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(createInviteMock).toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
stakeholder: { email: string; status: string };
|
||||
};
|
||||
expect(body.stakeholder.email).toBe("inv@example.com");
|
||||
expect(body.stakeholder.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/rules/[id]/stakeholders/[stakeholderId]", () => {
|
||||
it("returns 403 when the rule belongs to another user", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
rule: { userId: "other-user" },
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the stakeholder for the rule owner", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
rule: { userId: owner.id },
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "st-1" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => {
|
||||
it("returns 400 when the invite was already accepted", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
email: "inv@example.com",
|
||||
inviteTokenHash: null,
|
||||
inviteExpiresAt: null,
|
||||
rule: { userId: owner.id, title: "My rule" },
|
||||
});
|
||||
const res = await POST_RESEND(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
|
||||
{ method: "POST" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("rotates the token and sends a new invite email", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
email: "inv@example.com",
|
||||
inviteTokenHash: "old-hash",
|
||||
inviteExpiresAt: new Date("2026-01-01T00:00:00Z"),
|
||||
rule: { userId: owner.id, title: "My rule" },
|
||||
});
|
||||
const res = await POST_RESEND(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
|
||||
{ method: "POST" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
expect(sendInviteEmailMock).toHaveBeenCalledWith(
|
||||
"inv@example.com",
|
||||
expect.stringContaining("/api/invites/rule-stakeholder/verify?token="),
|
||||
"My rule",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
let uploadRoot: string | null = null;
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", () => ({
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/uploads/[id]/route";
|
||||
|
||||
beforeEach(async () => {
|
||||
uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-test-"));
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockImplementation(() => uploadRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uploadRoot = null;
|
||||
});
|
||||
|
||||
describe("GET /api/uploads/[id]", () => {
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/uploads/upload-1"),
|
||||
{ params: Promise.resolve({ id: "upload-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 404 when the upload is not found", async () => {
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"https://x.test/api/uploads/550e8400-e29b-41d4-a716-446655440000",
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the file bytes with content type", async () => {
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
await writeFile(path.join(uploadRoot!, `${id}.png`), "png-bytes");
|
||||
const res = await GET(
|
||||
new NextRequest(`https://x.test/api/uploads/${id}`),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(res.headers.get("Cache-Control")).toContain("immutable");
|
||||
expect(await res.text()).toBe("png-bytes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", () => ({
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { POST } from "../../app/api/uploads/route";
|
||||
|
||||
function multipartRequest(opts: {
|
||||
purpose?: string;
|
||||
fileName?: string;
|
||||
fileContent?: string;
|
||||
}): NextRequest {
|
||||
const boundary = "----VitestBoundary";
|
||||
const parts: string[] = [];
|
||||
if (opts.purpose) {
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\n${opts.purpose}\r\n`,
|
||||
);
|
||||
}
|
||||
if (opts.fileName && opts.fileContent !== undefined) {
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`--${boundary}--\r\n`);
|
||||
return new NextRequest("https://x.test/api/uploads", {
|
||||
method: "POST",
|
||||
body: parts.join(""),
|
||||
headers: {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
|
||||
});
|
||||
|
||||
describe("POST /api/uploads", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("server_misconfigured");
|
||||
});
|
||||
|
||||
it("returns 400 when purpose is missing", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
const res = await POST(
|
||||
multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user