Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 232 additions & 1 deletion src/__tests__/unit/components/lingo/CreateLingoNodeDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { toast } from "sonner";
// ─── Helpers ──────────────────────────────────────────────────────────────────

const SLUG = "test-ws";
const WORKSPACE_ID = "ws-id-001";

let mockFetch: ReturnType<typeof vi.fn>;

Expand All @@ -85,15 +86,23 @@ beforeEach(() => {
vi.mocked(toast).mockClear();
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
// Mock URL.createObjectURL / revokeObjectURL
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:mock-preview-url"),
revokeObjectURL: vi.fn(),
});
});

function renderDialog(overrides: Partial<{
isOpen: boolean;
workspaceId: string;
onClose: () => void;
onCreated: (node: any) => void;
}> = {}) {
const props = {
workspaceSlug: SLUG,
workspaceId: WORKSPACE_ID,
isOpen: true,
onClose: vi.fn(),
onCreated: vi.fn(),
Expand All @@ -102,7 +111,13 @@ function renderDialog(overrides: Partial<{
return { ...render(<CreateLingoNodeDialog {...props} />), props };
}

// ─── Tests ────────────────────────────────────────────────────────────────────
function makeFile(name = "icon.png", type = "image/png", size = 1024) {
const file = new File(["x".repeat(size)], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}

// ─── Core dialog tests ────────────────────────────────────────────────────────

describe("CreateLingoNodeDialog", () => {
it("renders name and definition fields when open", () => {
Expand Down Expand Up @@ -352,3 +367,219 @@ describe("CreateLingoNodeDialog", () => {
});
});
});

// ─── Icon upload chain tests ──────────────────────────────────────────────────

describe("CreateLingoNodeDialog — icon upload chain", () => {
it("renders an 'Add icon' button", () => {
renderDialog();
expect(screen.getByTestId("add-icon-button")).toBeInTheDocument();
});

it("does NOT call fetch when a file is selected (deferred upload)", async () => {
renderDialog();
const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile();

await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

expect(mockFetch).not.toHaveBeenCalled();
});

it("shows a preview after valid file select", async () => {
renderDialog();
const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile();

await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

expect(screen.getByTestId("icon-preview")).toBeInTheDocument();
expect(screen.getByTestId("icon-preview")).toHaveAttribute("src", "blob:mock-preview-url");
});

it("shows inline error for invalid file type", async () => {
renderDialog();
const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile("doc.pdf", "application/pdf");

await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

expect(screen.getByTestId("icon-error")).toBeInTheDocument();
expect(mockFetch).not.toHaveBeenCalled();
});

it("shows inline error for oversized file", async () => {
renderDialog();
const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile("big.png", "image/png", 11 * 1024 * 1024);

await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

expect(screen.getByTestId("icon-error")).toBeInTheDocument();
expect(mockFetch).not.toHaveBeenCalled();
});

it("fires presign → PUT → POST in sequence on form submit with a valid file", async () => {
const S3_PATH = "uploads/ws-id-001/lingo-icons/123_abc_icon.png";
const PRESIGN_URL = "https://s3.example.com/presigned";

// 1. Presign call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ presignedUrl: PRESIGN_URL, s3Path: S3_PATH }),
});
// 2. PUT to S3
mockFetch.mockResolvedValueOnce({ ok: true });
// 3. POST create node
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
data: { ref_id: "new-ref", name: "Icon Node", icon_url: S3_PATH },
}),
});

const { props } = renderDialog();

// Select file first
const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile("icon.png", "image/png", 1024);
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

// Fill in name
fireEvent.change(screen.getByTestId("lingo-name-input"), {
target: { value: "Icon Node" },
});

// Submit
await act(async () => {
fireEvent.click(screen.getByTestId("lingo-create-submit"));
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(3);
});

// Call 1: presign
expect(mockFetch.mock.calls[0][0]).toBe("/api/upload/presigned-url");
expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({
workspaceId: WORKSPACE_ID,
context: "lingo",
filename: "icon.png",
contentType: "image/png",
});

// Call 2: PUT to S3
expect(mockFetch.mock.calls[1][0]).toBe(PRESIGN_URL);
expect(mockFetch.mock.calls[1][1].method).toBe("PUT");

// Call 3: POST create node with icon_url
expect(mockFetch.mock.calls[2][0]).toBe(`/api/workspaces/${SLUG}/lingo/nodes`);
const postBody = JSON.parse(mockFetch.mock.calls[2][1].body);
expect(postBody.icon_url).toBe(S3_PATH);

expect(props.onCreated).toHaveBeenCalledWith(
expect.objectContaining({ name: "Icon Node", icon_url: S3_PATH }),
);
});

it("aborts node creation and shows error when presign fails", async () => {
// Presign fails
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Presign error" }),
});

const { props } = renderDialog();

const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile();
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

fireEvent.change(screen.getByTestId("lingo-name-input"), {
target: { value: "Fail Node" },
});

await act(async () => {
fireEvent.click(screen.getByTestId("lingo-create-submit"));
});

await waitFor(() => {
expect(screen.getByTestId("lingo-create-error")).toBeInTheDocument();
});

// Only 1 fetch call (the presign); node POST never fires
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(props.onCreated).not.toHaveBeenCalled();
});

it("aborts node creation and shows error when S3 PUT fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ presignedUrl: "https://s3.example.com/p", s3Path: "some/path" }),
});
// PUT fails
mockFetch.mockResolvedValueOnce({ ok: false });

const { props } = renderDialog();

const fileInput = screen.getByTestId("icon-file-input");
const file = makeFile();
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});

fireEvent.change(screen.getByTestId("lingo-name-input"), {
target: { value: "Fail Node" },
});

await act(async () => {
fireEvent.click(screen.getByTestId("lingo-create-submit"));
});

await waitFor(() => {
expect(screen.getByTestId("lingo-create-error")).toBeInTheDocument();
});

// 2 calls (presign + PUT); node POST never fires
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(props.onCreated).not.toHaveBeenCalled();
});

it("submits without icon when no file is selected", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ success: true, data: { ref_id: "r1", name: "No Icon" } }),
});

renderDialog();
fireEvent.change(screen.getByTestId("lingo-name-input"), {
target: { value: "No Icon" },
});

await act(async () => {
fireEvent.click(screen.getByTestId("lingo-create-submit"));
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});

const postBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(postBody).not.toHaveProperty("icon_url");
});
});
84 changes: 65 additions & 19 deletions src/__tests__/unit/components/lingo/LingoCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { LingoCard } from "@/app/w/[slug]/lingo/components/LingoCard";
import { LingoCard as LearnLingoCard } from "@/app/w/[slug]/learn/lingo/components/LingoCard";
import type { LingoNode } from "@/app/api/mock/lingo/nodes";

const baseNode: LingoNode = {
Expand All @@ -13,27 +14,72 @@ const baseNode: LingoNode = {
date_added_to_graph: 1750000000,
};

describe("LingoCard", () => {
it("renders the node name", () => {
render(<LingoCard node={baseNode} onClick={() => {}} />);
expect(screen.getByText("Test Term")).toBeInTheDocument();
});
// Run the same test suite for both component tree locations
const testSuites: Array<{ label: string; Component: typeof LingoCard }> = [
{ label: "lingo/LingoCard", Component: LingoCard },
{ label: "learn/lingo/LingoCard", Component: LearnLingoCard },
];

it("renders lingo-type-badge when lingo_type is present", () => {
const node: LingoNode = { ...baseNode, lingo_type: "company_jargon" };
render(<LingoCard node={node} onClick={() => {}} />);
const badge = screen.getByTestId("lingo-type-badge");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent("company_jargon");
});
testSuites.forEach(({ label, Component }) => {
describe(label, () => {
it("renders the node name", () => {
render(<Component node={baseNode} onClick={() => {}} />);
expect(screen.getByText("Test Term")).toBeInTheDocument();
});

it("does not render lingo-type-badge when lingo_type is undefined", () => {
render(<LingoCard node={baseNode} onClick={() => {}} />);
expect(screen.queryByTestId("lingo-type-badge")).not.toBeInTheDocument();
});
it("renders lingo-type-badge when lingo_type is present", () => {
const node: LingoNode = { ...baseNode, lingo_type: "company_jargon" };
render(<Component node={node} onClick={() => {}} />);
const badge = screen.getByTestId("lingo-type-badge");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent("company_jargon");
});

it("does not render lingo-type-badge when lingo_type is undefined", () => {
render(<Component node={baseNode} onClick={() => {}} />);
expect(screen.queryByTestId("lingo-type-badge")).not.toBeInTheDocument();
});

it("renders definition when present", () => {
render(<Component node={baseNode} onClick={() => {}} />);
expect(screen.getByText("A test definition.")).toBeInTheDocument();
});

it("renders icon thumbnail when icon_url is set", () => {
const node: LingoNode = {
...baseNode,
icon_url: "uploads/ws-1/lingo-icons/123_abc_logo.png",
};
render(<Component node={node} onClick={() => {}} />);
const img = screen.getByTestId("lingo-icon-thumbnail");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute(
"src",
"/api/upload/presigned-url?s3Key=uploads%2Fws-1%2Flingo-icons%2F123_abc_logo.png",
);
expect(img).toHaveAttribute("loading", "lazy");
});

it("does not render icon thumbnail when icon_url is absent", () => {
render(<Component node={baseNode} onClick={() => {}} />);
expect(screen.queryByTestId("lingo-icon-thumbnail")).not.toBeInTheDocument();
});

it("does not render icon thumbnail when icon_url is null", () => {
const node: LingoNode = { ...baseNode, icon_url: null };
render(<Component node={node} onClick={() => {}} />);
expect(screen.queryByTestId("lingo-icon-thumbnail")).not.toBeInTheDocument();
});

it("renders definition when present", () => {
render(<LingoCard node={baseNode} onClick={() => {}} />);
expect(screen.getByText("A test definition.")).toBeInTheDocument();
it("renders both badge and thumbnail when both icon_url and lingo_type are set", () => {
const node: LingoNode = {
...baseNode,
lingo_type: "company_jargon",
icon_url: "uploads/ws-1/lingo-icons/img.png",
};
render(<Component node={node} onClick={() => {}} />);
expect(screen.getByTestId("lingo-icon-thumbnail")).toBeInTheDocument();
expect(screen.getByTestId("lingo-type-badge")).toBeInTheDocument();
});
});
});
2 changes: 2 additions & 0 deletions src/__tests__/unit/components/lingo/NeighborView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import React from "react";
vi.mock("lucide-react", () => ({
Trash2: () => <svg data-testid="trash-icon" />,
Plus: () => <svg data-testid="plus-icon" />,
ImageIcon: () => <svg data-testid="image-icon" />,
Paperclip: () => <svg data-testid="paperclip-icon" />,
}));

vi.mock("@/components/ui/button", () => ({
Expand Down
Loading
Loading