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();
});
});
});