feat(tests): implement unit tests for job post and application forms and fix dashboard tests

This commit is contained in:
Tiago Yamamoto 2026-01-01 11:15:32 -03:00
parent d79fa8e97a
commit 812affb803
5 changed files with 391 additions and 11 deletions

View file

@ -12,6 +12,7 @@ const customJestConfig = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/', '<rootDir>/e2e/'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async

View file

@ -0,0 +1,149 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import JobApplicationPage from "./page";
import { jobsApi, applicationsApi } from "@/lib/api";
import { useNotify } from "@/contexts/notification-context";
import { useRouter } from "next/navigation";
// Mock dependencies
jest.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "en",
setLocale: jest.fn(),
}),
useI18n: () => ({
t: (key: string) => key,
locale: "en",
setLocale: jest.fn(),
}),
}));
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}));
jest.mock("@/lib/api", () => ({
jobsApi: {
getById: jest.fn(),
},
applicationsApi: {
create: jest.fn(),
},
}));
jest.mock("@/contexts/notification-context", () => ({
useNotify: jest.fn(),
}));
jest.mock("@/components/ui/progress", () => ({
Progress: () => <div data-testid="progress" />,
}));
describe("JobApplicationPage", () => {
const mockPush = jest.fn();
const mockNotify = { error: jest.fn(), success: jest.fn(), info: jest.fn() };
const mockJob = {
id: "job-123",
title: "Software Engineer",
companyName: "Tech Corp",
location: "Remote",
};
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({ push: mockPush });
(useNotify as jest.Mock).mockReturnValue(mockNotify);
(jobsApi.getById as jest.Mock).mockResolvedValue(mockJob);
window.scrollTo = jest.fn();
});
// Since component uses `use(params)`, we need to wrap or pass a promise
const params = Promise.resolve({ id: "job-123" });
it("renders job title and company info", async () => {
render(<JobApplicationPage params={params} />);
await waitFor(() => {
expect(screen.getByText("Application: Software Engineer")).toBeInTheDocument();
expect(screen.getByText(/Tech Corp/)).toBeInTheDocument();
});
});
it("validates step 1 fields", async () => {
render(<JobApplicationPage params={params} />);
await waitFor(() => expect(screen.getByText("Application: Software Engineer")).toBeInTheDocument());
// Try next empty
const nextBtn = screen.getByRole("button", { name: /Next step/i });
fireEvent.click(nextBtn);
expect(mockNotify.error).toHaveBeenCalledWith("Required fields", expect.any(String));
// Fill fields
fireEvent.change(screen.getByLabelText(/Full name/i), { target: { value: "John Doe" } });
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: "john@example.com" } });
fireEvent.change(screen.getByLabelText(/Phone/i), { target: { value: "11999999999" } });
// Forgot checkbox
fireEvent.click(nextBtn);
expect(mockNotify.error).toHaveBeenCalledWith("Privacy policy", expect.any(String));
// Check checkbox
// Finding checkbox by ID might be easiest or by label text click
const policyLabel = screen.getByText(/I have read and agree to the/i);
fireEvent.click(policyLabel); // Should toggle checkbox associated with label
fireEvent.click(nextBtn);
// Should move to step 2
expect(screen.getByText("Resume & Documents")).toBeInTheDocument();
});
it("submits application successfully", async () => {
render(<JobApplicationPage params={params} />);
await waitFor(() => expect(jobsApi.getById).toHaveBeenCalled());
// Step 1
fireEvent.change(screen.getByLabelText(/Full name/i), { target: { value: "John Doe" } });
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: "john@example.com" } });
fireEvent.change(screen.getByLabelText(/Phone/i), { target: { value: "11999999999" } });
fireEvent.click(screen.getByText(/I have read and agree to the/i));
fireEvent.click(screen.getByRole("button", { name: /Next step/i }));
// Step 2
// Optional fields here or skip
// Resume is technically required (Step 2 logic: case 2 returns true currently in the simplified code?
// Looking at source: case 2: return true; (lines 122). So no validation on Step 2!)
fireEvent.click(screen.getByRole("button", { name: /Next step/i }));
// Step 3
// Salary and Experience
const trigger = screen.getByText("Select a range");
fireEvent.click(trigger);
fireEvent.click(screen.getByText("Up to R$ 3,000")); // SelectOption
fireEvent.click(screen.getByLabelText("Yes, I do")); // Radio
fireEvent.click(screen.getByRole("button", { name: /Next step/i }));
// Step 4
// Why Us and Availability
fireEvent.change(screen.getByLabelText(/Why do you want to work/i), { target: { value: "Because I like it." } });
fireEvent.click(screen.getByLabelText("Immediate start"));
// Submit
fireEvent.click(screen.getByRole("button", { name: /Submit application/i }));
await waitFor(() => {
expect(applicationsApi.create).toHaveBeenCalledWith(expect.objectContaining({
name: "John Doe",
email: "john@example.com",
jobId: "job-123"
}));
expect(mockNotify.success).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/dashboard/my-applications");
});
});
});

View file

@ -0,0 +1,197 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import PostJobPage from "./page";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
// Mock dependencies
jest.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "en",
setLocale: jest.fn(),
}),
useI18n: () => ({
t: (key: string) => key,
locale: "en",
setLocale: jest.fn(),
}),
}));
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}));
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}));
jest.mock("./translations", () => ({
translations: {
pt: {
title: "Postar uma Vaga",
buttons: { next: "Next Step", publish: "Publish Job", publishing: "Publishing..." },
company: {
name: "Company Name",
namePlaceholder: "My Company",
email: "Email",
password: "Password",
confirmPassword: "Confirm Password",
phone: "Phone",
},
job: {
title: "Job Details",
jobTitle: "Job Title",
jobTitlePlaceholder: "e.g. Developer",
description: "Job Description",
location: "Location",
salary: "Salary",
},
steps: { data: "Data", confirm: "Confirm" },
cardTitle: { step1: "Step 1", step2: "Step 2" },
cardDesc: { step1: "Desc 1", step2: "Desc 2" },
common: { company: "Company", job: "Job", name: "Name", email: "Email", title: "Title" },
options: {
period: { hourly: "/hr" },
contract: { permanent: "Permanent" },
hours: { fullTime: "Full Time" },
mode: { remote: "Remote" }
}
},
},
}));
// Mock RichTextEditor component
jest.mock("@/components/rich-text-editor", () => ({
RichTextEditor: ({ value, onChange, placeholder }: any) => (
<textarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="rich-text-editor"
/>
),
}));
// Mock LocationPicker component
jest.mock("@/components/location-picker", () => ({
LocationPicker: ({ value, onChange }: any) => (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Location"
data-testid="location-picker"
/>
),
}));
describe("PostJobPage", () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({ push: mockPush });
global.fetch = jest.fn();
// Mock scroll methods not in test env
window.scrollTo = jest.fn();
});
it("renders step 1 initially", () => {
render(<PostJobPage />);
expect(screen.getByText("Postar uma Vaga")).toBeInTheDocument();
expect(screen.getByText("Step 1")).toBeInTheDocument();
expect(screen.getByPlaceholderText("My Company")).toBeInTheDocument();
});
it("validates company fields before proceeding", () => {
render(<PostJobPage />);
// Try next without filling
fireEvent.click(screen.getByText("Next Step"));
expect(toast.error).toHaveBeenCalledWith("Preencha os dados obrigatórios da empresa");
// Fill password mismatch
fireEvent.change(screen.getByPlaceholderText("My Company"), { target: { value: "Test Co" } });
fireEvent.change(screen.getByPlaceholderText("contato@empresa.com"), { target: { value: "test@co.com" } });
const passwords = screen.getAllByPlaceholderText("••••••••");
fireEvent.change(passwords[0], { target: { value: "12345678" } });
fireEvent.change(passwords[1], { target: { value: "mismatch" } });
fireEvent.click(screen.getByText("Next Step"));
expect(toast.error).toHaveBeenCalledWith("As senhas não coincidem");
});
it("proceeds to step 2 when company info is valid", () => {
render(<PostJobPage />);
fireEvent.change(screen.getByPlaceholderText("My Company"), { target: { value: "Test Co" } });
fireEvent.change(screen.getByPlaceholderText("contato@empresa.com"), { target: { value: "test@co.com" } });
const passwords = screen.getAllByPlaceholderText("••••••••");
fireEvent.change(passwords[0], { target: { value: "password123" } });
fireEvent.change(passwords[1], { target: { value: "password123" } });
// Need to fill job description as well now
fireEvent.change(screen.getByPlaceholderText("e.g. Developer"), { target: { value: "Frontend Dev" } });
const editors = screen.getAllByTestId("rich-text-editor");
if (editors.length > 1) {
fireEvent.change(editors[1], { target: { value: "Job Desc" } });
} else {
fireEvent.change(editors[0], { target: { value: "Job Desc" } });
}
fireEvent.click(screen.getByText("Next Step"));
expect(screen.getByText("Step 2")).toBeInTheDocument();
});
it("submits the form successfully", async () => {
(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: "fake-token" }),
}) // Register
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: "job-123" }),
}); // Create Job
render(<PostJobPage />);
// Step 1
fireEvent.change(screen.getByPlaceholderText("My Company"), { target: { value: "Test Co" } });
fireEvent.change(screen.getByPlaceholderText("contato@empresa.com"), { target: { value: "test@co.com" } });
const passwords = screen.getAllByPlaceholderText("••••••••");
fireEvent.change(passwords[0], { target: { value: "password123" } });
fireEvent.change(passwords[1], { target: { value: "password123" } });
// Fill Job Details (Step 1)
fireEvent.change(screen.getByPlaceholderText("e.g. Developer"), { target: { value: "Frontend Dev" } });
const editors = screen.getAllByTestId("rich-text-editor");
if (editors.length > 1) {
fireEvent.change(editors[1], { target: { value: "Job Desc" } });
} else {
fireEvent.change(editors[0], { target: { value: "Job Desc" } });
}
fireEvent.click(screen.getByText("Next Step"));
// Check we are on Step 2
expect(screen.getByText("Step 2")).toBeInTheDocument();
// Submit
fireEvent.click(screen.getByText("Publish Job"));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(toast.success).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/dashboard/jobs");
});
});
});

View file

@ -114,30 +114,52 @@ export default function PostJobPage() {
return value.replace(/[^\d\s-]/g, "");
};
const handleSubmit = async () => {
const validateForm = () => {
if (!company.name || !company.email || !company.password) {
toast.error("Preencha os dados obrigatórios da empresa");
setStep(1);
return;
setStep(1); // Ensure we are on step 1 for company data errors
return false;
}
if (company.password !== company.confirmPassword) {
toast.error("As senhas não coincidem");
setStep(1);
return;
setStep(1); // Ensure we are on step 1 for password mismatch
return false;
}
if (company.password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres");
setStep(1);
return;
setStep(1); // Ensure we are on step 1 for password length
return false;
}
if (!job.title || !job.description) {
toast.error("Preencha os dados da vaga");
setStep(2);
setStep(1); // Stay on step 1 for job data errors
return false;
}
return true;
};
const handleNext = () => {
// Only validate step 1 fields to move to step 2
if (!company.name || !company.email || !company.password) {
toast.error("Preencha os dados obrigatórios da empresa");
return;
}
if (company.password !== company.confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
if (company.password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres");
return;
}
setStep(2);
};
const handleSubmit = async () => {
if (!validateForm()) return;
setLoading(true);
try {
@ -607,7 +629,7 @@ export default function PostJobPage() {
</div>
</div>
<Button onClick={() => setStep(2)} className="w-full">
<Button onClick={handleNext} className="w-full">
{t.buttons.next}
</Button>
</div>

View file

@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react"
import CandidateDashboard from "./candidate-dashboard"
import { CandidateDashboardContent } from "./candidate-dashboard"
// Mocks
jest.mock("@/lib/auth", () => ({
@ -7,6 +7,17 @@ jest.mock("@/lib/auth", () => ({
isAuthenticated: jest.fn().mockReturnValue(true),
}))
jest.mock("@/contexts/notification-context", () => ({
useNotifications: jest.fn().mockReturnValue({ notifications: [], markAsRead: jest.fn() }),
useNotify: jest.fn().mockReturnValue(jest.fn()),
}))
jest.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock("@/lib/api", () => ({
jobsApi: {
list: jest.fn().mockResolvedValue({ data: [], pagination: {} }),
@ -40,7 +51,7 @@ jest.mock("@/components/ui/skeleton", () => ({
describe("CandidateDashboard", () => {
it("renders welcome message", async () => {
render(<CandidateDashboard />)
render(<CandidateDashboardContent />)
// Check for static text or known content
// Assuming dashboard has "Welcome" or similar
// Or check for section headers "My Applications", "Recommended Jobs"