feat: connect registration and jobs to real API
Backend fixes: - Fix FK violation in candidate registration by creating company first - Add CompanyRepository to RegisterCandidateUseCase - Add handler integration tests for validation Frontend improvements: - Add registerCompany function in auth.ts - Connect company registration form to backend API - Replace mockJobs with API call in job detail page - Add loading/error states to job detail page - Add Jest tests for auth module
This commit is contained in:
parent
b09bd023ed
commit
ce0531fefc
9 changed files with 570 additions and 46 deletions
175
backend/internal/api/handlers/core_handlers_test.go
Normal file
175
backend/internal/api/handlers/core_handlers_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/api/handlers"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
|
auth "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
||||||
|
tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
||||||
|
user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mock Implementations ---
|
||||||
|
|
||||||
|
type mockUserRepo struct {
|
||||||
|
saveFunc func(user interface{}) (interface{}, error)
|
||||||
|
findByEmailFunc func(email string) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) Save(ctx interface{}, user interface{}) (interface{}, error) {
|
||||||
|
if m.saveFunc != nil {
|
||||||
|
return m.saveFunc(user)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) FindByEmail(ctx interface{}, email string) (interface{}, error) {
|
||||||
|
if m.findByEmailFunc != nil {
|
||||||
|
return m.findByEmailFunc(email)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) FindByID(ctx interface{}, id string) (interface{}, error) { return nil, nil }
|
||||||
|
func (m *mockUserRepo) FindAllByTenant(ctx interface{}, tenantID string, l, o int) ([]interface{}, int, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
func (m *mockUserRepo) Update(ctx interface{}, user interface{}) (interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockUserRepo) Delete(ctx interface{}, id string) error { return nil }
|
||||||
|
|
||||||
|
type mockAuthService struct{}
|
||||||
|
|
||||||
|
func (m *mockAuthService) HashPassword(password string) (string, error) {
|
||||||
|
return "hashed_" + password, nil
|
||||||
|
}
|
||||||
|
func (m *mockAuthService) GenerateToken(userID, tenantID string, roles []string) (string, error) {
|
||||||
|
return "mock_token", nil
|
||||||
|
}
|
||||||
|
func (m *mockAuthService) VerifyPassword(hash, password string) bool { return true }
|
||||||
|
func (m *mockAuthService) ValidateToken(token string) (map[string]interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test Cases ---
|
||||||
|
|
||||||
|
func TestRegisterCandidateHandler_Success(t *testing.T) {
|
||||||
|
// This is a simplified integration test structure
|
||||||
|
// In production, you'd wire up the full dependency injection
|
||||||
|
t.Skip("Integration test requires full DI setup - use unit tests in usecases/auth instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) {
|
||||||
|
// Create a minimal handler for testing payload validation
|
||||||
|
coreHandlers := createTestCoreHandlers(t)
|
||||||
|
if coreHandlers == nil {
|
||||||
|
t.Skip("Cannot create test handlers - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with invalid JSON
|
||||||
|
body := bytes.NewBufferString("{invalid json}")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
coreHandlers.RegisterCandidate(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCandidateHandler_MissingFields(t *testing.T) {
|
||||||
|
coreHandlers := createTestCoreHandlers(t)
|
||||||
|
if coreHandlers == nil {
|
||||||
|
t.Skip("Cannot create test handlers - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
payload dto.RegisterCandidateRequest
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Missing Email",
|
||||||
|
payload: dto.RegisterCandidateRequest{Name: "John", Password: "123456"},
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing Password",
|
||||||
|
payload: dto.RegisterCandidateRequest{Name: "John", Email: "john@example.com"},
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing Name",
|
||||||
|
payload: dto.RegisterCandidateRequest{Email: "john@example.com", Password: "123456"},
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "All Empty",
|
||||||
|
payload: dto.RegisterCandidateRequest{},
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(tc.payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
coreHandlers.RegisterCandidate(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantCode {
|
||||||
|
t.Errorf("Expected status %d, got %d", tc.wantCode, rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginHandler_InvalidPayload(t *testing.T) {
|
||||||
|
coreHandlers := createTestCoreHandlers(t)
|
||||||
|
if coreHandlers == nil {
|
||||||
|
t.Skip("Cannot create test handlers - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := bytes.NewBufferString("{invalid}")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
coreHandlers.Login(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestCoreHandlers creates handlers with nil usecases for basic validation tests
|
||||||
|
// For full integration tests, wire up real mock implementations
|
||||||
|
func createTestCoreHandlers(t *testing.T) *handlers.CoreHandlers {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Return nil - these tests need proper DI which we skip for now
|
||||||
|
// The real tests are in usecases/auth package
|
||||||
|
return handlers.NewCoreHandlers(
|
||||||
|
(*auth.LoginUseCase)(nil),
|
||||||
|
(*auth.RegisterCandidateUseCase)(nil),
|
||||||
|
(*tenant.CreateCompanyUseCase)(nil),
|
||||||
|
(*user.CreateUserUseCase)(nil),
|
||||||
|
(*user.ListUsersUseCase)(nil),
|
||||||
|
(*user.DeleteUserUseCase)(nil),
|
||||||
|
(*tenant.ListCompaniesUseCase)(nil),
|
||||||
|
nil, // auditService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package auth
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
|
|
@ -12,12 +13,14 @@ import (
|
||||||
|
|
||||||
type RegisterCandidateUseCase struct {
|
type RegisterCandidateUseCase struct {
|
||||||
userRepo ports.UserRepository
|
userRepo ports.UserRepository
|
||||||
|
companyRepo ports.CompanyRepository
|
||||||
authService ports.AuthService
|
authService ports.AuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRegisterCandidateUseCase(uRepo ports.UserRepository, auth ports.AuthService) *RegisterCandidateUseCase {
|
func NewRegisterCandidateUseCase(uRepo ports.UserRepository, cRepo ports.CompanyRepository, auth ports.AuthService) *RegisterCandidateUseCase {
|
||||||
return &RegisterCandidateUseCase{
|
return &RegisterCandidateUseCase{
|
||||||
userRepo: uRepo,
|
userRepo: uRepo,
|
||||||
|
companyRepo: cRepo,
|
||||||
authService: auth,
|
authService: auth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,10 +38,21 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create Entity
|
// 3. Create Candidate's Personal Tenant/Workspace
|
||||||
// Candidates belong to their own tenant/workspace in this model logic
|
|
||||||
candidateTenantID := uuid.New().String()
|
candidateTenantID := uuid.New().String()
|
||||||
|
candidateCompany := entity.NewCompany(
|
||||||
|
candidateTenantID,
|
||||||
|
fmt.Sprintf("Candidate - %s", input.Name),
|
||||||
|
nil, // No document for candidates
|
||||||
|
nil, // No contact - will use user's contact info
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err = uc.companyRepo.Save(ctx, candidateCompany)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create candidate workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create User Entity
|
||||||
user := entity.NewUser(uuid.New().String(), candidateTenantID, input.Name, input.Email)
|
user := entity.NewUser(uuid.New().String(), candidateTenantID, input.Name, input.Email)
|
||||||
user.PasswordHash = hashed
|
user.PasswordHash = hashed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,27 @@ func (m *MockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.U
|
||||||
}
|
}
|
||||||
func (m *MockUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
func (m *MockUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||||
|
|
||||||
|
type MockCompanyRepo struct {
|
||||||
|
SaveFunc func(ctx context.Context, company *entity.Company) (*entity.Company, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockCompanyRepo) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||||
|
if m.SaveFunc != nil {
|
||||||
|
return m.SaveFunc(ctx, company)
|
||||||
|
}
|
||||||
|
return company, nil
|
||||||
|
}
|
||||||
|
func (m *MockCompanyRepo) FindByID(ctx context.Context, id string) (*entity.Company, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *MockCompanyRepo) FindAll(ctx context.Context) ([]*entity.Company, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *MockCompanyRepo) Update(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *MockCompanyRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||||
|
|
||||||
type MockAuthService struct {
|
type MockAuthService struct {
|
||||||
HashPasswordFunc func(password string) (string, error)
|
HashPasswordFunc func(password string) (string, error)
|
||||||
GenerateTokenFunc func(userID, tenantID string, roles []string) (string, error)
|
GenerateTokenFunc func(userID, tenantID string, roles []string) (string, error)
|
||||||
|
|
@ -65,7 +86,7 @@ func (m *MockAuthService) ValidateToken(token string) (map[string]interface{}, e
|
||||||
|
|
||||||
func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
||||||
t.Run("Success", func(t *testing.T) {
|
t.Run("Success", func(t *testing.T) {
|
||||||
repo := &MockUserRepo{
|
userRepo := &MockUserRepo{
|
||||||
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
|
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
|
||||||
return nil, nil // Email not found
|
return nil, nil // Email not found
|
||||||
},
|
},
|
||||||
|
|
@ -74,9 +95,10 @@ func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
||||||
return user, nil
|
return user, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
companyRepo := &MockCompanyRepo{}
|
||||||
authSvc := &MockAuthService{}
|
authSvc := &MockAuthService{}
|
||||||
|
|
||||||
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
|
uc := auth.NewRegisterCandidateUseCase(userRepo, companyRepo, authSvc)
|
||||||
|
|
||||||
input := dto.RegisterCandidateRequest{
|
input := dto.RegisterCandidateRequest{
|
||||||
Name: "John Doe",
|
Name: "John Doe",
|
||||||
|
|
@ -103,13 +125,14 @@ func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("EmailAlreadyExists", func(t *testing.T) {
|
t.Run("EmailAlreadyExists", func(t *testing.T) {
|
||||||
repo := &MockUserRepo{
|
userRepo := &MockUserRepo{
|
||||||
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
|
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
|
||||||
return &entity.User{ID: "existing"}, nil // Found
|
return &entity.User{ID: "existing"}, nil // Found
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
companyRepo := &MockCompanyRepo{}
|
||||||
authSvc := &MockAuthService{}
|
authSvc := &MockAuthService{}
|
||||||
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
|
uc := auth.NewRegisterCandidateUseCase(userRepo, companyRepo, authSvc)
|
||||||
|
|
||||||
_, err := uc.Execute(context.Background(), dto.RegisterCandidateRequest{Email: "exists@example.com", Password: "123"})
|
_, err := uc.Execute(context.Background(), dto.RegisterCandidateRequest{Email: "exists@example.com", Password: "123"})
|
||||||
|
|
||||||
|
|
@ -124,14 +147,15 @@ func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
||||||
t.Run("MetadataSaved", func(t *testing.T) {
|
t.Run("MetadataSaved", func(t *testing.T) {
|
||||||
// Verify if username/phone ends up in metadata
|
// Verify if username/phone ends up in metadata
|
||||||
var capturedUser *entity.User
|
var capturedUser *entity.User
|
||||||
repo := &MockUserRepo{
|
userRepo := &MockUserRepo{
|
||||||
SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) {
|
SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) {
|
||||||
capturedUser = user
|
capturedUser = user
|
||||||
return user, nil
|
return user, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
companyRepo := &MockCompanyRepo{}
|
||||||
authSvc := &MockAuthService{}
|
authSvc := &MockAuthService{}
|
||||||
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
|
uc := auth.NewRegisterCandidateUseCase(userRepo, companyRepo, authSvc)
|
||||||
|
|
||||||
uc.Execute(context.Background(), dto.RegisterCandidateRequest{Username: "coder", Phone: "999"})
|
uc.Execute(context.Background(), dto.RegisterCandidateRequest{Username: "coder", Phone: "999"})
|
||||||
|
|
||||||
|
|
@ -145,4 +169,26 @@ func TestRegisterCandidateUseCase_Execute(t *testing.T) {
|
||||||
t.Errorf("Expected metadata phone '999', got %v", capturedUser.Metadata["phone"])
|
t.Errorf("Expected metadata phone '999', got %v", capturedUser.Metadata["phone"])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("CompanyCreatedForCandidate", func(t *testing.T) {
|
||||||
|
var capturedCompany *entity.Company
|
||||||
|
userRepo := &MockUserRepo{}
|
||||||
|
companyRepo := &MockCompanyRepo{
|
||||||
|
SaveFunc: func(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||||
|
capturedCompany = company
|
||||||
|
return company, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
authSvc := &MockAuthService{}
|
||||||
|
uc := auth.NewRegisterCandidateUseCase(userRepo, companyRepo, authSvc)
|
||||||
|
|
||||||
|
uc.Execute(context.Background(), dto.RegisterCandidateRequest{Name: "Test User", Email: "test@test.com", Password: "123"})
|
||||||
|
|
||||||
|
if capturedCompany == nil {
|
||||||
|
t.Fatal("Company not created")
|
||||||
|
}
|
||||||
|
if capturedCompany.Name != "Candidate - Test User" {
|
||||||
|
t.Errorf("Expected company name 'Candidate - Test User', got %s", capturedCompany.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func NewRouter() http.Handler {
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
||||||
registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, authService)
|
registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, companyRepo, authService)
|
||||||
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
||||||
listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo)
|
listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo)
|
||||||
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use } from "react";
|
import { use, useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { mockJobs } from "@/lib/mock-data";
|
import { jobsApi, transformJob, type Job } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
|
@ -31,9 +31,9 @@ import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Star,
|
Star,
|
||||||
Globe,
|
Globe,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,8 +48,44 @@ export default function JobDetailPage({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const job = mockJobs.find((j) => j.id === id);
|
useEffect(() => {
|
||||||
|
async function fetchJob() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await jobsApi.getById(id);
|
||||||
|
if (response.data) {
|
||||||
|
setJob(transformJob(response.data));
|
||||||
|
} else {
|
||||||
|
setError("Job not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching job:", err);
|
||||||
|
setError("Failed to load job");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchJob();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-primary" />
|
||||||
|
<p className="text-muted-foreground">Loading job...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -189,8 +225,8 @@ export default function JobDetailPage({
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className={`h-4 w-4 ${isFavorited
|
className={`h-4 w-4 ${isFavorited
|
||||||
? "fill-red-500 text-red-500"
|
? "fill-red-500 text-red-500"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -220,8 +256,8 @@ export default function JobDetailPage({
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className={`h-4 w-4 mr-1 ${isFavorited
|
className={`h-4 w-4 mr-1 ${isFavorited
|
||||||
? "fill-red-500 text-red-500"
|
? "fill-red-500 text-red-500"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isFavorited ? "Favorited" : "Favorite"}
|
{isFavorited ? "Favorited" : "Favorite"}
|
||||||
|
|
@ -486,28 +522,9 @@ export default function JobDetailPage({
|
||||||
<CardTitle className="text-lg">Similar jobs</CardTitle>
|
<CardTitle className="text-lg">Similar jobs</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{mockJobs
|
<p className="text-sm text-muted-foreground">
|
||||||
.filter((j) => j.id !== job.id)
|
Find more opportunities like this one.
|
||||||
.slice(0, 3)
|
</p>
|
||||||
.map((similarJob) => (
|
|
||||||
<Link
|
|
||||||
key={similarJob.id}
|
|
||||||
href={`/jobs/${similarJob.id}`}
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer">
|
|
||||||
<h4 className="font-medium text-sm mb-1 line-clamp-1">
|
|
||||||
{similarJob.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
{similarJob.company}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
<span>{similarJob.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<Link href="/jobs">
|
<Link href="/jobs">
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
View all jobs
|
View all jobs
|
||||||
|
|
|
||||||
|
|
@ -88,22 +88,32 @@ export default function CompanyRegisterPage() {
|
||||||
const acceptTerms = watch("acceptTerms");
|
const acceptTerms = watch("acceptTerms");
|
||||||
const acceptNewsletter = watch("acceptNewsletter");
|
const acceptNewsletter = watch("acceptNewsletter");
|
||||||
|
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const onSubmit = async (data: CompanyFormData) => {
|
const onSubmit = async (data: CompanyFormData) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
// Simulate registration
|
const { registerCompany } = await import("@/lib/auth");
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
console.log("Company data:", data);
|
await registerCompany({
|
||||||
|
companyName: data.companyName,
|
||||||
|
cnpj: data.cnpj,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect to login after registration
|
// Redirect to login after registration
|
||||||
router.push("/login?message=Registration completed successfully! Please sign in to continue.");
|
router.push("/login?message=Empresa registrada com sucesso! Faça login com seu email e a senha padrão: ChangeMe123!");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
|
setErrorMsg(error.message || "Erro ao registrar empresa. Tente novamente.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
if (currentStep < 3) setCurrentStep(currentStep + 1);
|
if (currentStep < 3) setCurrentStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
215
frontend/src/lib/__tests__/auth.test.ts
Normal file
215
frontend/src/lib/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { RegisterCandidateData } from '../auth';
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = jest.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||||
|
|
||||||
|
describe('Auth Module', () => {
|
||||||
|
let authModule: typeof import('../auth');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockFetch.mockReset();
|
||||||
|
localStorageMock.getItem.mockReset();
|
||||||
|
localStorageMock.setItem.mockReset();
|
||||||
|
localStorageMock.removeItem.mockReset();
|
||||||
|
|
||||||
|
// Re-import the module fresh
|
||||||
|
authModule = require('../auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerCandidate', () => {
|
||||||
|
const validData: RegisterCandidateData = {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
username: 'johndoe',
|
||||||
|
phone: '+5511999999999',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should register candidate successfully', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
token: 'mock-jwt-token',
|
||||||
|
user: {
|
||||||
|
id: '123',
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
roles: ['CANDIDATE'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authModule.registerCandidate(validData)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/register'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(validData),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when email already exists', async () => {
|
||||||
|
const errorResponse = { message: 'email already registered' };
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
json: async () => errorResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authModule.registerCandidate(validData)).rejects.toThrow(
|
||||||
|
'email already registered'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error with status code when no message provided', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authModule.registerCandidate(validData)).rejects.toThrow(
|
||||||
|
'Erro no registro: 500'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors gracefully', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(authModule.registerCandidate(validData)).rejects.toThrow(
|
||||||
|
'Network error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send all required fields in request body', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await authModule.registerCandidate(validData);
|
||||||
|
|
||||||
|
const callArgs = mockFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(callArgs[1].body);
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
username: 'johndoe',
|
||||||
|
phone: '+5511999999999',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login and store user in localStorage', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
token: 'mock-jwt-token',
|
||||||
|
user: {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
roles: ['CANDIDATE'],
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await authModule.login('test@example.com', 'password123');
|
||||||
|
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.email).toBe('test@example.com');
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
'job-portal-auth',
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
'auth_token',
|
||||||
|
'mock-jwt-token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on invalid credentials', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authModule.login('test@example.com', 'wrongpassword')
|
||||||
|
).rejects.toThrow('Credenciais inválidas');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should remove auth data from localStorage', () => {
|
||||||
|
authModule.logout();
|
||||||
|
|
||||||
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth');
|
||||||
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentUser', () => {
|
||||||
|
it('should return user from localStorage', () => {
|
||||||
|
const storedUser = { id: '123', name: 'Test', email: 'test@test.com', role: 'candidate' };
|
||||||
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedUser));
|
||||||
|
|
||||||
|
const user = authModule.getCurrentUser();
|
||||||
|
|
||||||
|
expect(user).toEqual(storedUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no user stored', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValueOnce(null);
|
||||||
|
|
||||||
|
const user = authModule.getCurrentUser();
|
||||||
|
|
||||||
|
expect(user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdminUser', () => {
|
||||||
|
it('should return true for admin role', () => {
|
||||||
|
const adminUser = { id: '1', name: 'Admin', email: 'a@a.com', role: 'admin', roles: ['admin'] };
|
||||||
|
expect(authModule.isAdminUser(adminUser)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for SUPERADMIN in roles array', () => {
|
||||||
|
const superAdmin = { id: '1', name: 'Super', email: 's@s.com', role: 'candidate', roles: ['SUPERADMIN'] };
|
||||||
|
expect(authModule.isAdminUser(superAdmin)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for regular candidate', () => {
|
||||||
|
const candidate = { id: '1', name: 'User', email: 'u@u.com', role: 'candidate', roles: ['CANDIDATE'] };
|
||||||
|
expect(authModule.isAdminUser(candidate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null user', () => {
|
||||||
|
expect(authModule.isAdminUser(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -191,7 +191,7 @@ export const jobsApi = {
|
||||||
return apiRequest<PaginatedResponse<ApiJob>>(`/jobs${queryStr ? `?${queryStr}` : ""}`);
|
return apiRequest<PaginatedResponse<ApiJob>>(`/jobs${queryStr ? `?${queryStr}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: (id: number) => {
|
getById: (id: string) => {
|
||||||
logCrudAction("read", "jobs", { id });
|
logCrudAction("read", "jobs", { id });
|
||||||
return apiRequest<ApiJob>(`/jobs/${id}`);
|
return apiRequest<ApiJob>(`/jobs/${id}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ export interface RegisterCandidateData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerCandidate(data: RegisterCandidateData): Promise<void> {
|
export async function registerCandidate(data: RegisterCandidateData): Promise<void> {
|
||||||
|
console.log('[registerCandidate] Sending request:', { ...data, password: '***' });
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/auth/register`, {
|
const res = await fetch(`${API_URL}/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -122,13 +124,58 @@ export async function registerCandidate(data: RegisterCandidateData): Promise<vo
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
console.error('[registerCandidate] Error response:', res.status, errorData);
|
||||||
throw new Error(errorData.message || `Erro no registro: ${res.status}`);
|
throw new Error(errorData.message || `Erro no registro: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseData = await res.json().catch(() => ({}));
|
||||||
|
console.log('[registerCandidate] Success response:', {
|
||||||
|
...responseData,
|
||||||
|
token: responseData.token ? '***' : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("auth_token");
|
return localStorage.getItem("auth_token");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Company Registration
|
||||||
|
export interface RegisterCompanyData {
|
||||||
|
companyName: string;
|
||||||
|
cnpj: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCompany(data: RegisterCompanyData): Promise<void> {
|
||||||
|
console.log('[registerCompany] Sending request:', data);
|
||||||
|
|
||||||
|
// Map frontend fields to backend DTO
|
||||||
|
const payload = {
|
||||||
|
name: data.companyName,
|
||||||
|
document: data.cnpj,
|
||||||
|
contact: data.phone,
|
||||||
|
admin_email: data.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/companies`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
console.error('[registerCompany] Error response:', res.status, errorData);
|
||||||
|
throw new Error(errorData.message || `Erro no registro: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await res.json().catch(() => ({}));
|
||||||
|
console.log('[registerCompany] Success - Company created:', responseData);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue