From 0d5910bf98d4e512b2c3ba7799750f7517ad813d Mon Sep 17 00:00:00 2001 From: DeleMike Date: Tue, 28 Oct 2025 11:06:11 +0100 Subject: [PATCH 1/4] add simple tests --- api/middleware/rate_limit.go | 2 +- api/service/llm_service_test.go | 131 ++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 4 + 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 api/service/llm_service_test.go diff --git a/api/middleware/rate_limit.go b/api/middleware/rate_limit.go index bea9acc..0c8cd7c 100644 --- a/api/middleware/rate_limit.go +++ b/api/middleware/rate_limit.go @@ -40,7 +40,7 @@ func RateLimit() gin.HandlerFunc { // check limit if count >= rateLimitPerDay { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ - "error": "You have exceeded your daily limit of 5 requests.", + "error": "You have exceeded your daily limit of 10 requests.", "message": "Please try again in 24 hours.", }) return diff --git a/api/service/llm_service_test.go b/api/service/llm_service_test.go new file mode 100644 index 0000000..4b1a576 --- /dev/null +++ b/api/service/llm_service_test.go @@ -0,0 +1,131 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseQuestions tests the question parsing logic with various inputs +func TestParseQuestions(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "numbered questions with dots", + input: "1. What is your experience with Go?\n2. How do you handle errors?\n3. Explain interfaces?", + expected: []string{ + "What is your experience with Go?", + "How do you handle errors?", + "Explain interfaces?", + }, + }, + { + name: "questions with asterisks", + input: "* What is your experience?\n* How do you test?", + expected: []string{ + "What is your experience?", + "How do you test?", + }, + }, + { + name: "questions with bullets", + input: "• First question?\n• Second question?", + expected: []string{ + "First question?", + "Second question?", + }, + }, + { + name: "empty input", + input: "", + expected: []string{}, + }, + { + name: "whitespace only", + input: " \n\n \n", + expected: []string{}, + }, + { + name: "mixed formatting", + input: "1) What is Go?\n2) Explain testing?\n- What about mocking?", + expected: []string{ + "What is Go?", + "Explain testing?", + "What about mocking?", + }, + }, + { + name: "with header line (should skip)", + input: "Here are your questions:\n1. What is your name?\n2. Where do you work?", + expected: []string{ + "What is your name?", + "Where do you work?", + }, + }, + { + name: "questions without numbering", + input: "What is your experience?\nHow do you handle errors?", + expected: []string{ + "What is your experience?", + "How do you handle errors?", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseQuestions(tt.input) + + assert.Equal(t, len(tt.expected), len(result), + "Expected %d questions but got %d", len(tt.expected), len(result)) + + for i, expected := range tt.expected { + if i < len(result) { + assert.Equal(t, expected, result[i], + "Question %d mismatch", i) + } + } + }) + } +} + +// TestFloat32Ptr tests the helper function +func TestFloat32Ptr(t *testing.T) { + tests := []struct { + name string + input float32 + }{ + {"zero", 0.0}, + {"positive", 0.5}, + {"negative", -1.5}, + {"one", 1.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := float32Ptr(tt.input) + + require.NotNil(t, result, "Result should not be nil") + assert.Equal(t, tt.input, *result, "Value should match input") + }) + } +} + +// Benchmark for parseQuestions to ensure performance +func BenchmarkParseQuestions(b *testing.B) { + input := `Here are your interview questions: +1. What is your experience with Go? +2. How do you handle concurrent programming? +3. Explain goroutines and channels? +4. What testing frameworks have you used? +5. How do you ensure code quality?` + + b.ResetTimer() + for i := 0; i < b.N; i++ { + parseQuestions(input) + } +} diff --git a/go.mod b/go.mod index 595b400..883abbb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.1 require ( github.com/gin-gonic/gin v1.11.0 + github.com/redis/go-redis/v9 v9.14.1 github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 google.golang.org/genai v1.31.0 ) @@ -17,6 +19,7 @@ require ( github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -42,9 +45,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect - github.com/redis/go-redis/v9 v9.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -71,4 +74,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index da6e17b..4f2a80b 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= From 633e3d9c3eb365e926a333b170ebd16dcf412e3e Mon Sep 17 00:00:00 2001 From: DeleMike Date: Tue, 28 Oct 2025 11:25:07 +0100 Subject: [PATCH 2/4] added some tests --- api/models/types_test.go | 28 +++++++++++++ api/routes/helpers_test.go | 81 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 api/models/types_test.go create mode 100644 api/routes/helpers_test.go diff --git a/api/models/types_test.go b/api/models/types_test.go new file mode 100644 index 0000000..91f4e39 --- /dev/null +++ b/api/models/types_test.go @@ -0,0 +1,28 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnswerPairJSONMarshalling(t *testing.T) { + pair := AnswerPair{ + Question: "What is Go?", + Answer: "A programming language by Google.", + } + + // Marshal to JSON + data, err := json.Marshal(pair) + assert.NoError(t, err) + + expectedJSON := `{"question":"What is Go?","answer":"A programming language by Google."}` + assert.JSONEq(t, expectedJSON, string(data)) + + var decoded AnswerPair + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + + assert.Equal(t, pair, decoded) +} diff --git a/api/routes/helpers_test.go b/api/routes/helpers_test.go new file mode 100644 index 0000000..5b7bf23 --- /dev/null +++ b/api/routes/helpers_test.go @@ -0,0 +1,81 @@ +package routes + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DeleMike/AIpply/api/apierrors" + "github.com/DeleMike/AIpply/api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "google.golang.org/genai" +) + +func TestHandleLLMRequestPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("invalid JSON returns 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{invalid-json")) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + handleLLMRequest(c, &struct{}{}, func(ctx context.Context, _ *struct{}) (any, error) { + return nil, nil + }) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), apierrors.ErrInvalidRequest) + }) + + t.Run("LLM client not initialized returns 500", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Ensure no client + service.LLMClient = nil + + handleLLMRequest(c, &struct{}{}, func(ctx context.Context, _ *struct{}) (any, error) { + return nil, nil + }) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), apierrors.ErrLLMNotInitialized) + }) + t.Run("handler error returns 500", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Mock client to pass readiness check + service.LLMClient = &genai.Client{} + + handleLLMRequest(c, &struct{}{}, func(ctx context.Context, _ *struct{}) (any, error) { + return nil, errors.New("failed") + }) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), apierrors.ErrWeCouldNotProcessRequest) + }) + t.Run("success returns 200", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + service.LLMClient = &genai.Client{} + + handleLLMRequest(c, &struct{}{}, func(ctx context.Context, _ *struct{}) (any, error) { + return gin.H{"message": "ok"}, nil + }) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "ok") + }) +} From 77c36a575ec2aede37e750bbe4dab935e2c5be42 Mon Sep 17 00:00:00 2001 From: DeleMike Date: Tue, 28 Oct 2025 13:58:41 +0100 Subject: [PATCH 3/4] add workflow --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..21cb728 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +name: Go +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + + - name: Build + run: go build -v ./... + + - name: Test with Coverage + run: go test -v -coverprofile=coverage.out ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.out + fail_ci_if_error: true \ No newline at end of file diff --git a/README.md b/README.md index e435473..095bab5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/DeleMike/AIpply?style=for-the-badge)](https://goreportcard.com/report/github.com/DeleMike/AIpply) [![Go Version](https://img.shields.io/github/go-mod/go-version/DeleMike/AIpply?style=for-the-badge&logo=go)](https://golang.org) +[![Go Tests](https://img.shields.io/github/actions/workflow/status/DeleMike/AIpply/go.yml?branch=main&label=Tests&style=for-the-badge&logo=go)](https://github.com/DeleMike/AIpply/actions) +[![codecov](https://img.shields.io/codecov/c/github/DeleMike/AIpply?style=for-the-badge&logo=codecov&token=YOUR_CODECOV_TOKEN)](https://codecov.io/gh/DeleMike/AIpply) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) This repository contains the Go backend service for **AIpply**. It functions as a REST API that leverages the Google Gemini AI to generate tailored CVs, cover letters, and interview questions based on a job description and user-provided answers. From 446f9599b7da1485279be3582f862b08f1281050 Mon Sep 17 00:00:00 2001 From: DeleMike Date: Tue, 28 Oct 2025 14:21:37 +0100 Subject: [PATCH 4/4] improve workflow --- .github/workflows/go.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 21cb728..6ec98f5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,6 +13,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 + with: + go-version: '1.25.1' - name: Build run: go build -v ./...