establish db

This commit is contained in:
2026-06-05 20:30:28 -04:00
parent 820a872f07
commit 58b5804d7a
95 changed files with 42873 additions and 233 deletions

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
import * as diseasesLib from "@/lib/api/diseases";
// Mock the diseases library
vi.mock("@/lib/api/diseases", () => ({
listDiseases: vi.fn(),
}));
describe("GET /api/diseases", () => {
const createRequest = (searchParams: string) => {
const url = new URL(`http://localhost/api/diseases${searchParams}`);
const req = new Request(url);
// Mock NextRequest.nextUrl
(req as any).nextUrl = url;
return req;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns all diseases with no filters", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "early-blight", name: "Early Blight" },
{ id: "late-blight", name: "Late Blight" },
]);
const response = await GET(createRequest(""));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.diseases).toHaveLength(2);
expect(body.total).toBe(2);
});
it("filters diseases by plantId", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "early-blight", name: "Early Blight", plantId: "tomato" },
]);
const response = await GET(createRequest("?plantId=tomato"));
expect(response.status).toBe(200);
});
it("filters diseases by search term", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "early-blight", name: "Early Blight" },
]);
const response = await GET(createRequest("?search=blight"));
expect(response.status).toBe(200);
});
it("filters diseases by causalAgentType", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "early-blight", name: "Early Blight", causalAgentType: "fungal" },
]);
const response = await GET(createRequest("?causalAgentType=fungal"));
expect(response.status).toBe(200);
});
it("filters diseases by severity", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "early-blight", name: "Early Blight", severity: "moderate" },
]);
const response = await GET(createRequest("?severity=moderate"));
expect(response.status).toBe(200);
});
it("returns 400 for empty search term", async () => {
const response = await GET(createRequest("?search="));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Bad Request");
});
it("returns 400 for invalid causalAgentType", async () => {
const response = await GET(createRequest("?causalAgentType=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid causalAgentType/i);
});
it("returns 400 for invalid severity", async () => {
const response = await GET(createRequest("?severity=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid severity/i);
});
it("accepts valid causalAgentTypes", async () => {
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
for (const type of validTypes) {
const response = await GET(createRequest(`?causalAgentType=${type}`));
expect(response.status).toBe(200);
}
});
it("accepts valid severities", async () => {
const validSeverities = ["low", "moderate", "high", "critical"];
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
for (const severity of validSeverities) {
const response = await GET(createRequest(`?severity=${severity}`));
expect(response.status).toBe(200);
}
});
it("returns cache control header", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
const response = await GET(createRequest(""));
const cacheControl = response.headers.get("Cache-Control");
expect(cacheControl).toContain("max-age=3600");
});
});

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { GET } from "./route";
describe("GET /api/health", () => {
it("returns 200 with status ok", async () => {
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body.timestamp).toBeDefined();
});
it("returns valid ISO timestamp", async () => {
const response = await GET();
const body = await response.json();
const date = new Date(body.timestamp);
expect(date.toString()).not.toBe("Invalid Date");
});
it("returns JSON content type", async () => {
const response = await GET();
const contentType = response.headers.get("content-type");
expect(contentType).toContain("application/json");
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
import * as diseasesLib from "@/lib/api/diseases";
// Mock the diseases library
vi.mock("@/lib/api/diseases", () => ({
listPlants: vi.fn(),
}));
describe("GET /api/plants", () => {
const createRequest = (searchParams: string) => {
const url = new URL(`http://localhost/api/plants${searchParams}`);
const req = new Request(url);
(req as any).nextUrl = url;
return req;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns all plants with no filters", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tomato", commonName: "Tomato" },
{ id: "pepper", commonName: "Pepper" },
]);
const response = await GET(createRequest(""));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.plants).toHaveLength(2);
expect(body.total).toBe(2);
});
it("filters plants by search term", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tomato", commonName: "Tomato" },
]);
const response = await GET(createRequest("?search=tomato"));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.plants[0].commonName).toBe("Tomato");
});
it("filters plants by category", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tomato", commonName: "Tomato", category: "vegetables" },
]);
const response = await GET(createRequest("?category=vegetables"));
expect(response.status).toBe(200);
});
it("returns 400 for empty search term", async () => {
const response = await GET(createRequest("?search="));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Bad Request");
});
it("returns 400 for invalid category", async () => {
const response = await GET(createRequest("?category=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid category/i);
});
it("returns cache control header", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([]);
const response = await GET(createRequest(""));
const cacheControl = response.headers.get("Cache-Control");
expect(cacheControl).toContain("max-age=3600");
});
it("accepts valid categories", async () => {
const validCategories = [
"vegetables",
"herbs",
"houseplants",
"flowers",
];
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([]);
for (const cat of validCategories) {
const response = await GET(createRequest(`?category=${cat}`));
expect(response.status).toBe(200);
}
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import BrowseContent from "@/app/browse/BrowseContent";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
useSearchParams: vi.fn(() => ({
get: vi.fn(() => null),
})),
}));
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: any) => (
<div data-testid={`plant-card-${plant.id}`}>
<span>{plant.commonName}</span>
<span>{plant.emoji}</span>
</div>
),
}));
// Mock EmptyState
vi.mock("@/components/EmptyState", () => ({
default: ({ title, description, actionLabel }: any) => (
<div data-testid="empty-state">
<span data-testid="empty-title">{title}</span>
<span data-testid="empty-desc">{description}</span>
{actionLabel && <span>{actionLabel}</span>}
</div>
),
}));
describe("BrowseContent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders page header with plant count", () => {
render(<BrowseContent />);
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
});
it("renders search input", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox", {
name: /Search plants and diseases/i,
});
expect(searchInput).toBeInTheDocument();
});
it("filters plants by search query", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
// Should show tomato plant
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("shows results count", () => {
render(<BrowseContent />);
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
});
it("renders category filter tabs", () => {
render(<BrowseContent />);
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
expect(tablist).toBeInTheDocument();
// Should have category tabs
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBeGreaterThan(0);
});
it("filters by category when tab is clicked", () => {
render(<BrowseContent />);
const tabs = screen.getAllByRole("tab");
// Click a category tab (not 'all')
const vegTab = tabs.find((t) => t.textContent?.toLowerCase().includes("vegetable"));
if (vegTab) {
fireEvent.click(vegTab);
expect(screen.getByText(/in vegetable/i)).toBeInTheDocument();
}
});
it("clears search when clear button is clicked", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
expect(searchInput.value).toBe("tomato");
const clearBtn = screen.getByRole("button", { name: /Clear search/i });
fireEvent.click(clearBtn);
expect(searchInput.value).toBe("");
});
it("shows empty state when no plants match search", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
expect(screen.getByTestId("empty-title")).toHaveTextContent("No plants found");
});
it("shows empty state with search query in description", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
expect(screen.getByTestId("empty-desc")).toHaveTextContent(/xyznonexistent123/i);
});
it("shows matching text in results count", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
expect(screen.getByText(/matching "tomato"/i)).toBeInTheDocument();
});
it("renders all plant cards when no filter applied", () => {
render(<BrowseContent />);
// Should show all plants
const plantCards = screen.getAllByTestId(/plant-card-/);
expect(plantCards.length).toBeGreaterThan(0);
});
it("searches by scientific name", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanum" } });
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("searches by family name", () => {
render(<BrowseContent />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanaceae" } });
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import NotFound from "@/app/not-found";
describe("NotFound (404 page)", () => {
it("renders 404 heading", () => {
render(<NotFound />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
it("renders plant-themed messaging", () => {
render(<NotFound />);
// Should have plant-themed content
const container = screen.container;
expect(container.textContent).toMatch(/plant|leaf|garden|grow/i);
});
it("renders link to go home", () => {
render(<NotFound />);
const homeLink = screen.getByRole("link", { name: /home/i });
expect(homeLink).toHaveAttribute("href", "/");
});
it("renders illustration or emoji", () => {
render(<NotFound />);
// Should have some visual element
const container = screen.container;
expect(container.textContent).toMatch(/[🍂🌿🌱🌻🍃]/);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "@/app/page";
// Mock components that are used in the homepage
vi.mock("@/components/Navbar", () => ({
default: () => <nav data-testid="navbar">Navbar</nav>,
}));
vi.mock("@/components/Footer", () => ({
default: () => <footer data-testid="footer">Footer</footer>,
}));
vi.mock("@/components/ImageUpload", () => ({
default: () => <div data-testid="image-upload">Upload</div>,
}));
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: any) => (
<div data-testid={`plant-card-${plant.id}`}>{plant.commonName}</div>
),
}));
describe("Homepage (page.tsx)", () => {
it("renders hero section with title", () => {
render(<Page />);
expect(screen.getByRole("banner")).toBeInTheDocument();
});
it("renders image upload component", () => {
render(<Page />);
expect(screen.getByTestId("image-upload")).toBeInTheDocument();
});
it("renders trust signals section", () => {
render(<Page />);
// Trust signals should be present
const trustSignals = screen.queryAllByText(/95/i);
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
});
it("renders how it works section", () => {
render(<Page />);
expect(screen.getByText(/How It Works/i)).toBeInTheDocument();
});
it("renders featured plants section", () => {
render(<Page />);
expect(screen.getByText(/Featured Plants/i)).toBeInTheDocument();
});
it("renders navbar", () => {
render(<Page />);
expect(screen.getByTestId("navbar")).toBeInTheDocument();
});
it("renders footer", () => {
render(<Page />);
expect(screen.getByTestId("footer")).toBeInTheDocument();
});
it("renders beta disclaimer", () => {
render(<Page />);
expect(screen.getByText(/beta/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import ResultsPage from "@/app/results/[imageId]/page";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
back: vi.fn(),
})),
useParams: vi.fn(() => ({ imageId: "test-image-123" })),
}));
// Mock API
vi.mock("@/lib/api/identify", () => ({
identifyPlant: vi.fn(),
}));
// Mock ResultsDashboard
vi.mock("@/components/ResultsDashboard", () => ({
default: ({ loading, error, response }: any) => (
<div data-testid="results-dashboard">
{loading && <span>Loading...</span>}
{error && <span>Error: {error}</span>}
{response && <span>Results for {response.predictions?.length} predictions</span>}
</div>
),
}));
// Mock LoadingSkeleton
vi.mock("@/components/LoadingSkeleton", () => ({
default: () => <div data-testid="loading-skeleton">Loading...</div>,
}));
// Mock EmptyState
vi.mock("@/components/EmptyState", () => ({
default: ({ title }: any) => <div data-testid="empty-state">{title}</div>,
}));
describe("ResultsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading state initially", () => {
const { identifyPlant } = require("@/lib/api/identify");
// Make identifyPlant never resolve
identifyPlant.mockReturnValue(new Promise(() => {}));
render(<ResultsPage />);
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
});
it("renders error state when identification fails", async () => {
const { identifyPlant } = require("@/lib/api/identify");
identifyPlant.mockRejectedValue(new Error("Image not found"));
render(<ResultsPage />);
await waitFor(() => {
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
});
});
it("renders results when identification succeeds", async () => {
const { identifyPlant } = require("@/lib/api/identify");
identifyPlant.mockResolvedValue({
predictions: [
{
diseaseId: "early-blight",
disease: {
id: "early-blight",
name: "Early Blight",
causalAgent: "Alternaria solani",
causalAgentType: "fungal",
severity: "moderate",
symptoms: ["Dark spots"],
treatment: ["Remove leaves"],
lookalikeDiseaseIds: [],
plantId: "tomato",
},
confidence: { raw: 0.85, adjusted: 0.82 },
lookalikes: [],
},
],
metadata: {
model: "mock-model",
inferenceTimeMs: 150,
imageId: "test-image-123",
},
});
render(<ResultsPage />);
await waitFor(() => {
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import EmptyState from "@/components/EmptyState";
describe("EmptyState", () => {
it("renders title", () => {
render(<EmptyState title="No Results" />);
expect(screen.getByText("No Results")).toBeInTheDocument();
});
it("renders description", () => {
render(
<EmptyState
title="No Results"
description="Try adjusting your search terms."
/>
);
expect(screen.getByText("Try adjusting your search terms.")).toBeInTheDocument();
});
it("renders CTA button with label", () => {
const onAction = vi.fn();
render(
<EmptyState
title="No Results"
actionLabel="Clear Filters"
onAction={onAction}
/>
);
const button = screen.getByRole("button", { name: /Clear Filters/i });
expect(button).toBeInTheDocument();
});
it("calls onAction when CTA button is clicked", () => {
const onAction = vi.fn();
render(
<EmptyState
title="No Results"
actionLabel="Try Again"
onAction={onAction}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Try Again/i }));
expect(onAction).toHaveBeenCalled();
});
it("does not render CTA button when no actionLabel provided", () => {
render(<EmptyState title="No Results" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders illustration emoji", () => {
render(<EmptyState title="No Results" illustration="🔍" />);
expect(screen.getByText("🔍")).toBeInTheDocument();
});
it("renders default illustration when none provided", () => {
render(<EmptyState title="No Results" />);
// Default illustration should be present
const container = screen.container;
expect(container.querySelector(".text-5xl")).toBeInTheDocument();
});
it("renders with custom className", () => {
render(<EmptyState title="No Results" className="custom-class" />);
const container = screen.container;
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ErrorBoundary from "@/components/ErrorBoundary";
// Component that throws on render
function ThrowOnRender() {
throw new Error("Boom!");
}
describe("ErrorBoundary", () => {
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
process.env.NODE_ENV = originalEnv;
});
it("renders children when no error occurs", () => {
render(
<ErrorBoundary>
<div data-testid="child">Hello World</div>
</ErrorBoundary>
);
expect(screen.getByTestId("child")).toBeInTheDocument();
expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument();
});
it("renders fallback UI when child throws", () => {
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(screen.getByText(/A leaf must have fallen/)).toBeInTheDocument();
});
it("renders custom fallback when provided", () => {
render(
<ErrorBoundary fallback={<div data-testid="custom-fallback">Custom error</div>}>
<ThrowOnRender />
</ErrorBoundary>
);
expect(screen.getByTestId("custom-fallback")).toBeInTheDocument();
});
it("shows 'Try again' button that resets state", () => {
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
const tryAgain = screen.getByText(/Try again/);
expect(tryAgain).toBeInTheDocument();
// Clicking Try again resets the error state
fireEvent.click(tryAgain);
// After reset, the child will throw again, so fallback reappears
// But the key is the button exists and is clickable
expect(tryAgain).toBeEnabled();
});
it("shows 'Go home' link", () => {
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
const goHome = screen.getByText(/Go home/);
expect(goHome).toBeInTheDocument();
expect(goHome.closest("a")).toHaveAttribute("href", "/");
});
it("shows error details in development mode", () => {
process.env.NODE_ENV = "development";
vi.spyOn(console, "error").mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
expect(screen.getByText(/Error details \(dev only\)/)).toBeInTheDocument();
});
it("does not show error details in production mode", () => {
process.env.NODE_ENV = "production";
vi.spyOn(console, "error").mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
expect(screen.queryByText(/Error details/)).not.toBeInTheDocument();
});
it("logs error to console", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowOnRender />
</ErrorBoundary>
);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Footer from "@/components/Footer";
describe("Footer", () => {
it("renders footer element", () => {
render(<Footer />);
expect(screen.getByRole("contentinfo")).toBeInTheDocument();
});
it("renders app name", () => {
render(<Footer />);
expect(screen.getByText(/Plant Disease/i)).toBeInTheDocument();
});
it("renders navigation links", () => {
render(<Footer />);
// Should have links
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);
});
it("renders copyright or year", () => {
render(<Footer />);
const container = screen.container;
expect(container.textContent).toMatch(/\d{4}/);
});
it("renders disclaimer text", () => {
render(<Footer />);
const container = screen.container;
expect(container.textContent).toMatch(/beta|preview|accuracy|disclaimer/i);
});
it("renders links section with nav links", () => {
render(<Footer />);
expect(screen.getByText(/Links/i)).toBeInTheDocument();
});
it("renders about section", () => {
render(<Footer />);
expect(screen.getByText(/About/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import ImageUpload from "@/components/ImageUpload";
import * as uploadApi from "@/lib/api/upload";
import * as imageProcessing from "@/lib/image-processing";
// Mock dependencies
vi.mock("@/lib/api/upload", () => ({
uploadImage: vi.fn(),
}));
vi.mock("@/lib/image-processing", () => ({
validateImageFile: vi.fn(),
}));
describe("ImageUpload", () => {
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
const mockResponse = {
imageId: "test-id-123",
tensorShape: [3, 224, 224],
previewUrl: "/uploads/test-id-123.png",
};
beforeEach(() => {
vi.clearAllMocks();
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
ok: true,
});
});
it("renders drop zone with upload prompt", () => {
render(<ImageUpload />);
expect(screen.getByRole("button", { name: /Upload a plant image/i })).toBeInTheDocument();
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
expect(screen.getByText(/PNG, JPG, WebP/i)).toBeInTheDocument();
});
it("renders disabled state when disabled prop is true", () => {
render(<ImageUpload disabled />);
const dropZone = screen.getByRole("button");
expect(dropZone).toHaveClass("opacity-50");
});
it("triggers file input click on drop zone click", () => {
render(<ImageUpload />);
const dropZone = screen.getByRole("button");
fireEvent.click(dropZone);
// The hidden file input exists in the DOM
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).toBeInTheDocument();
});
it("triggers file input on keyboard Enter", () => {
render(<ImageUpload />);
const dropZone = screen.getByRole("button");
fireEvent.keyDown(dropZone, { key: "Enter", code: "Enter" });
});
it("triggers file input on keyboard Space", () => {
render(<ImageUpload />);
const dropZone = screen.getByRole("button");
fireEvent.keyDown(dropZone, { key: " ", code: "Space" });
});
it("shows drag over state", () => {
render(<ImageUpload />);
const dropZone = screen.getByRole("button");
fireEvent.dragEnter(dropZone);
expect(screen.getByText(/Drop your image here/i)).toBeInTheDocument();
});
it("resets drag over state on drag leave", () => {
render(<ImageUpload />);
const dropZone = screen.getByRole("button");
fireEvent.dragEnter(dropZone);
fireEvent.dragLeave(dropZone);
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
});
it("handles file selection and shows uploading state", async () => {
const onUpload = vi.fn();
render(<ImageUpload onUpload={onUpload} />);
// Simulate file selection via the file input
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(screen.getByText(/Uploading/i)).toBeInTheDocument();
});
});
it("calls onUpload callback on success", async () => {
const onUpload = vi.fn();
render(<ImageUpload onUpload={onUpload} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(onUpload).toHaveBeenCalledWith(mockResponse);
});
});
it("shows success state with image details", async () => {
render(<ImageUpload />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
});
expect(screen.getByText(/Upload Another/i)).toBeInTheDocument();
});
it("calls onError callback when upload fails", async () => {
const onError = vi.fn();
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Network error")
);
render(<ImageUpload onError={onError} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(onError).toHaveBeenCalledWith("Network error");
});
});
it("shows error state with retry and clear buttons", async () => {
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Upload failed")
);
render(<ImageUpload />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(screen.getByText(/Upload Failed/i)).toBeInTheDocument();
});
expect(screen.getByText(/Retry/i)).toBeInTheDocument();
expect(screen.getByText(/Clear/i)).toBeInTheDocument();
});
it("calls onError with validation error", async () => {
const onError = vi.fn();
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
ok: false,
error: "File too large",
});
render(<ImageUpload onError={onError} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(onError).toHaveBeenCalledWith("File too large");
});
});
it("clears state when Clear/Upload Another button is clicked", async () => {
render(<ImageUpload />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
await waitFor(() => {
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upload Another/i));
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
});
it("does not handle file when disabled", async () => {
render(<ImageUpload disabled />);
const dropZone = screen.getByRole("button");
fireEvent.click(dropZone);
expect(uploadApi.uploadImage).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import LoadingSkeleton, {
ResultsSkeleton,
PlantCardSkeleton,
UploadSkeleton,
} from "@/components/LoadingSkeleton";
describe("LoadingSkeleton", () => {
it("renders default text variant skeleton", () => {
render(<LoadingSkeleton />);
const container = screen.container;
// Default text variant renders 3 lines with animate-pulse
const pulseElements = container.querySelectorAll(".animate-pulse");
expect(pulseElements.length).toBe(3);
});
it("renders skeleton with custom className", () => {
render(<LoadingSkeleton className="custom-class" />);
const container = screen.container;
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("renders multiple skeletons when count > 1", () => {
render(<LoadingSkeleton count={3} />);
// Each text variant has 3 div lines, 3 groups = 9 divs
const container = screen.container;
const pulseElements = container.querySelectorAll(".animate-pulse");
expect(pulseElements.length).toBe(9);
});
});
describe("LoadingSkeleton variants", () => {
it("renders card variant with image and text blocks", () => {
render(<LoadingSkeleton variant="card" />);
const container = screen.container;
expect(container.querySelector(".rounded-xl")).toBeInTheDocument();
});
it("renders text variant with staggered widths", () => {
render(<LoadingSkeleton variant="text" />);
const container = screen.container;
const lines = container.querySelectorAll(".animate-pulse");
expect(lines.length).toBe(3);
});
it("renders image variant", () => {
render(<LoadingSkeleton variant="image" />);
const container = screen.container;
const image = container.querySelector(".animate-pulse");
expect(image).toBeInTheDocument();
expect(image).toHaveClass("h-48");
});
it("renders circle variant", () => {
render(<LoadingSkeleton variant="circle" />);
const container = screen.container;
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
});
it("renders row variant with icon and text", () => {
render(<LoadingSkeleton variant="row" />);
const container = screen.container;
const row = container.querySelector(".flex.items-center.gap-4");
expect(row).toBeInTheDocument();
});
});
describe("ResultsSkeleton", () => {
it("renders a full-page results skeleton with status role", () => {
render(<ResultsSkeleton />);
const status = screen.getByRole("status", { name: /Loading results/i });
expect(status).toBeInTheDocument();
});
it("renders image, text, and card sections", () => {
render(<ResultsSkeleton />);
const container = screen.container;
const pulseElements = container.querySelectorAll(".animate-pulse");
expect(pulseElements.length).toBeGreaterThan(5);
});
});
describe("PlantCardSkeleton", () => {
it("renders default 6 card skeletons", () => {
render(<PlantCardSkeleton />);
const container = screen.container;
const cards = container.querySelectorAll(".rounded-xl");
expect(cards.length).toBe(6);
});
it("renders custom count of card skeletons", () => {
render(<PlantCardSkeleton count={3} />);
const container = screen.container;
const cards = container.querySelectorAll(".rounded-xl");
expect(cards.length).toBe(3);
});
});
describe("UploadSkeleton", () => {
it("renders upload area skeleton with status role", () => {
render(<UploadSkeleton />);
const status = screen.getByRole("status", { name: /Loading upload area/i });
expect(status).toBeInTheDocument();
});
it("renders circle and text skeletons inside dashed border", () => {
render(<UploadSkeleton />);
const container = screen.container;
expect(container.querySelector(".border-dashed")).toBeInTheDocument();
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
});
});

View File

@@ -6,163 +6,153 @@ import type { Disease } from "@/lib/types";
describe("LookalikeWarning", () => {
const mockDisease: Disease = {
id: "early-blight",
plantId: "tomato",
name: "Early Blight",
scientificName: "Alternaria solani",
causalAgent: "Alternaria solani",
causalAgentType: "fungal",
description: "Early blight is a common fungal disease.",
symptoms: [
"Dark brown spots with concentric rings on lower leaves",
"Yellowing of leaves surrounding infected spots",
"Premature defoliation starting from bottom of plant",
],
causes: ["Warm temperatures with high humidity"],
treatment: ["Remove infected leaves", "Apply fungicide"],
prevention: ["Practice crop rotation"],
lookalikeDiseaseIds: ["late-blight"],
severity: "moderate",
symptoms: [
"Dark brown spots on lower leaves",
"Concentric rings in lesions",
"Yellowing of older leaves",
],
treatment: [
"Remove affected leaves",
"Apply copper fungicide",
"Improve air circulation",
],
lookalikeDiseaseIds: ["late-blight"],
};
const mockLookalike: Disease = {
id: "late-blight",
plantId: "tomato",
name: "Late Blight",
scientificName: "Phytophthora infestans",
causalAgent: "Phytophthora infestans",
causalAgentType: "fungal",
description: "Late blight is a devastating oomycete disease.",
severity: "high",
symptoms: [
"Large irregular dark green to black water-soaked lesions on leaves",
"Yellowing of leaves surrounding infected spots",
"Rapid browning and death of entire leaves and stems",
"Dark brown spots on lower leaves",
"Water-soaked lesions",
"White fungal growth on undersides",
],
treatment: [
"Remove and destroy infected plants",
"Apply systemic fungicide",
"Crop rotation",
],
causes: ["Cool temperatures with prolonged leaf wetness"],
treatment: ["Remove and destroy infected material", "Apply mancozeb fungicide"],
prevention: ["Plant resistant varieties"],
lookalikeDiseaseIds: ["early-blight"],
severity: "critical",
};
function renderWarning(disease: Disease, lookalikes: Disease[]) {
return render(<LookalikeWarning disease={disease} lookalikes={lookalikes} />);
}
describe("renders nothing when no lookalikes", () => {
it("returns null for empty lookalikes array", () => {
const { container } = renderWarning(mockDisease, []);
expect(container.innerHTML).toBe("");
});
it("renders nothing when lookalikes array is empty", () => {
const { container } = render(
<LookalikeWarning disease={mockDisease} lookalikes={[]} />
);
expect(container.firstChild).toBeNull();
});
describe("banner header", () => {
it("shows warning message with lookalike name", () => {
renderWarning(mockDisease, [mockLookalike]);
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
expect(screen.getByText("Late Blight")).toBeInTheDocument();
});
it("shows warning icon", () => {
renderWarning(mockDisease, [mockLookalike]);
// Warning icon is an SVG with specific path
const svg = document.querySelector('svg[aria-hidden="true"]');
expect(svg).toBeInTheDocument();
});
it("renders warning banner with lookalike name", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
expect(screen.getByText("Late Blight")).toBeInTheDocument();
});
describe("expand/collapse", () => {
it("shows collapsed state by default", () => {
renderWarning(mockDisease, [mockLookalike]);
// Comparison table should not be visible
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
});
it("expands comparison on click", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button", { expanded: false });
fireEvent.click(button);
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
});
it("collapses comparison on second click", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button");
fireEvent.click(button); // expand
fireEvent.click(button); // collapse
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
});
it("toggles chevron direction", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button", { expanded: false });
expect(button).toHaveAttribute("aria-expanded", "false");
fireEvent.click(button);
expect(button).toHaveAttribute("aria-expanded", "true");
});
it("renders plural 's' when multiple lookalikes", () => {
render(
<LookalikeWarning
disease={mockDisease}
lookalikes={[mockLookalike, mockDisease]}
/>
);
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
});
describe("comparison table", () => {
it("shows comparison table header with disease names", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.getByText("Symptom")).toBeInTheDocument();
// Disease names appear in table headers (th elements)
const headers = document.querySelectorAll("th");
const headerText = Array.from(headers).map((h) => h.textContent);
expect(headerText).toContain("Early Blight");
expect(headerText).toContain("Late Blight");
it("expands comparison table on click", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
const toggleButton = screen.getByRole("button", {
name: /easily confused with/i,
});
it("shows 'Present' for symptoms shared by both diseases", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button");
fireEvent.click(button);
// Table should not be visible initially
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
// "Yellowing of leaves surrounding infected spots" is in both
const presentSpans = screen.getAllByText("Present");
expect(presentSpans.length).toBeGreaterThan(0);
});
// Click to expand
fireEvent.click(toggleButton);
it("shows legend for present/similar indicators", () => {
renderWarning(mockDisease, [mockLookalike]);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.getByText("Present in both")).toBeInTheDocument();
expect(screen.getByText("Similar symptom")).toBeInTheDocument();
});
// Comparison table should now be visible
expect(screen.getByText("Symptom")).toBeInTheDocument();
expect(screen.getByText(/Early Blight vs\. Late Blight/i)).toBeInTheDocument();
});
describe("multiple lookalikes", () => {
it("shows all lookalike names in banner", () => {
const lookalike2: Disease = {
...mockLookalike,
id: "septoria-leaf-spot",
name: "Septoria Leaf Spot",
symptoms: ["Small circular spots with dark borders"],
};
renderWarning(mockDisease, [mockLookalike, lookalike2]);
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
it("collapses comparison table on second click", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
const toggleButton = screen.getByRole("button", {
name: /easily confused with/i,
});
it("shows comparison for each lookalike when expanded", () => {
const lookalike2: Disease = {
...mockLookalike,
id: "septoria-leaf-spot",
name: "Septoria Leaf Spot",
symptoms: ["Small circular spots with dark borders"],
};
// Expand
fireEvent.click(toggleButton);
expect(screen.getByText("Symptom")).toBeInTheDocument();
renderWarning(mockDisease, [mockLookalike, lookalike2]);
const button = screen.getByRole("button");
fireEvent.click(button);
// Collapse
fireEvent.click(toggleButton);
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
});
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
expect(screen.getByText("Early Blight vs. Septoria Leaf Spot")).toBeInTheDocument();
});
it("shows symptom comparison columns", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
fireEvent.click(screen.getByRole("button"));
expect(screen.getAllByText("Early Blight").length).toBeGreaterThan(0);
expect(screen.getAllByText("Late Blight").length).toBeGreaterThan(0);
});
it("marks shared symptoms as Present", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
fireEvent.click(screen.getByRole("button"));
// "Dark brown spots on lower leaves" is in both
const presentCount = screen.getAllByText("Present");
expect(presentCount.length).toBeGreaterThanOrEqual(2);
});
it("marks unique symptoms with dash", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
fireEvent.click(screen.getByRole("button"));
// Symptoms unique to one disease should show "—"
const dashes = screen.getAllByText("—");
expect(dashes.length).toBeGreaterThan(0);
});
it("renders legend for Present and Similar indicators", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByText(/Present in both/i)).toBeInTheDocument();
expect(screen.getByText(/Similar symptom/i)).toBeInTheDocument();
});
it("has aria-expanded attribute on toggle button", () => {
render(
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
);
const toggleButton = screen.getByRole("button");
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggleButton);
expect(toggleButton).toHaveAttribute("aria-expanded", "true");
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Navbar from "@/components/Navbar";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
usePathname: vi.fn(() => "/"),
}));
vi.mock("next/link", () => ({
default: ({ href, children, className, ...props }: any) => (
<a href={href} className={className} {...props}>
{children}
</a>
),
}));
describe("Navbar", () => {
const mockRouter = {
push: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
const { useRouter } = require("next/navigation");
useRouter.mockReturnValue(mockRouter);
});
it("renders header with app name", () => {
render(<Navbar />);
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByText("Plant Health ID")).toBeInTheDocument();
});
it("renders navigation links", () => {
render(<Navbar />);
const nav = screen.getByRole("navigation", { name: /Global/i });
expect(nav).toBeInTheDocument();
});
it("renders desktop search form", () => {
render(<Navbar />);
const searchForm = screen.getByRole("search");
expect(searchForm).toBeInTheDocument();
});
it("navigates to browse page on search submit", () => {
render(<Navbar />);
const searchForm = screen.getByRole("search");
const searchInput = searchForm.querySelector('input[type="search"]') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
fireEvent.submit(searchForm);
expect(mockRouter.push).toHaveBeenCalledWith("/browse?search=tomato");
});
it("navigates to browse on empty search", () => {
render(<Navbar />);
const searchForm = screen.getByRole("search");
fireEvent.submit(searchForm);
expect(mockRouter.push).toHaveBeenCalledWith("/browse");
});
it("renders mobile menu toggle button", () => {
render(<Navbar />);
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
expect(menuButton).toBeInTheDocument();
});
it("toggles mobile menu on button click", () => {
render(<Navbar />);
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
// Open menu
fireEvent.click(menuButton);
const mobileDialog = screen.getByRole("dialog", { name: /Mobile navigation/i });
expect(mobileDialog).toBeInTheDocument();
// Close menu
const closeButton = screen.getByRole("button", { name: /Close navigation menu/i });
fireEvent.click(closeButton);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders mobile search form when menu is open", () => {
render(<Navbar />);
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
fireEvent.click(menuButton);
// Mobile search should be in the drawer
const mobileSearch = screen.getByRole("search");
expect(mobileSearch).toBeInTheDocument();
});
it("renders plant emoji logo", () => {
render(<Navbar />);
expect(screen.getByText("🌱")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import PlantCard from "@/components/PlantCard";
import type { Plant } from "@/data/plants";
describe("PlantCard", () => {
const mockPlant: Plant = {
id: "tomato",
commonName: "Tomato",
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetables",
description: "A popular garden vegetable.",
careSummary: "Full sun, well-drained soil.",
imageEmoji: "🍅",
diseases: [
{
id: "early-blight",
name: "Early Blight",
type: "fungal",
description: "A fungal disease.",
symptoms: ["Dark spots"],
causes: ["Fungus"],
treatmentSteps: ["Remove leaves"],
preventionTips: ["Rotate crops"],
severity: "moderate",
},
{
id: "late-blight",
name: "Late Blight",
type: "fungal",
description: "A devastating disease.",
symptoms: ["Water-soaked lesions"],
causes: ["Water mold"],
treatmentSteps: ["Remove plants"],
preventionTips: ["Use resistant varieties"],
severity: "high",
},
],
};
it("renders plant name", () => {
render(<PlantCard plant={mockPlant} />);
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("renders plant emoji", () => {
render(<PlantCard plant={mockPlant} />);
expect(screen.getByText("🍅")).toBeInTheDocument();
});
it("renders plant family", () => {
render(<PlantCard plant={mockPlant} />);
expect(screen.getByText("Solanaceae")).toBeInTheDocument();
});
it("renders disease count", () => {
render(<PlantCard plant={mockPlant} />);
expect(screen.getByText(/2 diseases tracked/i)).toBeInTheDocument();
});
it("renders link to plant detail page", () => {
render(<PlantCard plant={mockPlant} />);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/browse/tomato");
});
it("hides disease count when showDiseaseCount is false", () => {
render(<PlantCard plant={mockPlant} showDiseaseCount={false} />);
expect(screen.queryByText(/diseases tracked/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,350 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ResultsDashboard from "@/components/ResultsDashboard";
import type { IdentifyResponse, PredictionResult } from "@/lib/types";
// Mock dependencies
vi.mock("@/components/DiseaseCard", () => ({
default: ({ rank, isPrimary, onDismiss, prediction }: any) => (
<div data-testid={`disease-card-${rank}`} data-primary={isPrimary}>
<span>{prediction.disease.name}</span>
<span data-testid={`confidence-${rank}`}>{prediction.confidence.adjusted.toFixed(2)}</span>
<button onClick={onDismiss}>Dismiss</button>
</div>
),
}));
vi.mock("@/components/LoadingSkeleton", () => ({
default: () => <div data-testid="loading-skeleton">Loading...</div>,
ResultsSkeleton: () => <div data-testid="results-skeleton">Results Loading...</div>,
}));
vi.mock("@/components/EmptyState", () => ({
default: ({ title, description, actionLabel, actionHref }: any) => (
<div data-testid="empty-state">
<span data-testid="empty-title">{title}</span>
<span data-testid="empty-desc">{description}</span>
{actionLabel && <a href={actionHref}>{actionLabel}</a>}
</div>
),
}));
vi.mock("@/lib/api/diseases", () => ({
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
getDiseaseById: vi.fn(),
getLookalikeDiseases: vi.fn(() => []),
}));
describe("ResultsDashboard", () => {
const mockPrediction: PredictionResult = {
diseaseId: "early-blight",
disease: {
id: "early-blight",
name: "Early Blight",
causalAgent: "Alternaria solani",
causalAgentType: "fungal",
severity: "moderate",
symptoms: ["Dark spots"],
treatment: ["Remove leaves"],
lookalikeDiseaseIds: [],
plantId: "tomato",
},
confidence: { raw: 0.85, adjusted: 0.82 },
lookalikes: [],
};
const mockResponse: IdentifyResponse = {
predictions: [mockPrediction],
metadata: {
model: "mock-model",
inferenceTimeMs: 150,
imageId: "test-image-123",
},
};
it("renders loading skeleton when loading is true", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={null}
loading={true}
error={null}
/>
);
expect(screen.getByTestId("results-skeleton")).toBeInTheDocument();
});
it("renders empty state with error when error is provided", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={null}
loading={false}
error="Something went wrong"
/>
);
expect(screen.getByTestId("empty-title")).toHaveTextContent("Identification Failed");
expect(screen.getByTestId("empty-desc")).toHaveTextContent("Something went wrong");
expect(screen.getByText("Try again")).toBeInTheDocument();
});
it("renders empty state when no response and no predictions", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={null}
loading={false}
error={null}
/>
);
expect(screen.getByTestId("empty-title")).toHaveTextContent("No Results Found");
});
it("renders results header with inference time and model", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={mockResponse}
loading={false}
error={null}
/>
);
expect(screen.getByText("Identification Results")).toBeInTheDocument();
expect(screen.getByText(/150ms/i)).toBeInTheDocument();
expect(screen.getByText(/mock-model/i)).toBeInTheDocument();
});
it("renders demo mode badge when demo_mode is true", () => {
const demoResponse: IdentifyResponse = {
...mockResponse,
demo_mode: true,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={demoResponse}
loading={false}
error={null}
/>
);
expect(screen.getByText("Demo mode")).toBeInTheDocument();
});
it("renders image preview", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={mockResponse}
loading={false}
error={null}
/>
);
const img = screen.getByAltText("Uploaded plant image");
expect(img).toHaveAttribute("src", "/test.jpg");
});
it("renders image metadata section", () => {
render(
<ResultsDashboard
imageId="test-image-123"
imageUrl="/test.jpg"
response={mockResponse}
loading={false}
error={null}
/>
);
expect(screen.getByText("Image ID")).toBeInTheDocument();
expect(screen.getByText("Predictions")).toBeInTheDocument();
expect(screen.getByText("1 shown")).toBeInTheDocument();
});
it("renders disease prediction cards", () => {
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={mockResponse}
loading={false}
error={null}
/>
);
expect(screen.getByTestId("disease-card-1")).toBeInTheDocument();
expect(screen.getByText("Early Blight")).toBeInTheDocument();
});
it("sorts predictions by confidence by default", () => {
const multiResponse: IdentifyResponse = {
predictions: [
{
...mockPrediction,
diseaseId: "disease-a",
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
confidence: { raw: 0.6, adjusted: 0.58 },
},
{
...mockPrediction,
diseaseId: "disease-b",
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
confidence: { raw: 0.9, adjusted: 0.88 },
},
],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={multiResponse}
loading={false}
error={null}
/>
);
// Disease B (higher confidence) should appear first
const cards = screen.getAllByRole("button", { name: /Dismiss/i });
expect(cards.length).toBe(2);
});
it("sorts predictions by name when sort changed", () => {
const multiResponse: IdentifyResponse = {
predictions: [
{
...mockPrediction,
diseaseId: "disease-b",
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
confidence: { raw: 0.9, adjusted: 0.88 },
},
{
...mockPrediction,
diseaseId: "disease-a",
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
confidence: { raw: 0.6, adjusted: 0.58 },
},
],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={multiResponse}
loading={false}
error={null}
/>
);
// Change sort to name
const select = screen.getByLabelText(/Sort by/i);
fireEvent.change(select, { target: { value: "name" } });
// Both cards should still be present
expect(screen.getByText("Disease A")).toBeInTheDocument();
expect(screen.getByText("Disease B")).toBeInTheDocument();
});
it("dismisses a prediction when dismiss button is clicked", () => {
const multiResponse: IdentifyResponse = {
predictions: [
mockPrediction,
{
...mockPrediction,
diseaseId: "late-blight",
disease: { ...mockPrediction.disease, name: "Late Blight", plantId: "tomato" },
confidence: { raw: 0.7, adjusted: 0.68 },
},
],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={multiResponse}
loading={false}
error={null}
/>
);
// Initially 2 predictions shown
expect(screen.getByText("2 shown")).toBeInTheDocument();
// Dismiss first prediction
fireEvent.click(screen.getAllByRole("button", { name: /Dismiss/i })[0]);
// Should show 1 prediction
expect(screen.getByText("1 shown")).toBeInTheDocument();
});
it("shows restore results link after dismissing all predictions", () => {
const singleResponse: IdentifyResponse = {
predictions: [mockPrediction],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={singleResponse}
loading={false}
error={null}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
expect(screen.getByText(/Restore results/i)).toBeInTheDocument();
});
it("shows 'All results dismissed' when all predictions dismissed", () => {
const singleResponse: IdentifyResponse = {
predictions: [mockPrediction],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={singleResponse}
loading={false}
error={null}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
});
it("restores dismissed predictions when clicking restore link", () => {
const singleResponse: IdentifyResponse = {
predictions: [mockPrediction],
metadata: mockResponse.metadata,
};
render(
<ResultsDashboard
imageId="test-id"
imageUrl="/test.jpg"
response={singleResponse}
loading={false}
error={null}
/>
);
// Dismiss
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
// Verify dismissed state
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
// Restore via the link
fireEvent.click(screen.getByText(/Restore results/i));
// Should be back to showing predictions
expect(screen.getByText("1 shown")).toBeInTheDocument();
});
});

View File

@@ -1,120 +1,88 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import TreatmentTimeline, { treatmentStepsWithUrgency, type TreatmentStep } from "@/components/TreatmentTimeline";
import TreatmentTimeline, {
treatmentStepsWithUrgency,
type TreatmentStep,
type UrgencyLevel,
} from "@/components/TreatmentTimeline";
describe("TreatmentTimeline", () => {
const mockSteps: TreatmentStep[] = [
{ action: "Remove and destroy all severely infected leaves immediately", urgency: "immediate" },
{ action: "Apply copper-based fungicide spray every 7-10 days", urgency: "within-week" },
{ action: "Improve air circulation by pruning lower leaves", urgency: "ongoing" },
{ action: "Mulch around base with 2-3 inches of straw", urgency: "ongoing" },
{ action: "Switch to drip irrigation to keep foliage dry", urgency: "ongoing" },
{ action: "Remove affected leaves immediately", urgency: "immediate" },
{ action: "Apply copper fungicide within a week", urgency: "within-week" },
{ action: "Monitor plant health regularly", urgency: "ongoing" },
];
function renderTimeline(steps: TreatmentStep[]) {
return render(<TreatmentTimeline steps={steps} />);
}
describe("renders all treatment steps", () => {
it("shows all step actions", () => {
renderTimeline(mockSteps);
mockSteps.forEach((step) => {
expect(screen.getByText(step.action)).toBeInTheDocument();
});
});
it("shows numbered step indicators", () => {
renderTimeline(mockSteps);
for (let i = 1; i <= mockSteps.length; i++) {
expect(screen.getByText(i.toString())).toBeInTheDocument();
}
});
it("shows urgency badges for each step", () => {
renderTimeline(mockSteps);
expect(screen.getByText("Immediate")).toBeInTheDocument();
expect(screen.getByText("Within a week")).toBeInTheDocument();
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
});
it("renders all treatment steps", () => {
render(<TreatmentTimeline steps={mockSteps} />);
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
expect(screen.getByText("Apply copper fungicide within a week")).toBeInTheDocument();
expect(screen.getByText("Monitor plant health regularly")).toBeInTheDocument();
});
describe("urgency levels", () => {
it("renders immediate urgency with red styling", () => {
renderTimeline(mockSteps);
const immediateBadge = screen.getByText("Immediate");
expect(immediateBadge.closest("span")).toHaveClass("bg-red-100");
});
it("renders within-week urgency with amber styling", () => {
renderTimeline(mockSteps);
const weekBadge = screen.getByText("Within a week");
expect(weekBadge.closest("span")).toHaveClass("bg-warning-amber-100");
});
it("renders ongoing urgency with green styling", () => {
renderTimeline(mockSteps);
const ongoingBadges = screen.getAllByText("Ongoing");
expect(ongoingBadges.length).toBeGreaterThan(0);
expect(ongoingBadges[0].closest("span")).toHaveClass("bg-leaf-green-100");
});
it("renders numbered step indicators", () => {
render(<TreatmentTimeline steps={mockSteps} />);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
describe("disclaimer", () => {
it("shows treatment disclaimer at bottom", () => {
renderTimeline(mockSteps);
expect(screen.getByText(/Treatments may vary depending on plant species/)).toBeInTheDocument();
});
it("renders urgency badges", () => {
render(<TreatmentTimeline steps={mockSteps} />);
expect(screen.getByText("Immediate")).toBeInTheDocument();
expect(screen.getByText("Within a week")).toBeInTheDocument();
expect(screen.getByText("Ongoing")).toBeInTheDocument();
});
describe("empty state", () => {
it("shows message when no steps provided", () => {
renderTimeline([]);
expect(screen.getByText("No treatment steps available.")).toBeInTheDocument();
});
it("renders disclaimer at bottom", () => {
render(<TreatmentTimeline steps={mockSteps} />);
expect(screen.getByText(/Treatments may vary/i)).toBeInTheDocument();
expect(screen.getByText(/consult a certified plant pathologist/i)).toBeInTheDocument();
});
describe("timeline connectors", () => {
it("shows connector lines between steps", () => {
renderTimeline(mockSteps);
// There should be N-1 connector lines
const connectors = document.querySelectorAll('.bg-zinc-200.dark\\:bg-zinc-700');
// At least some connectors should exist for multi-step timelines
expect(connectors.length).toBeGreaterThan(0);
});
it("renders empty state when no steps provided", () => {
render(<TreatmentTimeline steps={[]} />);
expect(screen.getByText(/No treatment steps available/i)).toBeInTheDocument();
});
describe("treatmentStepsWithUrgency helper", () => {
it("maps first step to immediate", () => {
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
expect(steps[0].urgency).toBe("immediate");
});
it("renders single step without connector line", () => {
render(<TreatmentTimeline steps={[mockSteps[0]]} />);
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
});
it("maps second step to within-week", () => {
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
expect(steps[1].urgency).toBe("within-week");
});
it("maps remaining steps to ongoing", () => {
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3", "Step 4"]);
expect(steps[2].urgency).toBe("ongoing");
expect(steps[3].urgency).toBe("ongoing");
});
it("preserves action text", () => {
const actions = ["Remove leaves", "Apply fungicide", "Improve circulation"];
const steps = treatmentStepsWithUrgency(actions);
expect(steps.map((s) => s.action)).toEqual(actions);
});
it("handles single step", () => {
const steps = treatmentStepsWithUrgency(["Only step"]);
expect(steps).toHaveLength(1);
expect(steps[0].urgency).toBe("immediate");
});
it("handles empty array", () => {
const steps = treatmentStepsWithUrgency([]);
expect(steps).toHaveLength(0);
});
it("uses list role for timeline", () => {
render(<TreatmentTimeline steps={mockSteps} />);
const list = screen.getByRole("list");
expect(list).toBeInTheDocument();
});
});
describe("treatmentStepsWithUrgency", () => {
it("maps first step to immediate urgency", () => {
const steps = treatmentStepsWithUrgency(["First action"]);
expect(steps[0].urgency).toBe("immediate");
expect(steps[0].action).toBe("First action");
});
it("maps second step to within-week urgency", () => {
const steps = treatmentStepsWithUrgency(["First", "Second"]);
expect(steps[1].urgency).toBe("within-week");
});
it("maps remaining steps to ongoing urgency", () => {
const steps = treatmentStepsWithUrgency(["First", "Second", "Third", "Fourth"]);
expect(steps[2].urgency).toBe("ongoing");
expect(steps[3].urgency).toBe("ongoing");
});
it("returns empty array for empty input", () => {
const steps = treatmentStepsWithUrgency([]);
expect(steps).toEqual([]);
});
it("preserves action text", () => {
const actions = ["Action A", "Action B", "Action C"];
const steps = treatmentStepsWithUrgency(actions);
expect(steps.map((s) => s.action)).toEqual(actions);
});
});

View File

@@ -0,0 +1,169 @@
import { describe, it, expect } from "vitest";
import {
plants,
getPlantById,
getPlantsByCategory,
getFeaturedPlants,
getAllDiseaseTypes,
searchPlants,
} from "./plants";
describe("plants data", () => {
it("exports a non-empty array of plants", () => {
expect(Array.isArray(plants)).toBe(true);
expect(plants.length).toBeGreaterThan(0);
});
it("each plant has required fields", () => {
for (const plant of plants) {
expect(plant).toHaveProperty("id");
expect(plant).toHaveProperty("commonName");
expect(plant).toHaveProperty("scientificName");
expect(plant).toHaveProperty("family");
expect(plant).toHaveProperty("category");
expect(plant).toHaveProperty("description");
expect(plant).toHaveProperty("careSummary");
expect(plant).toHaveProperty("imageEmoji");
expect(plant).toHaveProperty("diseases");
expect(Array.isArray(plant.diseases)).toBe(true);
}
});
it("each plant has at least one disease", () => {
for (const plant of plants) {
expect(plant.diseases.length).toBeGreaterThan(0);
}
});
it("each disease has required fields", () => {
for (const plant of plants) {
for (const disease of plant.diseases) {
expect(disease).toHaveProperty("id");
expect(disease).toHaveProperty("name");
expect(disease).toHaveProperty("type");
expect(disease).toHaveProperty("description");
expect(disease).toHaveProperty("symptoms");
expect(disease).toHaveProperty("causes");
expect(disease).toHaveProperty("treatmentSteps");
expect(disease).toHaveProperty("preventionTips");
expect(disease).toHaveProperty("severity");
expect(Array.isArray(disease.symptoms)).toBe(true);
expect(Array.isArray(disease.treatmentSteps)).toBe(true);
}
}
});
});
describe("getPlantById", () => {
it("returns plant when id exists", () => {
const plant = getPlantById("tomato");
expect(plant).toBeDefined();
expect(plant!.commonName).toBe("Tomato");
});
it("returns undefined when id does not exist", () => {
const plant = getPlantById("nonexistent");
expect(plant).toBeUndefined();
});
it("is case sensitive", () => {
const plant = getPlantById("Tomato");
expect(plant).toBeUndefined();
});
});
describe("getPlantsByCategory", () => {
it("returns plants in the vegetables category", () => {
const veggies = getPlantsByCategory("vegetables");
expect(veggies.length).toBeGreaterThan(0);
expect(veggies.every((p) => p.category === "vegetables")).toBe(true);
});
it("returns plants in the herbs category", () => {
const herbs = getPlantsByCategory("herbs");
expect(herbs.length).toBeGreaterThan(0);
expect(herbs.every((p) => p.category === "herbs")).toBe(true);
});
it("returns plants in the flowers category", () => {
const flowers = getPlantsByCategory("flowers");
expect(flowers.length).toBeGreaterThan(0);
expect(flowers.every((p) => p.category === "flowers")).toBe(true);
});
it("returns plants in the houseplants category", () => {
const houseplants = getPlantsByCategory("houseplants");
expect(houseplants.length).toBeGreaterThan(0);
expect(houseplants.every((p) => p.category === "houseplants")).toBe(true);
});
});
describe("getFeaturedPlants", () => {
it("returns a subset of plants", () => {
const featured = getFeaturedPlants();
expect(featured.length).toBeGreaterThan(0);
expect(featured.length).toBeLessThanOrEqual(plants.length);
});
it("returns expected featured plants", () => {
const featured = getFeaturedPlants();
const ids = featured.map((p) => p.id);
expect(ids).toContain("tomato");
expect(ids).toContain("basil");
expect(ids).toContain("rose");
expect(ids).toContain("monstera");
});
});
describe("getAllDiseaseTypes", () => {
it("returns unique disease types", () => {
const types = getAllDiseaseTypes();
expect(types.length).toBe(new Set(types).size);
});
it("includes expected disease types", () => {
const types = getAllDiseaseTypes();
expect(types).toContain("fungal");
expect(types).toContain("bacterial");
expect(types).toContain("physiological");
});
});
describe("searchPlants", () => {
it("returns all plants for empty query", () => {
const results = searchPlants("");
expect(results).toEqual(plants);
});
it("returns all plants for whitespace query", () => {
const results = searchPlants(" ");
expect(results).toEqual(plants);
});
it("finds plants by common name", () => {
const results = searchPlants("tomato");
expect(results.length).toBeGreaterThan(0);
expect(results[0].commonName).toBe("Tomato");
});
it("finds plants by scientific name", () => {
const results = searchPlants("solanum");
expect(results.length).toBeGreaterThan(0);
});
it("finds plants by disease name", () => {
const results = searchPlants("root rot");
expect(results.length).toBeGreaterThan(0);
});
it("is case insensitive", () => {
const lower = searchPlants("tomato");
const upper = searchPlants("TOMATO");
expect(lower.length).toBe(upper.length);
});
it("returns empty array for no matches", () => {
const results = searchPlants("xyznonexistent123");
expect(results).toEqual([]);
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { identifyPlant } from "./identify";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("identifyPlant", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("identifies plant and returns predictions", async () => {
const mockResponse = {
predictions: [
{
diseaseId: "early-blight",
disease: {
id: "early-blight",
name: "Early Blight",
causalAgent: "Alternaria solani",
causalAgentType: "fungal",
severity: "moderate",
symptoms: ["Dark spots"],
treatment: ["Remove leaves"],
lookalikeDiseaseIds: [],
plantId: "tomato",
},
confidence: { raw: 0.85, adjusted: 0.82 },
lookalikes: [],
},
],
metadata: {
model: "mock-model",
inferenceTimeMs: 150,
imageId: "test-image-123",
},
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const result = await identifyPlant("test-image-123");
expect(result).toEqual(mockResponse);
});
it("calls fetch with correct URL and method", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ predictions: [], metadata: {} }),
});
await identifyPlant("test-id");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/identify"),
expect.objectContaining({
method: "POST",
})
);
});
it("sends imageId in request body", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ predictions: [], metadata: {} }),
});
await identifyPlant("test-id");
const callArgs = mockFetch.mock.calls[0][1];
const body = JSON.parse(callArgs.body);
expect(body.imageId).toBe("test-id");
});
it("throws error when response is not ok", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
await expect(identifyPlant("test-id")).rejects.toThrow();
});
it("throws error when fetch fails", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(identifyPlant("test-id")).rejects.toThrow("Network error");
});
it("handles demo mode response", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
predictions: [],
metadata: {},
demo_mode: true,
}),
});
const result = await identifyPlant("test-id");
expect(result.demo_mode).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { uploadImage } from "./upload";
// Mock dependencies
vi.mock("@/lib/image-processing", () => ({
validateImageFile: vi.fn(() => ({ ok: true })),
validateImageDimensions: vi.fn(() => Promise.resolve({ ok: true })),
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("uploadImage", () => {
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
beforeEach(() => {
vi.clearAllMocks();
});
it("uploads image and returns response", async () => {
const mockResponse = {
imageId: "test-id-123",
tensorShape: [3, 224, 224],
previewUrl: "/uploads/test-id-123.png",
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const result = await uploadImage(mockFile);
expect(result).toEqual(mockResponse);
});
it("calls fetch with correct URL", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
});
await uploadImage(mockFile);
expect(mockFetch).toHaveBeenCalledWith(
"/api/upload",
expect.objectContaining({
method: "POST",
})
);
});
it("sends FormData with image field", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
});
await uploadImage(mockFile);
const callArgs = mockFetch.mock.calls[0][1];
expect(callArgs.body).toBeInstanceOf(FormData);
});
it("throws error when response is not ok", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 413,
json: async () => ({ error: "File too large", message: "File exceeds 10MB limit" }),
});
await expect(uploadImage(mockFile)).rejects.toThrow();
});
it("throws error when fetch fails", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(uploadImage(mockFile)).rejects.toThrow("Network error");
});
it("throws error when file validation fails", async () => {
const { validateImageFile } = require("@/lib/image-processing");
validateImageFile.mockReturnValue({ ok: false, error: "Invalid file type" });
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Invalid file type");
});
it("throws error when dimension validation fails", async () => {
const { validateImageDimensions } = require("@/lib/image-processing");
validateImageDimensions.mockResolvedValue({ ok: false, error: "Image too small" });
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Image too small");
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import {
APP_NAME,
NAV_LINKS,
PLANT_CATEGORIES,
TRUST_SIGNALS,
HOW_IT_WORKS,
BETA_DISCLAIMER,
SOCIAL_LINKS,
FEATURED_PLANT_IDS,
APP_TAGLINE,
APP_DESCRIPTION,
} from "./constants";
describe("constants", () => {
describe("APP_NAME", () => {
it("is a non-empty string", () => {
expect(typeof APP_NAME).toBe("string");
expect(APP_NAME.length).toBeGreaterThan(0);
});
it("equals expected name", () => {
expect(APP_NAME).toBe("Plant Health ID");
});
});
describe("APP_TAGLINE", () => {
it("is a non-empty string", () => {
expect(typeof APP_TAGLINE).toBe("string");
expect(APP_TAGLINE.length).toBeGreaterThan(0);
});
});
describe("APP_DESCRIPTION", () => {
it("is a non-empty string", () => {
expect(typeof APP_DESCRIPTION).toBe("string");
expect(APP_DESCRIPTION.length).toBeGreaterThan(0);
});
});
describe("SOCIAL_LINKS", () => {
it("has github link", () => {
expect(SOCIAL_LINKS.github).toMatch(/github/);
});
it("has twitter link", () => {
expect(SOCIAL_LINKS.twitter).toMatch(/twitter/);
});
});
describe("NAV_LINKS", () => {
it("is an array of navigation links", () => {
expect(Array.isArray(NAV_LINKS)).toBe(true);
expect(NAV_LINKS.length).toBeGreaterThan(0);
});
it("each link has label and href", () => {
for (const link of NAV_LINKS) {
expect(link).toHaveProperty("label");
expect(link).toHaveProperty("href");
expect(typeof link.label).toBe("string");
expect(typeof link.href).toBe("string");
expect(link.href.startsWith("/")).toBe(true);
}
});
it("includes expected routes", () => {
const hrefs = NAV_LINKS.map((l) => l.href);
expect(hrefs).toContain("/");
expect(hrefs).toContain("/browse");
});
});
describe("PLANT_CATEGORIES", () => {
it("is an array of categories", () => {
expect(Array.isArray(PLANT_CATEGORIES)).toBe(true);
expect(PLANT_CATEGORIES.length).toBeGreaterThan(0);
});
it("each category has label and value", () => {
for (const cat of PLANT_CATEGORIES) {
expect(cat).toHaveProperty("label");
expect(cat).toHaveProperty("value");
expect(typeof cat.label).toBe("string");
expect(typeof cat.value).toBe("string");
}
});
it("includes all option", () => {
const values = PLANT_CATEGORIES.map((c) => c.value);
expect(values).toContain("all");
});
it("includes common plant categories", () => {
const values = PLANT_CATEGORIES.map((c) => c.value);
expect(values).toContain("vegetables");
expect(values).toContain("flowers");
expect(values).toContain("herbs");
expect(values).toContain("houseplants");
});
});
describe("FEATURED_PLANT_IDS", () => {
it("is an array of plant IDs", () => {
expect(Array.isArray(FEATURED_PLANT_IDS)).toBe(true);
expect(FEATURED_PLANT_IDS.length).toBeGreaterThan(0);
});
it("includes expected featured plants", () => {
expect(FEATURED_PLANT_IDS).toContain("tomato");
expect(FEATURED_PLANT_IDS).toContain("basil");
});
});
describe("TRUST_SIGNALS", () => {
it("is an array of trust signals", () => {
expect(Array.isArray(TRUST_SIGNALS)).toBe(true);
expect(TRUST_SIGNALS.length).toBeGreaterThan(0);
});
it("each signal has icon and label", () => {
for (const signal of TRUST_SIGNALS) {
expect(signal).toHaveProperty("icon");
expect(signal).toHaveProperty("label");
}
});
});
describe("HOW_IT_WORKS", () => {
it("is an array of steps", () => {
expect(Array.isArray(HOW_IT_WORKS)).toBe(true);
expect(HOW_IT_WORKS.length).toBeGreaterThan(0);
});
it("each step has step number, emoji, title, and description", () => {
for (const step of HOW_IT_WORKS) {
expect(step).toHaveProperty("step");
expect(step).toHaveProperty("emoji");
expect(step).toHaveProperty("title");
expect(step).toHaveProperty("description");
}
});
it("has exactly 3 steps", () => {
expect(HOW_IT_WORKS.length).toBe(3);
});
it("steps are numbered sequentially", () => {
expect(HOW_IT_WORKS[0].step).toBe(1);
expect(HOW_IT_WORKS[1].step).toBe(2);
expect(HOW_IT_WORKS[2].step).toBe(3);
});
});
describe("BETA_DISCLAIMER", () => {
it("is a non-empty string", () => {
expect(typeof BETA_DISCLAIMER).toBe("string");
expect(BETA_DISCLAIMER.length).toBeGreaterThan(0);
});
it("mentions AI-assisted tool", () => {
expect(BETA_DISCLAIMER.toLowerCase()).toContain("ai-assisted");
});
it("mentions professional advice disclaimer", () => {
expect(BETA_DISCLAIMER.toLowerCase()).toMatch(/not a substitute|professional/i);
});
it("mentions plant pathologist", () => {
expect(BETA_DISCLAIMER.toLowerCase()).toContain("plant pathologist");
});
});
});

371
apps/web/src/lib/db.ts Normal file
View File

@@ -0,0 +1,371 @@
/**
* Turso/libSQL Database Client
*
* Provides the database client and schema management for the plant disease
* knowledge base. Connect to Turso/libSQL using environment variables.
*
* Required env vars:
* DATABASE_URL — Turso database URL (e.g., libsql://my-db.turso.io)
* DATABASE_TOKEN — Turso authentication token
*/
import { createClient, type InValue } from "@libsql/client";
import type { Plant, Disease, CausalAgentType, Severity } from "./types";
// ─── Client ──────────────────────────────────────────────────────────────────
let client: ReturnType<typeof createClient> | null = null;
let connected = false;
/** Get or create a singleton database client */
export function getDb() {
if (client) return client;
const url = process.env.DATABASE_URL;
const token = process.env.DATABASE_TOKEN;
if (!url) {
throw new Error(
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
);
}
if (!token) {
throw new Error(
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
);
}
client = createClient({ url, authToken: token });
return client;
}
/** Check database connectivity */
export async function checkConnection(): Promise<boolean> {
try {
const db = getDb();
await db.execute("SELECT 1 AS ok");
connected = true;
return true;
} catch (err) {
connected = false;
console.error("[DB] Connection failed:", err);
return false;
}
}
export function isConnected() {
return connected;
}
// ─── Schema ───────────────────────────────────────────────────────────────────
/** SQL to create the plants table */
const PLANTS_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS plants (
id TEXT PRIMARY KEY,
common_name TEXT NOT NULL,
scientific_name TEXT NOT NULL,
family TEXT NOT NULL,
category TEXT NOT NULL,
care_summary TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plants_category ON plants(category);
CREATE INDEX IF NOT EXISTS idx_plants_common_name ON plants(common_name);
`;
/** SQL to create the diseases table */
const DISEASES_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS diseases (
id TEXT PRIMARY KEY,
plant_id TEXT NOT NULL REFERENCES plants(id),
name TEXT NOT NULL,
scientific_name TEXT NOT NULL DEFAULT '',
causal_agent_type TEXT NOT NULL CHECK (causal_agent_type IN ('fungal','bacterial','viral','environmental')),
description TEXT NOT NULL DEFAULT '',
symptoms TEXT NOT NULL DEFAULT '[]',
causes TEXT NOT NULL DEFAULT '[]',
treatment TEXT NOT NULL DEFAULT '[]',
prevention TEXT NOT NULL DEFAULT '[]',
lookalike_ids TEXT NOT NULL DEFAULT '[]',
severity TEXT NOT NULL CHECK (severity IN ('low','moderate','high','critical')),
source_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_diseases_plant_id ON diseases(plant_id);
CREATE INDEX IF NOT EXISTS idx_diseases_causal_agent ON diseases(causal_agent_type);
CREATE INDEX IF NOT EXISTS idx_diseases_severity ON diseases(severity);
-- Full-text search virtual table for diseases
CREATE VIRTUAL TABLE IF NOT EXISTS diseases_fts USING fts5(
name,
scientific_name,
description,
symptoms_text,
content='diseases',
content_rowid='rowid'
);
`;
/** SQL to create the scrape_log table for tracking source freshness */
const SCRAPE_LOG_SQL = `
CREATE TABLE IF NOT EXISTS scrape_sources (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL CHECK (source_type IN ('wikipedia','university_extension','cabi','other')),
source_url TEXT NOT NULL,
last_scraped_at TEXT,
entries_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','success','error')),
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`;
/** Run all schema migrations */
export async function runSchema() {
const db = getDb();
console.log("[DB] Running schema migrations...");
await db.execute(PLANTS_TABLE_SQL);
console.log("[DB] ✓ plants table");
await db.execute(DISEASES_TABLE_SQL);
console.log("[DB] ✓ diseases table");
await db.execute(SCRAPE_LOG_SQL);
console.log("[DB] ✓ scrape_sources table");
console.log("[DB] Schema up to date.");
}
// ─── Row ↔ Type mappers ─────────────────────────────────────────────────────
/** Convert a database row to a Plant object */
export function rowToPlant(row: Record<string, unknown>): Plant {
return {
id: row.id as string,
commonName: row.common_name as string,
scientificName: row.scientific_name as string,
family: row.family as string,
category: row.category as Plant["category"],
careSummary: row.care_summary as string,
imageUrl: row.image_url as string,
};
}
/** Convert a database row to a Disease object */
export function rowToDisease(row: Record<string, unknown>): Disease {
return {
id: row.id as string,
plantId: row.plant_id as string,
name: row.name as string,
scientificName: row.scientific_name as string,
causalAgentType: row.causal_agent_type as CausalAgentType,
description: row.description as string,
symptoms: JSON.parse(row.symptoms as string) as string[],
causes: JSON.parse(row.causes as string) as string[],
treatment: JSON.parse(row.treatment as string) as string[],
prevention: JSON.parse(row.prevention as string) as string[],
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
severity: row.severity as Severity,
};
}
/** Convert a Plant object to database column values */
export function plantToRow(plant: Plant): Record<string, InValue> {
return {
id: plant.id,
common_name: plant.commonName,
scientific_name: plant.scientificName,
family: plant.family,
category: plant.category,
care_summary: plant.careSummary,
image_url: plant.imageUrl,
};
}
/** Convert a Disease object to database column values */
export function diseaseToRow(disease: Disease & { sourceUrl?: string }): Record<string, InValue> {
return {
id: disease.id,
plant_id: disease.plantId,
name: disease.name,
scientific_name: disease.scientificName,
causal_agent_type: disease.causalAgentType,
description: disease.description,
symptoms: JSON.stringify(disease.symptoms),
causes: JSON.stringify(disease.causes),
treatment: JSON.stringify(disease.treatment),
prevention: JSON.stringify(disease.prevention),
lookalike_ids: JSON.stringify(disease.lookalikeDiseaseIds),
severity: disease.severity,
source_url: disease.sourceUrl ?? "",
};
}
// ─── Query helpers ───────────────────────────────────────────────────────────
/** Insert or replace a plant */
export async function upsertPlant(plant: Plant) {
const db = getDb();
const row = plantToRow(plant);
const keys = Object.keys(row);
const columns = keys.join(", ");
const placeholders = keys.map(() => "?").join(", ");
const values = keys.map((k) => row[k]);
await db.execute(`INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`, values);
}
/** Insert or replace a disease */
export async function upsertDisease(disease: Disease & { sourceUrl?: string }) {
const db = getDb();
const row = diseaseToRow(disease);
const keys = Object.keys(row);
const columns = keys.join(", ");
const placeholders = keys.map(() => "?").join(", ");
const values = keys.map((k) => row[k]);
await db.execute(`INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`, values);
}
/** Bulk insert plants in a transaction */
export async function bulkUpsertPlants(plants: Plant[]) {
const db = getDb();
const tx = await db.transaction("write");
try {
for (const plant of plants) {
const row = plantToRow(plant);
const keys = Object.keys(row);
const columns = keys.join(", ");
const placeholders = keys.map(() => "?").join(", ");
const values = keys.map((k) => row[k]);
await tx.execute({
sql: `INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`,
args: values,
});
}
await tx.commit();
console.log(`[DB] Inserted/replaced ${plants.length} plants`);
} catch (err) {
await tx.rollback();
throw err;
}
}
/** Bulk insert diseases in a transaction */
export async function bulkUpsertDiseases(diseases: Array<Disease & { sourceUrl?: string }>) {
const db = getDb();
const tx = await db.transaction("write");
try {
for (const disease of diseases) {
const row = diseaseToRow(disease);
const keys = Object.keys(row);
const columns = keys.join(", ");
const placeholders = keys.map(() => "?").join(", ");
const values = keys.map((k) => row[k]);
await tx.execute({
sql: `INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`,
args: values,
});
}
await tx.commit();
console.log(`[DB] Inserted/replaced ${diseases.length} diseases`);
} catch (err) {
await tx.rollback();
throw err;
}
}
/** Get all plants */
export async function getAllPlants(): Promise<Plant[]> {
const db = getDb();
const result = await db.execute("SELECT * FROM plants ORDER BY common_name");
return result.rows.map((r) => rowToPlant(r as Record<string, unknown>));
}
/** Get all diseases (optionally filtered by plant_id) */
export async function getDiseases(plantId?: string): Promise<Disease[]> {
const db = getDb();
let sql = "SELECT * FROM diseases";
const params: InValue[] = [];
if (plantId) {
sql += " WHERE plant_id = ?";
params.push(plantId);
}
sql += " ORDER BY name";
const result = await db.execute(sql, params);
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
}
/** Get a single plant by ID */
export async function getPlantById(plantId: string): Promise<Plant | null> {
const db = getDb();
const result = await db.execute("SELECT * FROM plants WHERE id = ?", [plantId]);
if (result.rows.length === 0) return null;
return rowToPlant(result.rows[0] as Record<string, unknown>);
}
/** Get a single disease by ID */
export async function getDiseaseById(diseaseId: string): Promise<Disease | null> {
const db = getDb();
const result = await db.execute("SELECT * FROM diseases WHERE id = ?", [diseaseId]);
if (result.rows.length === 0) return null;
return rowToDisease(result.rows[0] as Record<string, unknown>);
}
/** Search diseases via FTS */
export async function searchDiseasesFts(searchTerm: string): Promise<Disease[]> {
const db = getDb();
try {
const result = await db.execute(
`SELECT d.* FROM diseases d
JOIN diseases_fts fts ON d.rowid = fts.rowid
WHERE diseases_fts MATCH ?
ORDER BY rank
LIMIT 50`,
[searchTerm],
);
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
} catch {
// FTS might not be populated yet; fall back to LIKE search
const likeTerm = `%${searchTerm}%`;
const result = await db.execute(
`SELECT * FROM diseases
WHERE name LIKE ? OR description LIKE ? OR scientific_name LIKE ?
LIMIT 50`,
[likeTerm, likeTerm, likeTerm],
);
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
}
}
/** Get database stats */
export async function getDbStats(): Promise<{
plants: number;
diseases: number;
byType: Record<string, number>;
bySeverity: Record<string, number>;
}> {
const db = getDb();
const plantCount = await db.execute("SELECT COUNT(*) as cnt FROM plants");
const diseaseCount = await db.execute("SELECT COUNT(*) as cnt FROM diseases");
const byType = await db.execute(
"SELECT causal_agent_type, COUNT(*) as cnt FROM diseases GROUP BY causal_agent_type",
);
const bySeverity = await db.execute(
"SELECT severity, COUNT(*) as cnt FROM diseases GROUP BY severity",
);
return {
plants: plantCount.rows[0].cnt as number,
diseases: diseaseCount.rows[0].cnt as number,
byType: Object.fromEntries(byType.rows.map((r) => [r.causal_agent_type, r.cnt as number])),
bySeverity: Object.fromEntries(bySeverity.rows.map((r) => [r.severity, r.cnt as number])),
};
}

View File

@@ -0,0 +1,62 @@
/**
* Drizzle ORM Database Client for Turso/libSQL.
*
* Provides the configured drizzle instance and convenience helpers.
* Reads DATABASE_URL and DATABASE_TOKEN from environment.
*/
import { sql } from "drizzle-orm";
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
export type { PlantRow, PlantInsert, DiseaseRow, DiseaseInsert } from "./schema";
export { schema };
let _db: LibSQLDatabase<typeof schema> | null = null;
let _client: ReturnType<typeof createClient> | null = null;
/** Get or create the Drizzle database instance (singleton). */
export function getDb(): LibSQLDatabase<typeof schema> {
if (_db) return _db;
const url = process.env.DATABASE_URL;
const token = process.env.DATABASE_TOKEN;
if (!url) {
throw new Error(
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
);
}
if (!token) {
throw new Error(
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
);
}
_client = createClient({ url, authToken: token });
_db = drizzle(_client, { schema });
return _db;
}
/** Check database connectivity. */
export async function checkConnection(): Promise<boolean> {
try {
const db = getDb();
const result = await db.run(sql`SELECT 1 AS ok`);
return result.rowsAffected >= 0;
} catch (err) {
console.error("[DB] Connection failed:", err);
return false;
}
}
/** Close the client connection. */
export function closeDb() {
if (_client) {
_client.close();
_client = null;
_db = null;
}
}

View File

@@ -0,0 +1,104 @@
/**
* Drizzle ORM Schema for the Plant Disease Knowledge Base.
*
* Uses Turso (libSQL) with SQLite dialect.
* Arrays (symptoms, causes, treatment, prevention, lookalike_ids)
* are stored as JSON text columns and typed via Drizzle's $type().
*/
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
// ─── Plants Table ────────────────────────────────────────────────────────────
export const plants = sqliteTable(
"plants",
{
id: text("id").primaryKey(),
commonName: text("common_name").notNull(),
scientificName: text("scientific_name").notNull(),
family: text("family").notNull(),
category: text("category").notNull(),
careSummary: text("care_summary").notNull().default(""),
imageUrl: text("image_url").notNull().default(""),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
},
(table) => ({
categoryIdx: index("idx_plants_category").on(table.category),
commonNameIdx: index("idx_plants_common_name").on(table.commonName),
}),
);
// ─── Diseases Table ──────────────────────────────────────────────────────────
export const diseases = sqliteTable(
"diseases",
{
id: text("id").primaryKey(),
plantId: text("plant_id")
.notNull()
.references(() => plants.id),
name: text("name").notNull(),
scientificName: text("scientific_name").notNull().default(""),
causalAgentType: text("causal_agent_type", {
enum: ["fungal", "bacterial", "viral", "environmental"],
}).notNull(),
description: text("description").notNull().default(""),
symptoms: text("symptoms", { mode: "json" }).notNull().default([]).$type<string[]>(),
causes: text("causes", { mode: "json" }).notNull().default([]).$type<string[]>(),
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
prevention: text("prevention", { mode: "json" }).notNull().default([]).$type<string[]>(),
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
severity: text("severity", {
enum: ["low", "moderate", "high", "critical"],
}).notNull(),
sourceUrl: text("source_url").notNull().default(""),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
},
(table) => ({
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
severityIdx: index("idx_diseases_severity").on(table.severity),
}),
);
// ─── Scrape Sources Table ────────────────────────────────────────────────────
export const scrapeSources = sqliteTable("scrape_sources", {
id: text("id").primaryKey(),
sourceType: text("source_type", {
enum: ["wikipedia", "university_extension", "cabi", "other"],
}).notNull(),
sourceUrl: text("source_url").notNull(),
lastScrapedAt: text("last_scraped_at"),
entriesCount: integer("entries_count").default(0),
status: text("status", { enum: ["pending", "success", "error"] })
.notNull()
.default("pending"),
errorMessage: text("error_message"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
});
// ─── Relation Inference ──────────────────────────────────────────────────────
export const plantsRelations = {};
export const diseasesRelations = {};
// ─── Type helpers ────────────────────────────────────────────────────────────
export type PlantRow = typeof plants.$inferSelect;
export type PlantInsert = typeof plants.$inferInsert;
export type DiseaseRow = typeof diseases.$inferSelect;
export type DiseaseInsert = typeof diseases.$inferInsert;

View File

@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from "vitest";
import { mimeTypeToExtension } from "./image-processing-server";
// Mock sharp dynamically
const mockSharp = vi.fn(() => ({
resize: vi.fn().mockReturnThis(),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(Buffer.from("resized-image-data")),
}));
vi.doMock("sharp", () => ({
default: mockSharp,
}));
describe("mimeTypeToExtension", () => {
it("maps image/png to png", () => {
expect(mimeTypeToExtension("image/png")).toBe("png");
});
it("maps image/jpeg to jpg", () => {
expect(mimeTypeToExtension("image/jpeg")).toBe("jpg");
});
it("maps image/jpg to jpg", () => {
expect(mimeTypeToExtension("image/jpg")).toBe("jpg");
});
it("maps image/webp to webp", () => {
expect(mimeTypeToExtension("image/webp")).toBe("webp");
});
it("returns jpg for unknown mime types", () => {
expect(mimeTypeToExtension("image/bmp")).toBe("jpg");
expect(mimeTypeToExtension("unknown/type")).toBe("jpg");
});
});
describe("resizeImageServer", () => {
it("resizes image to specified dimensions", async () => {
// Re-import after mock is set up
const { resizeImageServer } = await import("./image-processing-server");
const buffer = Buffer.from("test-image-data");