diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml new file mode 100644 index 0000000..c81878f --- /dev/null +++ b/.github/workflows/ci-cd.yaml @@ -0,0 +1,74 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +# Permissions required to push images to GitHub Container Registry (GHCR) +permissions: + contents: read + packages: write + +jobs: + # ================================================== + # Stage 1: Continuous Integration (CI) - Test & Build + # ================================================== + test-and-build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + cache: false + + - name: Install Dependencies + run: make deps + + - name: Run Tests + run: make test + + - name: Build Binary (Verify Compilation) + run: make build + + # ================================================== + # Stage 2: Continuous Deployment (CD) - Docker Push + # ================================================== + docker-push: + needs: test-and-build # Only run if tests passed + if: github.event_name == 'push' # Only on push to main (skip on PR) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Makefile b/Makefile index c3a1193..11759be 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,15 @@ -# Makefile for api-gateway-avner - BINARY_NAME=motzklist-api-gateway BUILD_DIR=./build -SRC_DIR=./ -# Default command: runs the server +# Default command: runs the server (using '.' to include all files in package) .PHONY: run run: - go run $(SRC_DIR)main.go + go run . -# Builds the binary +# Builds the binary (using '.' to include all files in package) .PHONY: build build: - go build -o $(BUILD_DIR)/$(BINARY_NAME) $(SRC_DIR)main.go + go build -o $(BUILD_DIR)/$(BINARY_NAME) . # Cleans up the build directory .PHONY: clean @@ -22,4 +19,13 @@ clean: # Installs dependencies .PHONY: deps deps: - go mod download \ No newline at end of file + go mod download + +# Runs tests +.PHONY: test +test: + @if command -v gotestsum > /dev/null; then \ + gotestsum --format testname ./...; \ + else \ + go test -v ./...; \ + fi \ No newline at end of file diff --git a/go.mod b/go.mod index 70a6cd4..ac97cb4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module api-gateway-avner - -go 1.25.4 +module api-gateway-avner + +go 1.25.4 diff --git a/main.go b/main.go index e04500e..a0af264 100644 --- a/main.go +++ b/main.go @@ -1,306 +1,306 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "math/rand" - "net/http" - "os" - "time" -) - -var sessions = map[string]string{} // sessionID -> userID - -func generateSessionID() string { - rand.New(rand.NewSource(time.Now().UnixNano())) - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, 32) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -// School structure -type School struct { - ID string `json:"id"` // Fix C: Changed single quotes to backticks (`) - Name string `json:"name"` -} - -type Grade struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type Equipment struct { - ID string `json:"id"` - Name string `json:"name"` - Quantity int `json:"quantity"` -} - -type EquipmentListResponse struct { - Items []Equipment `json:"items"` -} - -func JSONError(w http.ResponseWriter, err string, code int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - errEncode := json.NewEncoder(w).Encode(map[string]string{"error": err}) - if errEncode != nil { - log.Printf("Failed to encode JSON error response: %v", errEncode) - } -} - -// =====NEW===== -// login -type User struct { - UserID string `json:"userid"` - Username string `json:"username"` - Password string `json:"password"` -} - -func enableCORS(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - // Allow only the frontend origin - allowed_origin := os.Getenv("CLIENT_ORIGIN") - if allowed_origin == "" { - allowed_origin = "http://localhost:3000" // default for local development - } - if origin == allowed_origin { - w.Header().Set("Access-Control-Allow-Origin", origin) - } - // For production, use your real frontend URL above - - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.Header().Set("Access-Control-Allow-Credentials", "true") - - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - next.ServeHTTP(w, r) - } -} - -func main() { - // Handler for getSchools, getGrades, getEquipment - http.HandleFunc("/api/schools", enableCORS(getSchoolsHandler)) - http.HandleFunc("/api/grades", enableCORS(getGradesHandler)) - http.HandleFunc("/api/equipment", enableCORS(getEquipmentListsHandler)) - http.HandleFunc("/api/auth/status", enableCORS(authStatusHandler)) - http.HandleFunc("/api/login", enableCORS(postLoginHandler)) - http.HandleFunc("/api/logout", enableCORS(logoutHandler)) - http.HandleFunc("/api/cart", enableCORS(getPostCartHandler)) - - // Start the API Gateway server - port := "8080" // Changed port to string without colon for easier fmt use - // Using fmt.Sprintf to format the port with a colon for ListenAndServe - serverAddr := fmt.Sprintf(":%s", port) - - // Fix E: Corrected format specifier to %s - fmt.Printf("API Gateway starting on port %s\n", port) - - // Use the formatted address to listen - log.Fatal(http.ListenAndServe(serverAddr, nil)) -} - -func getSchoolsHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - // LATER: connect to database, extract corresponding list and parse it - schools := GetSchools() - - // Convert to Json - if err := json.NewEncoder(w).Encode(schools); err != nil { - JSONError(w, "Failed to encode schools response", http.StatusInternalServerError) - log.Printf("Error encoding response: %v", err) - return - } - log.Printf("Successfully served /api/schools request") -} - -func getGradesHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - // Extract the school_id query parameter - schoolID := r.URL.Query().Get("school_id") - - // 1. Input Validation: Check if the required parameter is missing - if schoolID == "" { - JSONError(w, "Missing required query parameter: school_id", http.StatusBadRequest) - return - } - - log.Printf("Received request for grades in school ID: %s", schoolID) - - // LATER: The mock data here would be filtered based on schoolID - // For now, we return the full mock list regardless of the ID. - - // LATER: connect to database, extract corresponding list and parse it - - grades := GetGradesBySchoolID(schoolID) - - // Convert to Json - if err := json.NewEncoder(w).Encode(grades); err != nil { - JSONError(w, "Failed to encode grades response", http.StatusInternalServerError) - log.Printf("Error encoding response: %v", err) - return - } - log.Printf("Successfully served /api/grades request") -} - -func getEquipmentListsHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - // Extract the required query parameters (updated) - schoolID := r.URL.Query().Get("school_id") - gradeID := r.URL.Query().Get("grade_id") - - // 1. Input Validation (updated) - if schoolID == "" || gradeID == "" { - JSONError(w, "Missing required query parameters: school_id or grade_id", http.StatusBadRequest) - return - } - - log.Printf("Received request for equipment list: School=%s, Grade=%s", schoolID, gradeID) - - // LATER: connect to database, extract corresponding list and parse it - equipment := GetEquipmentList(schoolID, gradeID) - - response := EquipmentListResponse{ - Items: equipment, - } - - if err := json.NewEncoder(w).Encode(response); err != nil { - JSONError(w, "Failed to encode equipment response", http.StatusInternalServerError) - log.Printf("Error encoding response: %v", err) - return - } - log.Printf("Successfully served /api/equipment request") -} - -// =====NEW===== -// adding handlers to login page & shopping cart -func authStatusHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("sessionid") - if err != nil { - JSONError(w, "Unauthorized", http.StatusUnauthorized) - return - } - - userID, exists := sessions[cookie.Value] - if !exists { - JSONError(w, "Unauthorized", http.StatusUnauthorized) - return - } - - for _, user := range MockUsers { - if user.UserID == userID { - err := json.NewEncoder(w).Encode(map[string]string{"userid": user.UserID, "username": user.Username}) - if err != nil { - log.Printf("Failed to encode auth status response: %v", err) - } - return - } - } - - JSONError(w, "Unauthorized", http.StatusUnauthorized) -} - -func postLoginHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - JSONError(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var credentials struct { - Username string `json:"username"` - Password string `json:"password"` - } - - if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { - JSONError(w, "Failed to decode request body", http.StatusBadRequest) - return - } - - for _, user := range MockUsers { - if user.Username == credentials.Username && user.Password == credentials.Password { - // Session generation - sessionID := generateSessionID() - sessions[sessionID] = user.UserID - - // Cookie setting - http.SetCookie(w, &http.Cookie{ - Name: "sessionid", - Value: sessionID, - Path: "/", - HttpOnly: true, - //Secure: true, // Uncomment this line if using HTTPS - //SameSite: http.SameSiteStrictMode, - }) - err := json.NewEncoder(w).Encode(map[string]string{"userid": user.UserID, "username": user.Username}) - if err != nil { - log.Printf("Failed to encode login response: %v", err) - } - return - } - } - - JSONError(w, "Incorrect username or password. Please try again.", http.StatusUnauthorized) -} - -func logoutHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("sessionid") - if err == nil { - delete(sessions, cookie.Value) - } - http.SetCookie(w, &http.Cookie{ - Name: "sessionid", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - }) - w.WriteHeader(http.StatusOK) -} - -func getPostCartHandler(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("userid") - if userID == "" { - JSONError(w, "Missing required query parameter: userid", http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - // Return existing cart (now returns []CartEntry) - cart, exists := MockCarts[userID] - if !exists { - cart = []CartEntry{} // Return empty list if no cart exists - } - err := json.NewEncoder(w).Encode(cart) - if err != nil { - log.Printf("Failed to encode cart response: %v", err) - } - - case http.MethodPost, http.MethodPut: - // Update the cart (expects []CartEntry) - var newEntries []CartEntry - if err := json.NewDecoder(r.Body).Decode(&newEntries); err != nil { - JSONError(w, "Failed to decode request body", http.StatusBadRequest) - return - } - MockCarts[userID] = newEntries - w.WriteHeader(http.StatusOK) - if _, err := fmt.Fprintf(w, "Cart updated successfully"); err != nil { - log.Printf("Failed to write cart update response: %v", err) - } - - default: - JSONError(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} +package main + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "time" +) + +var sessions = map[string]string{} // sessionID -> userID + +func generateSessionID() string { + rand.New(rand.NewSource(time.Now().UnixNano())) + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 32) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +// School structure +type School struct { + ID string `json:"id"` // Fix C: Changed single quotes to backticks (`) + Name string `json:"name"` +} + +type Grade struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Equipment struct { + ID string `json:"id"` + Name string `json:"name"` + Quantity int `json:"quantity"` +} + +type EquipmentListResponse struct { + Items []Equipment `json:"items"` +} + +func JSONError(w http.ResponseWriter, err string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + errEncode := json.NewEncoder(w).Encode(map[string]string{"error": err}) + if errEncode != nil { + log.Printf("Failed to encode JSON error response: %v", errEncode) + } +} + +// =====NEW===== +// login +type User struct { + UserID string `json:"userid"` + Username string `json:"username"` + Password string `json:"password"` +} + +func enableCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + // Allow only the frontend origin + allowed_origin := os.Getenv("CLIENT_ORIGIN") + if allowed_origin == "" { + allowed_origin = "http://localhost:3000" // default for local development + } + if origin == allowed_origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + // For production, use your real frontend URL above + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + } +} + +func main() { + // Handler for getSchools, getGrades, getEquipment + http.HandleFunc("/api/schools", enableCORS(getSchoolsHandler)) + http.HandleFunc("/api/grades", enableCORS(getGradesHandler)) + http.HandleFunc("/api/equipment", enableCORS(getEquipmentListsHandler)) + http.HandleFunc("/api/auth/status", enableCORS(authStatusHandler)) + http.HandleFunc("/api/login", enableCORS(postLoginHandler)) + http.HandleFunc("/api/logout", enableCORS(logoutHandler)) + http.HandleFunc("/api/cart", enableCORS(getPostCartHandler)) + + // Start the API Gateway server + port := "8080" // Changed port to string without colon for easier fmt use + // Using fmt.Sprintf to format the port with a colon for ListenAndServe + serverAddr := fmt.Sprintf(":%s", port) + + // Fix E: Corrected format specifier to %s + fmt.Printf("API Gateway starting on port %s\n", port) + + // Use the formatted address to listen + log.Fatal(http.ListenAndServe(serverAddr, nil)) +} + +func getSchoolsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // LATER: connect to database, extract corresponding list and parse it + schools := GetSchools() + + // Convert to Json + if err := json.NewEncoder(w).Encode(schools); err != nil { + JSONError(w, "Failed to encode schools response", http.StatusInternalServerError) + log.Printf("Error encoding response: %v", err) + return + } + log.Printf("Successfully served /api/schools request") +} + +func getGradesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Extract the school_id query parameter + schoolID := r.URL.Query().Get("school_id") + + // 1. Input Validation: Check if the required parameter is missing + if schoolID == "" { + JSONError(w, "Missing required query parameter: school_id", http.StatusBadRequest) + return + } + + log.Printf("Received request for grades in school ID: %s", schoolID) + + // LATER: The mock data here would be filtered based on schoolID + // For now, we return the full mock list regardless of the ID. + + // LATER: connect to database, extract corresponding list and parse it + + grades := GetGradesBySchoolID(schoolID) + + // Convert to Json + if err := json.NewEncoder(w).Encode(grades); err != nil { + JSONError(w, "Failed to encode grades response", http.StatusInternalServerError) + log.Printf("Error encoding response: %v", err) + return + } + log.Printf("Successfully served /api/grades request") +} + +func getEquipmentListsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Extract the required query parameters (updated) + schoolID := r.URL.Query().Get("school_id") + gradeID := r.URL.Query().Get("grade_id") + + // 1. Input Validation (updated) + if schoolID == "" || gradeID == "" { + JSONError(w, "Missing required query parameters: school_id or grade_id", http.StatusBadRequest) + return + } + + log.Printf("Received request for equipment list: School=%s, Grade=%s", schoolID, gradeID) + + // LATER: connect to database, extract corresponding list and parse it + equipment := GetEquipmentList(schoolID, gradeID) + + response := EquipmentListResponse{ + Items: equipment, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + JSONError(w, "Failed to encode equipment response", http.StatusInternalServerError) + log.Printf("Error encoding response: %v", err) + return + } + log.Printf("Successfully served /api/equipment request") +} + +// =====NEW===== +// adding handlers to login page & shopping cart +func authStatusHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("sessionid") + if err != nil { + JSONError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, exists := sessions[cookie.Value] + if !exists { + JSONError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + for _, user := range MockUsers { + if user.UserID == userID { + err := json.NewEncoder(w).Encode(map[string]string{"userid": user.UserID, "username": user.Username}) + if err != nil { + log.Printf("Failed to encode auth status response: %v", err) + } + return + } + } + + JSONError(w, "Unauthorized", http.StatusUnauthorized) +} + +func postLoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + JSONError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var credentials struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { + JSONError(w, "Failed to decode request body", http.StatusBadRequest) + return + } + + for _, user := range MockUsers { + if user.Username == credentials.Username && user.Password == credentials.Password { + // Session generation + sessionID := generateSessionID() + sessions[sessionID] = user.UserID + + // Cookie setting + http.SetCookie(w, &http.Cookie{ + Name: "sessionid", + Value: sessionID, + Path: "/", + HttpOnly: true, + //Secure: true, // Uncomment this line if using HTTPS + //SameSite: http.SameSiteStrictMode, + }) + err := json.NewEncoder(w).Encode(map[string]string{"userid": user.UserID, "username": user.Username}) + if err != nil { + log.Printf("Failed to encode login response: %v", err) + } + return + } + } + + JSONError(w, "Incorrect username or password. Please try again.", http.StatusUnauthorized) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("sessionid") + if err == nil { + delete(sessions, cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "sessionid", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + w.WriteHeader(http.StatusOK) +} + +func getPostCartHandler(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("userid") + if userID == "" { + JSONError(w, "Missing required query parameter: userid", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + // Return existing cart (now returns []CartEntry) + cart, exists := MockCarts[userID] + if !exists { + cart = []CartEntry{} // Return empty list if no cart exists + } + err := json.NewEncoder(w).Encode(cart) + if err != nil { + log.Printf("Failed to encode cart response: %v", err) + } + + case http.MethodPost, http.MethodPut: + // Update the cart (expects []CartEntry) + var newEntries []CartEntry + if err := json.NewDecoder(r.Body).Decode(&newEntries); err != nil { + JSONError(w, "Failed to decode request body", http.StatusBadRequest) + return + } + MockCarts[userID] = newEntries + w.WriteHeader(http.StatusOK) + if _, err := fmt.Fprintf(w, "Cart updated successfully"); err != nil { + log.Printf("Failed to write cart update response: %v", err) + } + + default: + JSONError(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ab39dad --- /dev/null +++ b/main_test.go @@ -0,0 +1,518 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// ========================================== +// 1. Helper Logic Tests (Session ID) +// ========================================== + +func TestSessionID_Length(t *testing.T) { + id := generateSessionID() + if len(id) != 32 { + t.Errorf("Expected length 32, got %d", len(id)) + } +} + +func TestSessionID_Uniqueness(t *testing.T) { + id1 := generateSessionID() + id2 := generateSessionID() + if id1 == id2 { + t.Error("Generated identical session IDs") + } +} + +func TestSessionID_Chars(t *testing.T) { + id := generateSessionID() + if strings.Contains(id, " ") { + t.Error("Session ID contains spaces") + } +} + +// ========================================== +// 2. Middleware & CORS Tests +// ========================================== + +// TestCORS_OriginHeader removed per request + +func TestCORS_MethodsHeader(t *testing.T) { + req, _ := http.NewRequest("OPTIONS", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(enableCORS(func(w http.ResponseWriter, r *http.Request) {})) + handler.ServeHTTP(rr, req) + + methods := rr.Header().Get("Access-Control-Allow-Methods") + if !strings.Contains(methods, "POST") { + t.Error("CORS methods should include POST") + } +} + +func TestCORS_OptionsStatus(t *testing.T) { + req, _ := http.NewRequest("OPTIONS", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(enableCORS(func(w http.ResponseWriter, r *http.Request) {})) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("OPTIONS should return 200, got %v", rr.Code) + } +} + +// ========================================== +// 3. Schools API Tests +// ========================================== + +func TestSchools_Status(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getSchoolsHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestSchools_IsJSON(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getSchoolsHandler) + handler.ServeHTTP(rr, req) + if rr.Header().Get("Content-Type") != "application/json" { + t.Error("Expected application/json content type") + } +} + +func TestSchools_NotEmpty(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getSchoolsHandler) + handler.ServeHTTP(rr, req) + if rr.Body.Len() == 0 { + t.Error("Response body is empty") + } +} + +func TestSchools_ContainsBenGurion(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/schools", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getSchoolsHandler) + handler.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "Ben Gurion") { + t.Error("Expected 'Ben Gurion' in response") + } +} + +// ========================================== +// 4. Grades API Tests +// ========================================== + +func TestGrades_ValidRequest(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/grades?school_id=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getGradesHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestGrades_MissingParams(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/grades", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getGradesHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestGrades_EmptyParam(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/grades?school_id=", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getGradesHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for empty param, got %v", rr.Code) + } +} + +func TestGrades_ResponseList(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/grades?school_id=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getGradesHandler) + handler.ServeHTTP(rr, req) + var grades []Grade + if err := json.NewDecoder(rr.Body).Decode(&grades); err != nil { + t.Error("Failed to decode grades JSON") + } + if len(grades) == 0 { + t.Error("Returned empty grades list") + } +} + +func TestGrades_Contains12thGrade(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/grades?school_id=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getGradesHandler) + handler.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "12th Grade") { + t.Error("Expected '12th Grade' in response") + } +} + +// ========================================== +// 5. Equipment API Tests +// ========================================== + +func TestEquipment_Specific(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/equipment?school_id=1&grade_id=9", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getEquipmentListsHandler) + handler.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "Notebook (Ruled)") { + t.Error("Missing specific item for school 1 grade 9") + } +} + +func TestEquipment_Default(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/equipment?school_id=99&grade_id=99", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getEquipmentListsHandler) + handler.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "Binder") { + t.Error("Missing default item") + } +} + +func TestEquipment_MissingSchool(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/equipment?grade_id=9", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getEquipmentListsHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestEquipment_MissingGrade(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/equipment?school_id=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getEquipmentListsHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestEquipment_Structure(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/equipment?school_id=1&grade_id=9", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getEquipmentListsHandler) + handler.ServeHTTP(rr, req) + var resp EquipmentListResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Error("Invalid JSON structure") + } + if len(resp.Items) == 0 { + t.Error("Items list is empty") + } +} + +// ========================================== +// 6. Login Tests +// ========================================== + +func TestLogin_ValidUser(t *testing.T) { + body := `{"username": "avner", "password": "2004"}` + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Login failed for valid user, got %v", rr.Code) + } +} + +func TestLogin_ValidAdmin(t *testing.T) { + body := `{"username": "admin", "password": "1234"}` + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Login failed for admin, got %v", rr.Code) + } +} + +func TestLogin_WrongPassword(t *testing.T) { + body := `{"username": "avner", "password": "wrong"}` + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %v", rr.Code) + } +} + +func TestLogin_UnknownUser(t *testing.T) { + body := `{"username": "ghost", "password": "boo"}` + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %v", rr.Code) + } +} + +func TestLogin_EmptyBody(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString("")) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for empty body, got %v", rr.Code) + } +} + +func TestLogin_MalformedJSON(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"user":...`)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for bad JSON, got %v", rr.Code) + } +} + +func TestLogin_SetsCookie(t *testing.T) { + body := `{"username": "avner", "password": "2004"}` + req, _ := http.NewRequest("POST", "/api/login", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + + found := false + for _, c := range rr.Result().Cookies() { + if c.Name == "sessionid" { + found = true + delete(sessions, c.Value) // Cleanup + } + } + if !found { + t.Error("Session cookie not set on login") + } +} + +func TestLogin_WrongMethod(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/login", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(postLoginHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected 405 Method Not Allowed, got %v", rr.Code) + } +} + +// ========================================== +// 7. Auth Status Tests +// ========================================== + +func TestAuthStatus_NoCookie(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/auth/status", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(authStatusHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %v", rr.Code) + } +} + +func TestAuthStatus_InvalidCookie(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: "sessionid", Value: "fake-123"}) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(authStatusHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %v", rr.Code) + } +} + +func TestAuthStatus_ValidSession(t *testing.T) { + sid := "test-session-auth" + sessions[sid] = "1" + defer delete(sessions, sid) + + req, _ := http.NewRequest("GET", "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: "sessionid", Value: sid}) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(authStatusHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestAuthStatus_ReturnsUsername(t *testing.T) { + sid := "test-session-name" + sessions[sid] = "1" + defer delete(sessions, sid) + + req, _ := http.NewRequest("GET", "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: "sessionid", Value: sid}) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(authStatusHandler) + handler.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "avner") { + t.Error("Response did not contain username") + } +} + +// ========================================== +// 8. Logout Tests +// ========================================== + +func TestLogout_Status(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/logout", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(logoutHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestLogout_ClearsCookie(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/logout", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(logoutHandler) + handler.ServeHTTP(rr, req) + + cleared := false + for _, c := range rr.Result().Cookies() { + if c.Name == "sessionid" && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Error("Session cookie not cleared (MaxAge < 0)") + } +} + +func TestLogout_RemovesFromMap(t *testing.T) { + sid := "test-logout-map" + sessions[sid] = "1" + req, _ := http.NewRequest("POST", "/api/logout", nil) + req.AddCookie(&http.Cookie{Name: "sessionid", Value: sid}) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(logoutHandler) + handler.ServeHTTP(rr, req) + + if _, exists := sessions[sid]; exists { + t.Error("Session ID still in map after logout") + } +} + +// ========================================== +// 9. Cart Tests +// ========================================== + +func TestCart_Get_NoUser(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/cart", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestCart_Get_Valid(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/cart?userid=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestCart_Get_ReturnArray(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/cart?userid=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if !strings.HasPrefix(strings.TrimSpace(rr.Body.String()), "[") { + t.Error("Expected JSON array") + } +} + +func TestCart_Post_Valid(t *testing.T) { + body := `[{"id":"cart-1", "items":[]}]` + req, _ := http.NewRequest("POST", "/api/cart?userid=1", bytes.NewBufferString(body)) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %v", rr.Code) + } +} + +func TestCart_Post_NoUser(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/cart", bytes.NewBufferString("[]")) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestCart_Post_BadJSON(t *testing.T) { + req, _ := http.NewRequest("POST", "/api/cart?userid=1", bytes.NewBufferString("[{...")) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %v", rr.Code) + } +} + +func TestCart_Delete_Method(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/api/cart?userid=1", nil) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(getPostCartHandler) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected 405, got %v", rr.Code) + } +} + +// ========================================== +// FINAL SUMMARY +// ========================================== + +func TestMain(m *testing.M) { + code := m.Run() + + fmt.Println("\n--------------------------------------------------") + if code == 0 { + fmt.Println("\033[32m[SUCCESS] ALL TESTS PASSED! \033[0m") + fmt.Println("\033[32m(See individual checks above for details)\033[0m") + } else { + fmt.Println("\033[31m[FAILURE] SOME TESTS FAILED! \033[0m") + } + fmt.Println("--------------------------------------------------") + + os.Exit(code) +} \ No newline at end of file diff --git a/mock_db.go b/mock_db.go index e4eefa7..61fc52a 100644 --- a/mock_db.go +++ b/mock_db.go @@ -1,122 +1,122 @@ -package main - -import "fmt" - -// Schools data (all possible schools) -var MockSchools = []School{ - {"1", "Ben Gurion"}, - {"2", "ORT"}, - {"3", "Brener"}, - {"4", "Herzel"}, - {"5", "Begin"}, -} - -// Grades data (9-12) -var MockGrades = []Grade{ - {"9", "9th Grade"}, - {"10", "10th Grade"}, - {"11", "11th Grade"}, - {"12", "12th Grade"}, -} - -// Equipment data (This is complex and needs filtering logic) -// To simulate different lists, we'll use a map keyed by a combination string: SchoolID-GradeID -var MockEquipmentLists = map[string][]Equipment{ - // Example: List for Ben Gurion (1), 9th Grade (9) - "1-9": { - {"101", "Notebook (Ruled)", 5}, - {"102", "Pencil", 12}, - {"103", "Math Textbook - Algebra I", 1}, - }, - // Example: List for ORT (2), 12th Grade (12) - "2-12": { - {"201", "Laptop (Required)", 1}, - {"202", "Engineering Calculator", 1}, - {"203", "Physics Textbook - Advanced", 1}, - }, - // Default list for all other combinations - "default": { - {"901", "Binder (3-ring)", 2}, - {"902", "Highlighters", 4}, - }, -} - -// --- Mock DB Functions --- - -// Get all schools. Doesn't need filtering. -func GetSchools() []School { - return MockSchools -} - -// Get grades for a specific school. Since grades are the same for all schools, -// this function only validates the schoolID exists. -func GetGradesBySchoolID(schoolID string) []Grade { - // In a real DB, you'd filter grades by school. Here, we just ensure the school is valid. - for _, s := range MockSchools { - if s.ID == schoolID { - return MockGrades // School is valid, return all grades - } - } - return nil // School not found -} - -// Get equipment list based on selection. -func GetEquipmentList(schoolID, gradeID string) []Equipment { - key := fmt.Sprintf("%s-%s", schoolID, gradeID) - - // Attempt to find a specific list - if list, ok := MockEquipmentLists[key]; ok { - return list - } - - // Return a default list if no specific list is defined - return MockEquipmentLists["default"] -} - -// ======NEW====== -// data for login page -var MockUsers = []User{ - {UserID: "1", Username: "avner", Password: "2004"}, - {UserID: "2", Username: "admin", Password: "1234"}, - {UserID: "3", Username: "noam", Password: "1919"}, -} - -// CartEntry structure for frontend compatibility -// (matches what the frontend expects) -type CartEntry struct { - ID string `json:"id"` - Timestamp int64 `json:"timestamp"` - School School `json:"school"` - Grade Grade `json:"grade"` - Items []Equipment `json:"items"` -} - -// data for cart -var MockCarts = map[string][]CartEntry{ - "1": { - { - ID: "cart-1", - Timestamp: 1700000000, - School: School{ID: "1", Name: "Ben Gurion"}, - Grade: Grade{ID: "9", Name: "9th Grade"}, - Items: []Equipment{ - {ID: "101", Name: "Notebook", Quantity: 2}, - {ID: "102", Name: "Engineering Calculator", Quantity: 1}, - {ID: "103", Name: "Physics Textbook - Advanced", Quantity: 1}, - }, - }, - }, - "2": { - { - ID: "cart-2", - Timestamp: 1700000001, - School: School{ID: "2", Name: "ORT"}, - Grade: Grade{ID: "12", Name: "12th Grade"}, - Items: []Equipment{ - {ID: "201", Name: "Laptop (Required)", Quantity: 1}, - {ID: "202", Name: "Engineering Calculator", Quantity: 1}, - {ID: "203", Name: "Physics Textbook - Beginners", Quantity: 1}, - }, - }, - }, -} +package main + +import "fmt" + +// Schools data (all possible schools) +var MockSchools = []School{ + {"1", "Ben Gurion"}, + {"2", "ORT"}, + {"3", "Brener"}, + {"4", "Herzel"}, + {"5", "Begin"}, +} + +// Grades data (9-12) +var MockGrades = []Grade{ + {"9", "9th Grade"}, + {"10", "10th Grade"}, + {"11", "11th Grade"}, + {"12", "12th Grade"}, +} + +// Equipment data (This is complex and needs filtering logic) +// To simulate different lists, we'll use a map keyed by a combination string: SchoolID-GradeID +var MockEquipmentLists = map[string][]Equipment{ + // Example: List for Ben Gurion (1), 9th Grade (9) + "1-9": { + {"101", "Notebook (Ruled)", 5}, + {"102", "Pencil", 12}, + {"103", "Math Textbook - Algebra I", 1}, + }, + // Example: List for ORT (2), 12th Grade (12) + "2-12": { + {"201", "Laptop (Required)", 1}, + {"202", "Engineering Calculator", 1}, + {"203", "Physics Textbook - Advanced", 1}, + }, + // Default list for all other combinations + "default": { + {"901", "Binder (3-ring)", 2}, + {"902", "Highlighters", 4}, + }, +} + +// --- Mock DB Functions --- + +// Get all schools. Doesn't need filtering. +func GetSchools() []School { + return MockSchools +} + +// Get grades for a specific school. Since grades are the same for all schools, +// this function only validates the schoolID exists. +func GetGradesBySchoolID(schoolID string) []Grade { + // In a real DB, you'd filter grades by school. Here, we just ensure the school is valid. + for _, s := range MockSchools { + if s.ID == schoolID { + return MockGrades // School is valid, return all grades + } + } + return nil // School not found +} + +// Get equipment list based on selection. +func GetEquipmentList(schoolID, gradeID string) []Equipment { + key := fmt.Sprintf("%s-%s", schoolID, gradeID) + + // Attempt to find a specific list + if list, ok := MockEquipmentLists[key]; ok { + return list + } + + // Return a default list if no specific list is defined + return MockEquipmentLists["default"] +} + +// ======NEW====== +// data for login page +var MockUsers = []User{ + {UserID: "1", Username: "avner", Password: "2004"}, + {UserID: "2", Username: "admin", Password: "1234"}, + {UserID: "3", Username: "noam", Password: "1919"}, +} + +// CartEntry structure for frontend compatibility +// (matches what the frontend expects) +type CartEntry struct { + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + School School `json:"school"` + Grade Grade `json:"grade"` + Items []Equipment `json:"items"` +} + +// data for cart +var MockCarts = map[string][]CartEntry{ + "1": { + { + ID: "cart-1", + Timestamp: 1700000000, + School: School{ID: "1", Name: "Ben Gurion"}, + Grade: Grade{ID: "9", Name: "9th Grade"}, + Items: []Equipment{ + {ID: "101", Name: "Notebook", Quantity: 2}, + {ID: "102", Name: "Engineering Calculator", Quantity: 1}, + {ID: "103", Name: "Physics Textbook - Advanced", Quantity: 1}, + }, + }, + }, + "2": { + { + ID: "cart-2", + Timestamp: 1700000001, + School: School{ID: "2", Name: "ORT"}, + Grade: Grade{ID: "12", Name: "12th Grade"}, + Items: []Equipment{ + {ID: "201", Name: "Laptop (Required)", Quantity: 1}, + {ID: "202", Name: "Engineering Calculator", Quantity: 1}, + {ID: "203", Name: "Physics Textbook - Beginners", Quantity: 1}, + }, + }, + }, +}