Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
with:
go-version: '1.25.1'

- 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion api/middleware/rate_limit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions api/models/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
81 changes: 81 additions & 0 deletions api/routes/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
131 changes: 131 additions & 0 deletions api/service/llm_service_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down