diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 199a881..ccd5972 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -8,12 +8,32 @@ on: - 'backend/**' jobs: - deploy-backend-dev: - # Usamos 'docker' porque é a label que seu runner atual possui + # Job 1: Run Backend Tests + test-backend: runs-on: docker + container: + image: golang:1.24-alpine + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + + - name: Install dependencies + run: | + cd backend + go mod download + + - name: Run tests + run: | + cd backend + go test -v ./internal/services/... + go test -v ./internal/core/usecases/... + + # Job 2: Deploy (only if tests pass) + deploy-backend-dev: + runs-on: docker + needs: test-backend steps: - name: Executar Deploy via SSH na Apolo (Ambiente Dev) - # Ajustado para usar a URL completa do GitHub para evitar o erro 'repository not found' uses: https://github.com/appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.HOST }} diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go index f6f3308..327ed36 100644 --- a/backend/internal/services/job_service_test.go +++ b/backend/internal/services/job_service_test.go @@ -35,7 +35,7 @@ func TestCreateJob(t *testing.T) { }, mockRun: func() { mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO jobs`)). - WithArgs("1", "user-123", "Go Developer", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "published", sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs("1", "user-123", "Go Developer", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "published", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow("100", time.Now(), time.Now())) }, wantErr: false, @@ -93,16 +93,16 @@ func TestGetJobs(t *testing.T) { // List query mock.ExpectQuery(regexp.QuoteMeta(`SELECT j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, - j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at, + j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at, COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url, r.name as region_name, ci.name as city_name FROM jobs j`)). WillReturnRows(sqlmock.NewRows([]string{ "id", "company_id", "title", "description", "salary_min", "salary_max", "salary_type", - "employment_type", "work_mode", "location", "status", "is_featured", "created_at", "updated_at", + "employment_type", "work_mode", "working_hours", "location", "status", "salary_negotiable", "is_featured", "created_at", "updated_at", "company_name", "company_logo_url", "region_name", "city_name", }).AddRow( - "1", "10", "Dev", "Desc", 100, 200, "m", "ft", "Remote", "Remote", "open", false, time.Now(), time.Now(), + "1", "10", "Dev", "Desc", 100, 200, "m", "ft", "Remote", "40h", "Remote", "open", true, false, time.Now(), time.Now(), "Acme", "url", "Region", "City", )) diff --git a/backend/internal/services/ticket_service_test.go b/backend/internal/services/ticket_service_test.go new file mode 100644 index 0000000..6597840 --- /dev/null +++ b/backend/internal/services/ticket_service_test.go @@ -0,0 +1,167 @@ +package services_test + +import ( + "context" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/rede5/gohorsejobs/backend/internal/services" + "github.com/stretchr/testify/assert" +) + +func TestCreateTicket(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := services.NewTicketService(db) + + tests := []struct { + name string + userID string + subject string + priority string + mockRun func() + wantErr bool + }{ + { + name: "Success", + userID: "user-1", + subject: "Help me", + priority: "high", + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). + WithArgs("user-1", "Help me", "high"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", "user-1", "Help me", "open", "high", time.Now(), time.Now())) + }, + wantErr: false, + }, + { + name: "Default Priority", + userID: "user-1", + subject: "Help me", + priority: "", + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). + WithArgs("user-1", "Help me", "medium"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", "user-1", "Help me", "open", "medium", time.Now(), time.Now())) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockRun() + got, err := service.CreateTicket(context.Background(), tt.userID, tt.subject, tt.priority) + if (err != nil) != tt.wantErr { + t.Errorf("TicketService.CreateTicket() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, "ticket-1", got.ID) + } + }) + } +} + +func TestListTickets(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := services.NewTicketService(db) + + tests := []struct { + name string + userID string + mockRun func() + wantErr bool + }{ + { + name: "Success", + userID: "user-1", + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets WHERE user_id = $1`)). + WithArgs("user-1"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", "user-1", "Help", "open", "medium", time.Now(), time.Now()). + AddRow("ticket-2", "user-1", "Bug", "closed", "high", time.Now(), time.Now())) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockRun() + tickets, err := service.ListTickets(context.Background(), tt.userID) + if (err != nil) != tt.wantErr { + t.Errorf("TicketService.ListTickets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Len(t, tickets, 2) + } + }) + } +} + +func TestGetTicket(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := services.NewTicketService(db) + + tests := []struct { + name string + ticketID string + userID string + mockRun func() + wantErr bool + }{ + { + name: "Success", + ticketID: "ticket-1", + userID: "user-1", + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets WHERE id = $1 AND user_id = $2`)). + WithArgs("ticket-1", "user-1"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", "user-1", "Help", "open", "medium", time.Now(), time.Now())) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, ticket_id, user_id, message, created_at FROM ticket_messages WHERE ticket_id = $1`)). + WithArgs("ticket-1"). + WillReturnRows(sqlmock.NewRows([]string{"id", "ticket_id", "user_id", "message", "created_at"}). + AddRow("msg-1", "ticket-1", "user-1", "Hello", time.Now())) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockRun() + ticket, msgs, err := service.GetTicket(context.Background(), tt.ticketID, tt.userID) + if (err != nil) != tt.wantErr { + t.Errorf("TicketService.GetTicket() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, "ticket-1", ticket.ID) + assert.Len(t, msgs, 1) + } + }) + } +} diff --git a/frontend/src/app/dashboard/users/page.test.tsx b/frontend/src/app/dashboard/users/page.test.tsx new file mode 100644 index 0000000..11cee75 --- /dev/null +++ b/frontend/src/app/dashboard/users/page.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import AdminUsersPage from "./page"; +import { usersApi, adminCompaniesApi } from "@/lib/api"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; + +// Mocks +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock("@/lib/api", () => ({ + usersApi: { + list: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + adminCompaniesApi: { + list: jest.fn(), + }, +})); + +jest.mock("@/lib/auth", () => ({ + getCurrentUser: jest.fn(), + isAdminUser: jest.fn(), +})); + +jest.mock("sonner", () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +// UI Mocks +jest.mock("@/components/ui/button", () => ({ Button: ({ children, onClick, ...props }: any) => })); +jest.mock("@/components/ui/input", () => ({ Input: (props: any) => })); +jest.mock("@/components/ui/card", () => ({ + Card: ({ children }: any) =>
{children}
, + CardHeader: ({ children }: any) =>
{children}
, + CardTitle: ({ children }: any) =>

{children}

, + CardDescription: ({ children }: any) =>
{children}
, + CardContent: ({ children }: any) =>
{children}
+})); +jest.mock("@/components/ui/badge", () => ({ Badge: ({ children }: any) => {children} })); +jest.mock("@/components/ui/table", () => ({ + Table: ({ children }: any) => {children}
, + TableHeader: ({ children }: any) => {children}, + TableBody: ({ children }: any) => {children}, + TableRow: ({ children }: any) => {children}, + TableHead: ({ children }: any) => {children}, + TableCell: ({ children }: any) => {children}, +})); +jest.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children, open }: any) => open ?
{children}
: null, + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogTrigger: ({ children, onClick }: any) =>
{children}
, // simplified +})); +jest.mock("@/components/ui/label", () => ({ Label: ({ children }: any) => })); +jest.mock("@/components/ui/select", () => ({ + Select: ({ children, value, onValueChange }: any) => ( + + ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: () => null, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , +})); +jest.mock("@/components/ui/skeleton", () => ({ Skeleton: () =>
Loading...
})); + +describe("AdminUsersPage", () => { + const mockUser = { id: "1", name: "Admin", role: "admin" }; + + beforeEach(() => { + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(mockUser); + (isAdminUser as jest.Mock).mockReturnValue(true); + (usersApi.list as jest.Mock).mockResolvedValue({ + data: [ + { id: "1", name: "John Doe", email: "john@example.com", role: "candidate", status: "active", created_at: new Date().toISOString() } + ], + pagination: { total: 1, page: 1, limit: 10 } + }); + (adminCompaniesApi.list as jest.Mock).mockResolvedValue({ data: [] }); + }); + + it("renders and lists users", async () => { + render(); + + expect(screen.getByText("User management")).toBeInTheDocument(); + // Loading state + expect(screen.getAllByText("Loading...").length).toBeGreaterThan(0); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("john@example.com")).toBeInTheDocument(); + }); + }); + + it("opens create user dialog", async () => { + render(); + + await waitFor(() => screen.getByText("New user")); + + // Since DialogTrigger is mocked as div, fire click + // Note: In real Shadcn, DialogTrigger wraps button. + // We need to find the "New user" button which is inside DialogTrigger. + // Our mock wraps children. + + fireEvent.click(screen.getByText("New user")); + + // Check if dialog content appears + // The mock Dialog renders children if open. + // However, the button inside DialogTrigger usually toggles state in parent. + // But our mock Dialog just renders based on 'open' prop controlled by parent state. + // Wait, the parent component controls `isDialogOpen`. + // The real `DialogTrigger` calls `onOpenChange(true)`. + // Our mock `DialogTrigger` implementation `{ children, onClick }` is nice but `onClick` isn't passed from `DialogTrigger` prop in real usage? + // Actually `DialogTrigger` usually doesn't take onClick, it handles it internally. + // But we can't easily trigger the state change in the parent unless we simulate the real trigger behavior. + // Since we cannot rely on complex interaction in mock, we might skip "interactions" tests if mocks are too shallow. + // Or we assume `New user` button click works if we didn't mock Button to do nothing. + // But `Dialog` relies on `isDialogOpen` state. + + // Let's rely on standard library behavior: + // The code has ` + + + + + + )) + )} + + + +
+ {totalUsers === 0 ? "No users to display" : `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalUsers)} of ${totalUsers}`} diff --git a/frontend/src/app/forgot-password/page.test.tsx b/frontend/src/app/forgot-password/page.test.tsx new file mode 100644 index 0000000..b49e6f1 --- /dev/null +++ b/frontend/src/app/forgot-password/page.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import ForgotPasswordPage from "./page"; + +// Mock translation +jest.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock simple UI components to avoid dependency issues if not configured +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); +jest.mock("@/components/ui/card", () => ({ + Card: ({ children }: any) =>
{children}
, + CardContent: ({ children }: any) =>
{children}
, +})); +jest.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); +jest.mock("@/components/ui/label", () => ({ + Label: ({ children, htmlFor }: any) => , +})); +jest.mock("@/components/ui/alert", () => ({ + Alert: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, +})); + +describe("ForgotPasswordPage", () => { + it("renders the form correctly", () => { + render(); + + expect(screen.getByText("auth.forgot.title")).toBeInTheDocument(); + expect(screen.getByText("auth.forgot.subtitle")).toBeInTheDocument(); + expect(screen.getByLabelText("auth.forgot.fields.email")).toBeInTheDocument(); + expect(screen.getByText("auth.forgot.submit")).toBeInTheDocument(); + }); + + it("shows success message after submission", () => { + render(); + + const emailInput = screen.getByLabelText("auth.forgot.fields.email"); + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + const submitBtn = screen.getByText("auth.forgot.submit"); + fireEvent.click(submitBtn); + + expect(screen.getByText("auth.forgot.success")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/forgot-password/page.tsx b/frontend/src/app/forgot-password/page.tsx index d3fb836..27f3e71 100644 --- a/frontend/src/app/forgot-password/page.tsx +++ b/frontend/src/app/forgot-password/page.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { useTranslation } from "@/lib/i18n"; +import { ArrowLeft, Mail } from "lucide-react"; export default function ForgotPasswordPage() { const { t } = useTranslation(); @@ -20,24 +21,42 @@ export default function ForgotPasswordPage() { }; return ( -
-
+
+
+ {/* Back to Login - Mobile friendly top placement */} +
+ + + {t("auth.forgot.backLogin")} + +
+
-

{t("auth.forgot.title")}

-

{t("auth.forgot.subtitle")}

+
+ +
+

{t("auth.forgot.title")}

+

{t("auth.forgot.subtitle")}

- + {submitted && ( - - {t("auth.forgot.success")} + + + {t("auth.forgot.success")} + )}
- + setEmail(event.target.value)} placeholder={t("auth.forgot.fields.emailPlaceholder")} required + className="h-10 sm:h-11" />
-
-
+ {/* Back to Login - Desktop */} +
+ {t("auth.forgot.backLogin")}
@@ -67,3 +89,4 @@ export default function ForgotPasswordPage() {
); } + diff --git a/frontend/src/app/jobs/[id]/apply/page.tsx b/frontend/src/app/jobs/[id]/apply/page.tsx index f842020..ef240fc 100644 --- a/frontend/src/app/jobs/[id]/apply/page.tsx +++ b/frontend/src/app/jobs/[id]/apply/page.tsx @@ -228,10 +228,10 @@ export default function JobApplicationPage({
-
+
{/* Header */} -
+
{/* Step progress */} -
+
Step {currentStep} of {steps.length}:{" "} @@ -269,6 +269,25 @@ export default function JobApplicationPage({
+ {/* Step indicator - Mobile */} +
+ {steps.map((step) => { + const isActive = step.id === currentStep; + const isCompleted = step.id < currentStep; + return ( +
+ ); + })} +
+ {/* Step indicator (desktop) */}
{steps.map((step) => { @@ -602,17 +621,18 @@ export default function JobApplicationPage({ )} - + -
+
})); +jest.mock("@/components/ui/card", () => ({ + Card: ({ children }: any) =>
{children}
, + CardHeader: ({ children }: any) =>
{children}
, + CardTitle: ({ children }: any) =>

{children}

, + CardDescription: ({ children }: any) =>
{children}
, + CardContent: ({ children }: any) =>
{children}
, +})); +jest.mock("@/components/ui/badge", () => ({ Badge: ({ children }: any) => {children} })); +jest.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children }: any) =>
{children}
, + AvatarFallback: ({ children }: any) =>
{children}
, + AvatarImage: ({ src }: any) => avatar, +})); +jest.mock("@/components/ui/separator", () => ({ Separator: () =>
})); + +// Mock framer-motion +jest.mock("framer-motion", () => ({ + motion: { + div: ({ children }: any) =>
{children}
, + } +})); + +describe("JobDetailPage", () => { + const mockJob = { + id: "1", + title: "Senior Go Developer", + companyName: "GoHorse Inc", + description: "Great job", + location: "Remote", + salaryMin: 10000, + salaryMax: 20000, + createdAt: new Date().toISOString(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders job details when found", async () => { + (jobsApi.getById as jest.Mock).mockResolvedValue(mockJob); + + // We can't easily test the `await params` unwrapping in simple Jest without complex setup. + // We will assume the mock of `React.use` works or we pass a resolved promise? + // Actually, `use` is a hook. + + // Simplification: We will rendering the component. + // However, `use(params)` inside the component will call our mocked `use`. + + const paramsPromise = Promise.resolve({ id: "1" }); + render(); + + expect(screen.getByText("Loading job...")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText("Loading job...")).not.toBeInTheDocument(); + expect(screen.getByText("Senior Go Developer")).toBeInTheDocument(); + expect(screen.getByText("GoHorse Inc")).toBeInTheDocument(); + }); + }); + + it("renders not found state", async () => { + (jobsApi.getById as jest.Mock).mockResolvedValue(null); + const paramsPromise = Promise.resolve({ id: "999" }); + render(); + + await waitFor(() => { + expect(screen.getByText("Job not found")).toBeInTheDocument(); + }); + }); +});