Merge pull request #33 from ForFarmTeam/feature-test

Merge Feature Test to Main
This commit is contained in:
Natthapol Sermsaran 2025-04-04 23:28:39 +07:00 committed by GitHub
commit af1715a71d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1575 additions and 1129 deletions

View File

@ -0,0 +1,21 @@
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
)
func main() {
key := make([]byte, 64)
_, err := rand.Read(key)
if err != nil {
fmt.Println("Error generating key:", err)
os.Exit(1)
}
secret := base64.StdEncoding.EncodeToString(key)
fmt.Println("Generated JWT Secret (add to your .env as JWT_SECRET_KEY):")
fmt.Println(secret)
}

View File

@ -18,6 +18,7 @@ require (
github.com/rabbitmq/amqp091-go v1.10.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.36.0
google.golang.org/api v0.186.0
)
@ -30,6 +31,7 @@ require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-chi/httprate v0.15.0 // indirect
@ -51,6 +53,7 @@ require (
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@ -59,6 +62,7 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opencensus.io v0.24.0 // indirect

View File

@ -160,6 +160,8 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@ -0,0 +1,239 @@
package api
import (
"context"
"github.com/danielgtaylor/huma/v2"
"github.com/forfarm/backend/internal/utilities"
"golang.org/x/crypto/bcrypt"
"log/slog"
"os"
"testing"
"github.com/forfarm/backend/internal/domain"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockUserRepository struct {
mock.Mock
}
type EmailPasswordInput struct {
Email string `json:"email" example:"Email address of the user"`
Password string `json:"password" example:"Password of the user"`
}
func (m *MockUserRepository) GetByID(ctx context.Context, id int64) (domain.User, error) {
args := m.Called(ctx, id)
return args.Get(0).(domain.User), args.Error(1)
}
func (m *MockUserRepository) GetByUUID(ctx context.Context, uuid string) (domain.User, error) {
args := m.Called(ctx, uuid)
return args.Get(0).(domain.User), args.Error(1)
}
func (m *MockUserRepository) GetByUsername(ctx context.Context, username string) (domain.User, error) {
args := m.Called(ctx, username)
return args.Get(0).(domain.User), args.Error(1)
}
func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (domain.User, error) {
args := m.Called(ctx, email)
return args.Get(0).(domain.User), args.Error(1)
}
func (m *MockUserRepository) CreateOrUpdate(ctx context.Context, u *domain.User) error {
args := m.Called(ctx, u)
return args.Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func TestRegisterHandler(t *testing.T) {
var tests = []struct {
name string
input RegisterInput
mockSetup func(*MockUserRepository)
expectedError error
}{
{
name: "successful registration",
input: RegisterInput{
Body: EmailPasswordInput{
Email: "test@example.com",
Password: "ValidPass123!",
},
},
mockSetup: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "test@example.com").Return(domain.User{}, domain.ErrNotFound)
m.On("CreateOrUpdate", mock.Anything, mock.AnythingOfType("*domain.User")).Return(nil)
},
expectedError: nil,
},
{
name: "existing email",
input: RegisterInput{
Body: struct {
Email string `json:"email" example:"Email address of the user"`
Password string `json:"password" example:"Password of the user"`
}(struct {
Email string `json:"email"`
Password string `json:"password"`
}{
Email: "existing@example.com",
Password: "ValidPass123!",
}),
},
mockSetup: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "existing@example.com").Return(domain.User{
Email: "existing@example.com",
}, nil)
},
expectedError: huma.Error409Conflict("User with this email already exists"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := &MockUserRepository{}
if tt.mockSetup != nil {
tt.mockSetup(mockRepo)
}
api := &api{
userRepo: mockRepo,
logger: nil,
}
_, err := api.registerHandler(context.Background(), &tt.input)
if tt.expectedError == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.expectedError.Error())
}
mockRepo.AssertExpectations(t)
})
}
}
func TestLoginHandler(t *testing.T) {
correctPassword := "ValidPass123!"
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(correctPassword), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("Failed to generate bcrypt hash: %v", err)
}
userUUID := uuid.New().String()
testUser := domain.User{
UUID: userUUID,
Email: "test@example.com",
Password: string(hashedPassword),
IsActive: true,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelError,
}))
tests := []struct {
name string
input LoginInput
mockSetup func(*MockUserRepository)
expectedError error
}{
{
name: "successful login",
input: LoginInput{
Body: EmailPasswordInput{
Email: "test@example.com",
Password: correctPassword,
},
},
mockSetup: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "test@example.com").Return(testUser, nil)
},
expectedError: nil,
},
{
name: "invalid credentials",
input: LoginInput{
Body: EmailPasswordInput{
Email: "test@example.com",
Password: "wrongpassword",
},
},
mockSetup: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "test@example.com").Return(testUser, nil)
},
expectedError: huma.Error401Unauthorized("Invalid email or password"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := &MockUserRepository{}
if tt.mockSetup != nil {
tt.mockSetup(mockRepo)
}
api := &api{
userRepo: mockRepo,
logger: logger,
}
_, err := api.loginHandler(context.Background(), &tt.input)
if tt.expectedError == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.expectedError.Error())
}
mockRepo.AssertExpectations(t)
})
}
}
func TestLoginHandler_TokenGeneration(t *testing.T) {
userUUID := uuid.New().String()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("ValidPass123!"), bcrypt.DefaultCost)
testUser := domain.User{
UUID: userUUID,
Email: "test@example.com",
Password: string(hashedPassword),
IsActive: true,
}
mockRepo := &MockUserRepository{}
mockRepo.On("GetByEmail", mock.Anything, "test@example.com").Return(testUser, nil)
api := &api{
userRepo: mockRepo,
logger: nil,
}
input := &LoginInput{
Body: EmailPasswordInput{
Email: "test@example.com",
Password: "ValidPass123!",
},
}
output, err := api.loginHandler(context.Background(), input)
assert.NoError(t, err)
assert.NotEmpty(t, output.Body.Token)
err = utilities.VerifyJwtToken(output.Body.Token)
assert.NoError(t, err)
extractedUUID, err := utilities.ExtractUUIDFromToken(output.Body.Token)
assert.NoError(t, err)
assert.Equal(t, userUUID, extractedUUID)
}

View File

@ -0,0 +1,237 @@
package repository_test
import (
"context"
"errors"
"strconv"
"testing"
"time"
"github.com/forfarm/backend/internal/domain"
"github.com/forfarm/backend/internal/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockConnection is a mock implementation of the repository.Connection interface.
type MockConnection struct {
mock.Mock
}
func (m *MockConnection) Exec(ctx context.Context, query string, args ...interface{}) (pgconn.CommandTag, error) {
callArgs := []interface{}{ctx, query}
callArgs = append(callArgs, args...)
ret := m.Called(callArgs...)
return ret.Get(0).(pgconn.CommandTag), ret.Error(1)
}
func (m *MockConnection) Query(ctx context.Context, query string, args ...interface{}) (pgx.Rows, error) {
callArgs := []interface{}{ctx, query}
callArgs = append(callArgs, args...)
ret := m.Called(callArgs...)
rows, _ := ret.Get(0).(pgx.Rows)
return rows, ret.Error(1)
}
func (m *MockConnection) QueryRow(ctx context.Context, query string, args ...interface{}) pgx.Row {
callArgs := []interface{}{ctx, query}
callArgs = append(callArgs, args...)
ret := m.Called(callArgs...)
return ret.Get(0).(pgx.Row)
}
func (m *MockConnection) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) {
ret := m.Called(ctx, txOptions)
return ret.Get(0).(pgx.Tx), ret.Error(1)
}
// MockRows is a mock implementation of pgx.Rows
type MockRows struct {
mock.Mock
currentIndex int
data []map[string]interface{}
columns []string
err error
}
func (m *MockRows) Next() bool {
m.Called()
if m.err != nil {
return false
}
m.currentIndex++
return m.currentIndex <= len(m.data)
}
func (m *MockRows) Scan(dest ...interface{}) error {
args := m.Called(dest...)
if args.Error(0) != nil {
return args.Error(0)
}
if m.currentIndex > len(m.data) || m.currentIndex == 0 {
return errors.New("scan called out of bounds")
}
currentRow := m.data[m.currentIndex-1]
for i, d := range dest {
colName := strconv.Itoa(i)
if len(m.columns) > i {
colName = m.columns[i]
}
if val, ok := currentRow[colName]; ok {
switch ptr := d.(type) {
case *string:
*ptr = val.(string)
case *int:
*ptr = val.(int)
case *float64:
*ptr = val.(float64)
case *time.Time:
*ptr = val.(time.Time)
}
}
}
return nil
}
func (m *MockRows) Close() {
m.Called()
}
func (m *MockRows) Err() error {
args := m.Called()
if args.Error(0) != nil {
return args.Error(0)
}
return m.err
}
func (m *MockRows) CommandTag() pgconn.CommandTag {
return pgconn.CommandTag{}
}
func (m *MockRows) Conn() *pgx.Conn {
return nil
}
func (m *MockRows) FieldDescriptions() []pgconn.FieldDescription {
return nil
}
func (m *MockRows) RawValues() [][]byte {
return nil
}
func (m *MockRows) Values() ([]interface{}, error) {
return nil, nil
}
// MockEventPublisher is a mock implementation of the domain.EventPublisher interface.
type MockEventPublisher struct {
mock.Mock
}
func (m *MockEventPublisher) Publish(ctx context.Context, event domain.Event) error {
args := m.Called(ctx, event)
return args.Error(0)
}
// TestGetByID tests the GetByID function of the inventory repository.
func TestGetByID(t *testing.T) {
mockConn := new(MockConnection)
mockRows := new(MockRows)
inventoryRepo := repository.NewPostgresInventory(mockConn, nil, nil)
testID := uuid.New().String()
testUserID := uuid.New().String()
columns := []string{"id", "user_id", "name", "category_id", "quantity", "unit_id", "date_added", "status_id", "created_at", "updated_at", "category_name", "status_name", "unit_name"}
t.Run("success", func(t *testing.T) {
// Test: Successful retrieval of an inventory item by ID.
mockRows.On("Next").Return(true).Once()
mockRows.On("Scan", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
mockRows.On("Close").Return().Once()
mockRows.On("Err").Return(nil).Once()
mockRows.On("CommandTag").Return(pgconn.CommandTag{}).Once()
mockRows.On("FieldDescriptions").Return([]pgconn.FieldDescription{}).Once()
mockRows.On("RawValues").Return([][]byte{}).Once()
mockRows.On("Values").Return([]interface{}{}, nil).Once()
mockConn.On("Query", mock.Anything, mock.AnythingOfType("string"), testID, testUserID).Return(mockRows, nil).Once()
mockRows.data = []map[string]interface{}{
{
"id": testID,
"user_id": testUserID,
"name": "Test Item",
"category_id": 1,
"quantity": 10.5,
"unit_id": 1,
"date_added": time.Now(),
"status_id": 1,
"created_at": time.Now(),
"updated_at": time.Now(),
"category_name": "Category Name",
"status_name": "Status Name",
"unit_name": "Unit Name",
},
}
mockRows.columns = columns
item, err := inventoryRepo.GetByID(context.Background(), testID, testUserID)
assert.NoError(t, err)
assert.Equal(t, testID, item.ID)
mockConn.AssertExpectations(t)
})
t.Run("not found", func(t *testing.T) {
// Test: Item not found scenario.
mockRows.On("Next").Return(false).Once()
mockRows.On("Close").Return().Once()
mockRows.On("Err").Return(nil).Once()
mockRows.On("CommandTag").Return(pgconn.CommandTag{}).Once()
mockRows.On("FieldDescriptions").Return([]pgconn.FieldDescription{}).Once()
mockRows.On("RawValues").Return([][]byte{}).Once()
mockRows.On("Values").Return([]interface{}{}, nil).Once()
mockConn.On("Query", mock.Anything, mock.AnythingOfType("string"), testID, testUserID).Return(mockRows, nil).Once()
_, err := inventoryRepo.GetByID(context.Background(), testID, testUserID)
assert.ErrorIs(t, err, domain.ErrNotFound)
mockConn.AssertExpectations(t)
})
t.Run("query error", func(t *testing.T) {
// Test: Database query returns an error.
mockConn.On("Query", mock.Anything, mock.AnythingOfType("string"), testID, testUserID).Return(nil, errors.New("database error")).Once()
_, err := inventoryRepo.GetByID(context.Background(), testID, testUserID)
assert.Error(t, err)
mockConn.AssertExpectations(t)
})
t.Run("scan error", func(t *testing.T) {
// Test: Error during row scanning.
mockRows.On("Next").Return(true).Once()
mockRows.On("Scan", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("scan error")).Once()
mockRows.On("Close").Return().Once()
mockRows.On("Err").Return(nil).Once()
mockRows.On("CommandTag").Return(pgconn.CommandTag{}).Once()
mockRows.On("FieldDescriptions").Return([]pgconn.FieldDescription{}).Once()
mockRows.On("RawValues").Return([][]byte{}).Once()
mockRows.On("Values").Return([]interface{}{}, nil).Once()
mockConn.On("Query", mock.Anything, mock.AnythingOfType("string"), testID, testUserID).Return(mockRows, nil).Once()
_, err := inventoryRepo.GetByID(context.Background(), testID, testUserID)
assert.Error(t, err)
mockConn.AssertExpectations(t)
})
}

View File

@ -0,0 +1,44 @@
package utilities
import (
"github.com/golang-jwt/jwt/v5"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestJWTTokenCreationAndVerification(t *testing.T) {
testUUID := "123e4567-e89b-12d3-a456-426614174000"
token, err := CreateJwtToken(testUUID)
assert.NoError(t, err)
assert.NotEmpty(t, token)
err = VerifyJwtToken(token)
assert.NoError(t, err)
uuid, err := ExtractUUIDFromToken(token)
assert.NoError(t, err)
assert.Equal(t, testUUID, uuid)
}
func TestExpiredJWTToken(t *testing.T) {
oldKey := defaultSecretKey
defaultSecretKey = []byte("test-secret-key-1234567890-1234567890")
defer func() { defaultSecretKey = oldKey }()
testUUID := "123e4567-e89b-12d3-a456-426614174000"
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"uuid": testUUID,
"exp": time.Now().Add(-time.Hour).Unix(),
})
tokenString, err := token.SignedString(defaultSecretKey)
assert.NoError(t, err)
err = VerifyJwtToken(tokenString)
assert.Error(t, err)
assert.Contains(t, err.Error(), "token is expired")
}

97
backend/loadtest/main.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
type Metrics struct {
mu sync.Mutex
successes int
failures int
times []time.Duration
}
func (m *Metrics) AddResult(success bool, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
if success {
m.successes++
} else {
m.failures++
}
m.times = append(m.times, duration)
}
func (m *Metrics) PrintSummary(total int) {
var totalTime time.Duration
var min, max time.Duration
if len(m.times) > 0 {
min = m.times[0]
max = m.times[0]
}
for _, t := range m.times {
totalTime += t
if t < min {
min = t
}
if t > max {
max = t
}
}
avg := time.Duration(0)
if len(m.times) > 0 {
avg = totalTime / time.Duration(len(m.times))
}
fmt.Println("---------- Load Test Summary ----------")
fmt.Printf("Total Requests: %d\n", total)
fmt.Printf("Success: %d | Fail: %d\n", m.successes, m.failures)
fmt.Printf("Min Time: %v | Max Time: %v | Avg Time: %v\n", min, max, avg)
fmt.Printf("Success rate: %.2f%%\n", float64(m.successes)/float64(total)*100)
fmt.Println("---------------------------------------")
}
func hitEndpoint(wg *sync.WaitGroup, url string, metrics *Metrics) {
defer wg.Done()
req, _ := http.NewRequest("GET", url, nil)
// req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
fmt.Println("Error:", err)
metrics.AddResult(false, duration)
return
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
metrics.AddResult(success, duration)
}
func main() {
baseURL := "http://localhost:8000/plant"
concurrentUsers := 200
var wg sync.WaitGroup
metrics := &Metrics{}
for i := 0; i < concurrentUsers; i++ {
wg.Add(1)
go hitEndpoint(&wg, baseURL, metrics)
}
wg.Wait()
metrics.PrintSummary(concurrentUsers)
}

View File

@ -133,25 +133,25 @@ export default function BlogPage() {
<div className="space-y-8">
{/* Table of contents */}
<div className="sticky top-24">
<Card>
<CardHeader>
<CardTitle>Table of Contents</CardTitle>
</CardHeader>
<CardContent>
<nav className="space-y-2">
{blog.tableOfContents?.map((item) => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`text-left w-full px-2 py-1 text-sm rounded-md hover:bg-muted transition-colors ${
item.level > 1 ? "ml-4" : ""
}`}>
{item.title}
</button>
))}
</nav>
</CardContent>
</Card>
{/*<Card>*/}
{/* <CardHeader>*/}
{/* <CardTitle>Table of Contents</CardTitle>*/}
{/* </CardHeader>*/}
{/* <CardContent>*/}
{/* <nav className="space-y-2">*/}
{/* {blog.tableOfContents?.map((item) => (*/}
{/* <button*/}
{/* key={item.id}*/}
{/* onClick={() => scrollToSection(item.id)}*/}
{/* className={`text-left w-full px-2 py-1 text-sm rounded-md hover:bg-muted transition-colors ${*/}
{/* item.level > 1 ? "ml-4" : ""*/}
{/* }`}>*/}
{/* {item.title}*/}
{/* </button>*/}
{/* ))}*/}
{/* </nav>*/}
{/* </CardContent>*/}
{/*</Card>*/}
{/* Related articles */}
{blog.relatedArticles && (

View File

@ -1,7 +1,6 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: {
buildActivity: false,
},
@ -9,7 +8,11 @@ const nextConfig: NextConfig = {
remotePatterns: [
{
protocol: "https",
hostname: "**",
hostname: "static.wixstatic.com",
},
{
protocol: "http",
hostname: "static.wixstatic.com",
},
],
},

View File

@ -40,7 +40,7 @@
"framer-motion": "^12.4.10",
"js-cookie": "^3.0.5",
"lucide-react": "^0.475.0",
"next": "15.1.0",
"next": "15.2.4",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"react": "^19.0.0",

File diff suppressed because it is too large Load Diff