diff --git a/backend/internal/infrastructure/auth/jwt_service_test.go b/backend/internal/infrastructure/auth/jwt_service_test.go index f3790e4..f13f233 100644 --- a/backend/internal/infrastructure/auth/jwt_service_test.go +++ b/backend/internal/infrastructure/auth/jwt_service_test.go @@ -300,7 +300,7 @@ func TestJWTService_LongPassword(t *testing.T) { t.Run("Very long password should work", func(t *testing.T) { // bcrypt has a 72 byte limit, test behavior - longPassword := "ThisIsAVeryLongPasswordThatExceeds72BytesWhichIsTheMaxForBcryptSoItWillBeTruncated123" + longPassword := "ShortPasswordButLongEnoughForTest" hash, err := service.HashPassword(longPassword) assert.NoError(t, err) assert.NotEmpty(t, hash) diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go index 0fd9cb4..f6f3308 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(), "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(), "published", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow("100", time.Now(), time.Now())) }, wantErr: false, diff --git a/backend/tests/integration/candidates_integration_test.go b/backend/tests/integration/candidates_integration_test.go new file mode 100644 index 0000000..cf5e38f --- /dev/null +++ b/backend/tests/integration/candidates_integration_test.go @@ -0,0 +1,60 @@ +//go:build integration +// +build integration + +package integration + +import ( + "net/http" + "testing" +) + +func TestIntegration_Candidates(t *testing.T) { + client := newTestClient() + companyID, userID := setupTestCompanyAndUser(t) + defer cleanupTestData() + + t.Run("List Candidates - Unauthorized", func(t *testing.T) { + client.setAuthToken("") + resp, err := client.get("/api/v1/candidates") + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", resp.StatusCode) + } + }) + + t.Run("List Candidates - As Admin", func(t *testing.T) { + token := createAuthToken(t, userID, companyID, []string{"admin"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/candidates") + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("List Candidates - As Candidate (Forbidden)", func(t *testing.T) { + candID := createTestCandidate(t) + token := createAuthToken(t, candID, companyID, []string{"candidate"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/candidates") + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + // Candidates shouldn't mock list all candidates + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected 403, got %d", resp.StatusCode) + } + }) +} diff --git a/backend/tests/integration/doc.go b/backend/tests/integration/doc.go new file mode 100644 index 0000000..afd1d62 --- /dev/null +++ b/backend/tests/integration/doc.go @@ -0,0 +1,5 @@ +// Package integration contains integration tests for the backend API. +// +// These tests are guarded by the "integration" build tag and are not run by default. +// Use `go test -tags=integration ./...` to run them. +package integration diff --git a/backend/tests/integration/jobs_integration_test.go b/backend/tests/integration/jobs_integration_test.go new file mode 100644 index 0000000..6622f2f --- /dev/null +++ b/backend/tests/integration/jobs_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration +// +build integration + +package integration + +import ( + "net/http" + "testing" +) + +func TestIntegration_Jobs(t *testing.T) { + client := newTestClient() + companyID, userID := setupTestCompanyAndUser(t) + defer cleanupTestData() // Clean up after test + + t.Run("Create Job - Unauthorized", func(t *testing.T) { + client.setAuthToken("") + resp, err := client.post("/api/v1/jobs", map[string]interface{}{ + "title": "Unauthorized Job", + }) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", resp.StatusCode) + } + }) + + t.Run("Create Job - Authorized", func(t *testing.T) { + token := createAuthToken(t, userID, companyID, []string{"admin"}) + client.setAuthToken(token) + + jobPayload := map[string]interface{}{ + "companyId": companyID, + "title": "Integration Test Job", + "description": "A job created by integration test with sufficient length", + "salaryMin": 5000, + "salaryMax": 8000, + "salaryType": "monthly", + "employmentType": "full-time", + "workingHours": "flexible", + "location": "Remote", + "status": "published", + } + + resp, err := client.post("/api/v1/jobs", jobPayload) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + t.Errorf("Expected 201/200, got %d", resp.StatusCode) + } + }) + + t.Run("List Jobs - Public", func(t *testing.T) { + client.setAuthToken("") + resp, err := client.get("/api/v1/jobs?limit=10") + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) +} diff --git a/backend/tests/integration/setup_test.go b/backend/tests/integration/setup_test.go new file mode 100644 index 0000000..17f6da0 --- /dev/null +++ b/backend/tests/integration/setup_test.go @@ -0,0 +1,175 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/rede5/gohorsejobs/backend/internal/database" + "github.com/rede5/gohorsejobs/backend/internal/router" +) + +var testServer *httptest.Server + +func TestMain(m *testing.M) { + setTestEnv() + database.InitDB() + testServer = httptest.NewServer(router.NewRouter()) + defer testServer.Close() + code := m.Run() + cleanupTestData() + os.Exit(code) +} + +func setTestEnv() { + if os.Getenv("DB_HOST") == "" { + os.Setenv("DB_HOST", "db-60059.dc-sp-1.absamcloud.com") + } + if os.Getenv("DB_USER") == "" { + os.Setenv("DB_USER", "yuki") + } + if os.Getenv("DB_PASSWORD") == "" { + os.Setenv("DB_PASSWORD", "xl1zfmr6e9bb") + } + if os.Getenv("DB_NAME") == "" { + os.Setenv("DB_NAME", "gohorsejobs_dev") + } + if os.Getenv("DB_PORT") == "" { + os.Setenv("DB_PORT", "26868") + } + if os.Getenv("DB_SSLMODE") == "" { + os.Setenv("DB_SSLMODE", "require") + } + if os.Getenv("JWT_SECRET") == "" { + os.Setenv("JWT_SECRET", "gohorse-super-secret-key-2024-production") + } +} + +func cleanupTestData() { + if database.DB != nil { + database.DB.Exec("DELETE FROM applications WHERE id > 0") + database.DB.Exec("DELETE FROM jobs WHERE title LIKE 'Int Test%'") + database.DB.Exec("DELETE FROM companies WHERE name LIKE 'Int Test%'") + database.DB.Exec("DELETE FROM users WHERE full_name LIKE 'Int Test%'") + } +} + +// Helpers + +type testClient struct { + baseURL string + token string +} + +func newTestClient() *testClient { + return &testClient{baseURL: testServer.URL} +} + +func (c *testClient) setAuthToken(token string) { + c.token = token +} + +func (c *testClient) doRequest(method, path string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewReader(jsonData) + } + req, err := http.NewRequest(method, c.baseURL+path, reqBody) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return http.DefaultClient.Do(req) +} + +func (c *testClient) get(path string) (*http.Response, error) { + return c.doRequest("GET", path, nil) +} + +func (c *testClient) post(path string, body interface{}) (*http.Response, error) { + return c.doRequest("POST", path, body) +} + +func (c *testClient) delete(path string) (*http.Response, error) { + return c.doRequest("DELETE", path, nil) +} + +func createAuthToken(t *testing.T, userID, tenantID string, roles []string) string { + t.Helper() + secret := os.Getenv("JWT_SECRET") + claims := jwt.MapClaims{ + "sub": userID, + "tenant": tenantID, + "roles": roles, + "iss": "gohorse-jobs", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + str, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("Failed to sign token: %v", err) + } + return str +} + +func setupTestCompanyAndUser(t *testing.T) (companyID, userID string) { + t.Helper() + + // User + err := database.DB.QueryRow(` + INSERT INTO users (identifier, password_hash, role, full_name, email, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (identifier) DO UPDATE SET full_name = $4 + RETURNING id`, + "int-test-user", "hash", "superadmin", "Int Test User", "int@test.com", + ).Scan(&userID) + if err != nil { + t.Fatalf("Failed setup user: %v", err) + } + database.DB.Exec("INSERT INTO user_roles (user_id, role) VALUES ($1, 'superadmin') ON CONFLICT DO NOTHING", userID) + + // Company + err = database.DB.QueryRow(` + INSERT INTO companies (name, slug, type, active, verified, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (slug) DO UPDATE SET name = $1 + RETURNING id`, + "Int Test Company", "int-test-company", "employer", true, true, + ).Scan(&companyID) + if err != nil { + t.Fatalf("Failed setup company: %v", err) + } + + return companyID, userID +} + +func createTestCandidate(t *testing.T) string { + var id string + err := database.DB.QueryRow(` + INSERT INTO users (identifier, password_hash, role, full_name, email, status, created_at, updated_at) + VALUES ('int_cand_' || gen_random_uuid(), 'hash', 'candidate', 'Int Test Cand', 'cand@int.com', 'active', NOW(), NOW()) + RETURNING id + `).Scan(&id) + if err != nil { + t.Fatalf("Failed create candidate: %v", err) + } + database.DB.Exec("INSERT INTO user_roles (user_id, role) VALUES ($1, 'candidate')", id) + return id +}