You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
39 KiB
39 KiB
Know Foolery - Development Guidelines & Coding Standards
Overview
This document establishes coding standards, development practices, and guidelines for the Know Foolery project to ensure consistency, maintainability, and quality across the codebase.
Quality Assurance
Testing Strategy
- Unit Testing: Go testing framework + Jest for frontend
- Integration Testing: testcontainers for database testing
- E2E Testing: Playwright for complete user journeys
- Performance Testing: k6 for load and stress testing
- Security Testing: Automated security scanning and penetration testing
Code Quality
- Type Safety: TypeScript for frontend, Go's type system for backend
- Code Review: Mandatory peer review for all changes
- Static Analysis: Automated code quality checks
- Documentation: Comprehensive API and component documentation
Code Organization
Project Structure Standards
knowfoolery/
├── backend/
│ ├── services/ # Microservices
│ │ ├── {service-name}/
│ │ │ ├── cmd/
│ │ │ │ └── main.go # Service entry point
│ │ │ ├── internal/
│ │ │ │ ├── handlers/ # Fiber HTTP handlers
│ │ │ │ ├── services/ # Business logic
│ │ │ │ ├── middleware/ # Service-specific middleware
│ │ │ │ └── models/ # Ent models and schemas
│ │ │ ├── api/ # API definitions (OpenAPI/gRPC)
│ │ │ ├── config/ # Configuration management
│ │ │ ├── tests/ # Service-specific tests
│ │ │ ├── Dockerfile # Container definition
│ │ │ └── go.mod # Go module
│ │ └── shared/ # Shared packages
│ │ ├── auth/ # JWT middleware & Zitadel integration
│ │ ├── database/ # Ent database client
│ │ ├── observability/ # Metrics and tracing
│ │ ├── security/ # Security utilities
│ │ └── utils/ # Common utilities
├── frontend/
│ ├── packages/
│ │ ├── ui-components/ # Shared Gluestack UI components
│ │ ├── shared-logic/ # Business logic
│ │ ├── shared-types/ # TypeScript types
│ │ └── shared-utils/ # Utility functions
│ ├── apps/
│ │ ├── web/ # React web app
│ │ ├── mobile/ # React Native app
│ │ └── desktop/ # Wails desktop app
└── infrastructure/ # DevOps and deployment
Naming Conventions
Go Code
// Files: snake_case
user_service.go
jwt_middleware.go
database_client.go
// Packages: lowercase, single word when possible
package auth
package database
package observability
// Exported types: PascalCase
type GameService struct {}
type AuthClaims struct {}
type DatabaseConfig struct {}
// Unexported types: camelCase
type userRepository struct {}
type tokenValidator struct {}
// Functions: PascalCase (exported), camelCase (unexported)
func NewGameService() *GameService {}
func (s *GameService) StartGame() error {}
func validateToken() error {}
// Constants: PascalCase or SCREAMING_SNAKE_CASE for groups
const DefaultTimeout = 30 * time.Second
const (
StatusActive = "active"
StatusInactive = "inactive"
)
// Interfaces: noun or adjective + "er" suffix
type TokenValidator interface {}
type UserRepository interface {}
type ScoreCalculator interface {}
TypeScript Code
// Files: kebab-case
game-card.tsx
user-service.ts
auth-context.tsx
// Components: PascalCase
export const GameCard: React.FC<GameCardProps> = () => {}
export const LeaderboardList: React.FC = () => {}
// Hooks: camelCase with "use" prefix
export const useGameSession = () => {}
export const useAuthentication = () => {}
// Functions: camelCase
export const calculateScore = () => {}
export const validateAnswer = () => {}
// Types/Interfaces: PascalCase
interface GameSessionProps {}
type AuthenticationState = 'loading' | 'authenticated' | 'unauthenticated'
// Constants: SCREAMING_SNAKE_CASE
export const MAX_ATTEMPTS = 3
export const SESSION_TIMEOUT = 30 * 60 * 1000 // 30 minutes
Go Development Standards
Fiber Framework Patterns
Service Structure
// Service initialization with Fiber
package main
import (
"log"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
)
func main() {
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Know Foolery Game Service",
ServerHeader: "Fiber",
ErrorHandler: customErrorHandler,
ReadTimeout: time.Second * 10,
WriteTimeout: time.Second * 10,
IdleTimeout: time.Second * 120,
})
// Global middleware
app.Use(logger.New())
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:3000, https://app.knowfoolery.com",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin, Content-Type, Authorization",
}))
// Health check endpoint
app.Get("/health", healthCheck)
// API routes with versioning
api := app.Group("/api/v1")
setupGameRoutes(api)
// Start server
log.Fatal(app.Listen(":8080"))
}
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
message := "Internal Server Error"
if e, ok := err.(*fiber.Error); ok {
code = e.Code
message = e.Message
}
return c.Status(code).JSON(fiber.Map{
"error": true,
"message": message,
"code": code,
})
}
Handler Patterns
// Handler structure and patterns
package handlers
import (
"github.com/gofiber/fiber/v3"
"knowfoolery/internal/services"
)
type GameHandler struct {
gameService *services.GameService
validator *validator.Validate
}
func NewGameHandler(gameService *services.GameService) *GameHandler {
return &GameHandler{
gameService: gameService,
validator: validator.New(),
}
}
// HTTP handler with proper error handling
func (h *GameHandler) StartGame(c *fiber.Ctx) error {
var req StartGameRequest
// Parse and validate request
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid request body",
})
}
if err := h.validator.Struct(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Validation failed",
"details": err.Error(),
})
}
// Extract user context from JWT middleware
userID := c.Locals("user_id").(string)
userRole := c.Locals("user_role").(string)
// Business logic
gameSession, err := h.gameService.StartGame(c.Context(), userID, req.PlayerName)
if err != nil {
return handleServiceError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"data": gameSession,
})
}
// Centralized error handling
func handleServiceError(c *fiber.Ctx, err error) error {
switch {
case errors.Is(err, services.ErrInvalidInput):
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid input",
"details": err.Error(),
})
case errors.Is(err, services.ErrNotFound):
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"message": "Resource not found",
})
case errors.Is(err, services.ErrUnauthorized):
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Unauthorized access",
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": "Internal server error",
})
}
}
// Request/Response structures with validation
type StartGameRequest struct {
PlayerName string `json:"player_name" validate:"required,min=2,max=50,alphanum_space"`
}
type StartGameResponse struct {
SessionID string `json:"session_id"`
PlayerName string `json:"player_name"`
StartTime time.Time `json:"start_time"`
TimeRemaining int `json:"time_remaining_seconds"`
}
Middleware Patterns
// Custom middleware for authentication
package middleware
import (
"github.com/gofiber/fiber/v3"
"knowfoolery/shared/auth"
)
func JWTMiddleware(zitadelRepo auth.ZitadelRepository) fiber.Handler {
return func(c *fiber.Ctx) error {
// Extract token from Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Authorization header required",
})
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid authorization header format",
})
}
// Validate token
claims, err := zitadelRepo.ValidateToken(c.Context(), token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid token",
})
}
// Set user context
c.Locals("user_id", claims.Subject)
c.Locals("user_email", claims.Email)
c.Locals("user_name", claims.Name)
c.Locals("user_roles", claims.Roles)
return c.Next()
}
}
// Rate limiting middleware
func RateLimitMiddleware(redisClient *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
userID := c.Locals("user_id")
clientIP := c.IP()
// Per-user rate limiting
if userID != nil {
userKey := fmt.Sprintf("rate_limit:user:%s", userID)
if !allowRequest(redisClient, userKey, 60, time.Minute) {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": true,
"message": "Rate limit exceeded for user",
})
}
}
// Per-IP rate limiting
ipKey := fmt.Sprintf("rate_limit:ip:%s", clientIP)
if !allowRequest(redisClient, ipKey, 100, time.Minute) {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": true,
"message": "Rate limit exceeded for IP",
})
}
return c.Next()
}
}
Database Patterns with Ent
Schema Definition Standards
// Ent schema with proper field validation and indexes
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"time"
)
type Question struct {
ent.Schema
}
func (Question) Fields() []ent.Field {
return []ent.Field{
field.String("theme").
NotEmpty().
MaxLen(100).
Comment("Question category/theme"),
field.Text("text").
NotEmpty().
MaxLen(1000).
Comment("The actual question text"),
field.String("answer").
NotEmpty().
MaxLen(500).
Comment("Correct answer"),
field.Text("hint").
Optional().
MaxLen(500).
Comment("Optional hint text"),
field.Enum("difficulty").
Values("easy", "medium", "hard").
Default("medium").
Comment("Question difficulty level"),
field.Bool("is_active").
Default(true).
Comment("Whether question is active"),
field.Time("created_at").
Default(time.Now).
Immutable().
Comment("Creation timestamp"),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now).
Comment("Last update timestamp"),
}
}
func (Question) Indexes() []ent.Index {
return []ent.Index{
index.Fields("theme", "is_active"),
index.Fields("difficulty", "is_active"),
index.Fields("created_at"),
}
}
func (Question) Edges() []ent.Edge {
return []ent.Edge{
edge.To("attempts", QuestionAttempt.Type),
}
}
Service Layer Patterns
// Service layer with proper error handling
package services
import (
"context"
"errors"
"knowfoolery/internal/models"
"knowfoolery/ent"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrGameInProgress = errors.New("game already in progress")
ErrSessionExpired = errors.New("session expired")
)
type GameService struct {
client *ent.Client
questionSvc QuestionService
sessionSvc SessionService
validator *validator.Validate
}
func NewGameService(
client *ent.Client,
questionSvc QuestionService,
sessionSvc SessionService,
) *GameService {
return &GameService{
client: client,
questionSvc: questionSvc,
sessionSvc: sessionSvc,
validator: validator.New(),
}
}
func (s *GameService) StartGame(ctx context.Context, userID, playerName string) (*models.GameSession, error) {
// Validate input
if err := s.validatePlayerName(playerName); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
// Check for existing active session
activeSession, err := s.client.GameSession.
Query().
Where(gamesession.And(
gamesession.PlayerNameEQ(playerName),
gamesession.StatusEQ(gamesession.StatusActive),
)).
Only(ctx)
if err != nil && !ent.IsNotFound(err) {
return nil, fmt.Errorf("failed to check existing session: %w", err)
}
if activeSession != nil {
return nil, ErrGameInProgress
}
// Start database transaction
tx, err := s.client.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Create new game session
session, err := tx.GameSession.
Create().
SetPlayerName(playerName).
SetTotalScore(0).
SetQuestionsAsked(0).
SetQuestionsCorrect(0).
SetHintsUsed(0).
SetStartTime(time.Now()).
SetStatus(gamesession.StatusActive).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Get first question
question, err := s.questionSvc.GetRandomQuestion(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get question: %w", err)
}
// Update session with first question
session, err = session.Update().
SetCurrentQuestionID(question.ID).
SetCurrentAttempts(0).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed to update session: %w", err)
}
// Commit transaction
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return &models.GameSession{
ID: session.ID,
PlayerName: session.PlayerName,
TotalScore: session.TotalScore,
StartTime: session.StartTime,
Status: string(session.Status),
CurrentQuestion: &models.Question{
ID: question.ID,
Theme: question.Theme,
Text: question.Text,
Hint: question.Hint,
},
TimeRemaining: s.calculateTimeRemaining(session.StartTime),
}, nil
}
func (s *GameService) validatePlayerName(name string) error {
type PlayerNameValidation struct {
Name string `validate:"required,min=2,max=50,alphanum_space"`
}
return s.validator.Struct(&PlayerNameValidation{Name: name})
}
func (s *GameService) calculateTimeRemaining(startTime time.Time) int {
const sessionDuration = 30 * time.Minute
elapsed := time.Since(startTime)
remaining := sessionDuration - elapsed
if remaining < 0 {
return 0
}
return int(remaining.Seconds())
}
TypeScript Development Standards
React Component Patterns
Component Structure
// Component with proper TypeScript types and patterns
import React, { useState, useEffect, useCallback } from 'react'
import {
Card,
VStack,
HStack,
Text,
Input,
Button,
Badge,
Progress
} from '@gluestack-ui/themed'
import { useGameSession } from '../hooks/useGameSession'
import { useTimer } from '../hooks/useTimer'
// Props interface with proper documentation
export interface GameCardProps {
/** The question to display */
question: string
/** Theme/category of the question */
theme: string
/** Time remaining in seconds */
timeRemaining: number
/** Number of attempts left */
attemptsLeft: number
/** Current player score */
currentScore: number
/** Loading state */
isLoading?: boolean
/** Callback when answer is submitted */
onSubmitAnswer: (answer: string) => Promise<void>
/** Callback when hint is requested */
onRequestHint: () => Promise<void>
/** Callback when time expires */
onTimeExpire?: () => void
}
/**
* GameCard component displays a quiz question with input and controls
*
* @param props - GameCard props
* @returns React component
*/
export const GameCard: React.FC<GameCardProps> = ({
question,
theme,
timeRemaining,
attemptsLeft,
currentScore,
isLoading = false,
onSubmitAnswer,
onRequestHint,
onTimeExpire
}) => {
// Local state with proper typing
const [answer, setAnswer] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
// Custom hooks
const { formatTime } = useTimer()
// Memoized handlers
const handleSubmit = useCallback(async () => {
if (!answer.trim() || isSubmitting) return
setIsSubmitting(true)
try {
await onSubmitAnswer(answer.trim())
setAnswer('') // Clear input on successful submission
} catch (error) {
console.error('Failed to submit answer:', error)
// Error handling would be managed by parent component
} finally {
setIsSubmitting(false)
}
}, [answer, isSubmitting, onSubmitAnswer])
const handleHintRequest = useCallback(async () => {
if (isLoading) return
try {
await onRequestHint()
} catch (error) {
console.error('Failed to request hint:', error)
}
}, [isLoading, onRequestHint])
// Effects
useEffect(() => {
if (timeRemaining === 0 && onTimeExpire) {
onTimeExpire()
}
}, [timeRemaining, onTimeExpire])
// Computed values
const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100
const isTimeWarning = timeRemaining <= 300 // 5 minutes
const isTimeCritical = timeRemaining <= 60 // 1 minute
return (
<Card size="lg" variant="elevated" m="$4">
<VStack space="md" p="$4">
{/* Header with theme and timer */}
<HStack justifyContent="space-between" alignItems="center">
<Badge
size="sm"
variant="solid"
action="info"
testID="theme-badge"
>
<Text color="$white" fontSize="$sm" fontWeight="$semibold">
{theme}
</Text>
</Badge>
<HStack space="sm" alignItems="center">
<Text
fontSize="$sm"
color={isTimeCritical ? "$error500" : isTimeWarning ? "$warning500" : "$textLight600"}
testID="timer-display"
>
⏱️ {formatTime(timeRemaining)}
</Text>
</HStack>
</HStack>
{/* Progress indicator */}
<Progress
value={progressValue}
size="sm"
testID="session-progress"
>
<ProgressFilledTrack />
</Progress>
{/* Question */}
<Text
fontSize="$lg"
fontWeight="$semibold"
lineHeight="$xl"
testID="question-text"
>
{question}
</Text>
{/* Answer input */}
<Input
size="lg"
isDisabled={isLoading || isSubmitting}
testID="answer-input"
>
<InputField
placeholder="Enter your answer..."
value={answer}
onChangeText={setAnswer}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={handleSubmit}
returnKeyType="send"
/>
</Input>
{/* Game stats */}
<HStack justifyContent="space-between" alignItems="center">
<Text fontSize="$sm" color="$textLight600" testID="attempts-counter">
Attempts left: {attemptsLeft}/3
</Text>
<Text fontSize="$sm" color="$textLight600" testID="score-display">
Score: {currentScore} points
</Text>
</HStack>
{/* Action buttons */}
<HStack space="sm">
<Button
size="lg"
variant="solid"
action="primary"
flex={1}
onPress={handleSubmit}
isDisabled={!answer.trim() || isLoading || isSubmitting}
testID="submit-button"
>
<ButtonText>
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
</ButtonText>
</Button>
<Button
size="lg"
variant="outline"
action="secondary"
onPress={handleHintRequest}
isDisabled={isLoading || isSubmitting}
testID="hint-button"
>
<ButtonText>💡 Hint</ButtonText>
</Button>
</HStack>
</VStack>
</Card>
)
}
// Default export for dynamic imports
export default GameCard
Custom Hooks Pattern
// Custom hook with proper TypeScript types
import { useState, useEffect, useCallback, useRef } from 'react'
import { GameSessionService } from '../services/game-session-service'
interface UseGameSessionOptions {
playerName: string
onSessionEnd?: (finalScore: number) => void
onError?: (error: Error) => void
}
interface GameSessionState {
sessionId: string | null
currentQuestion: Question | null
score: number
attemptsLeft: number
timeRemaining: number
isLoading: boolean
error: string | null
}
interface GameSessionActions {
startGame: () => Promise<void>
submitAnswer: (answer: string) => Promise<boolean>
requestHint: () => Promise<string>
endGame: () => Promise<void>
}
export const useGameSession = (
options: UseGameSessionOptions
): [GameSessionState, GameSessionActions] => {
const [state, setState] = useState<GameSessionState>({
sessionId: null,
currentQuestion: null,
score: 0,
attemptsLeft: 3,
timeRemaining: 30 * 60, // 30 minutes
isLoading: false,
error: null,
})
const timerRef = useRef<NodeJS.Timeout | null>(null)
const gameService = useRef(new GameSessionService())
// Timer effect
useEffect(() => {
if (state.sessionId && state.timeRemaining > 0) {
timerRef.current = setInterval(() => {
setState(prev => {
const newTimeRemaining = prev.timeRemaining - 1
if (newTimeRemaining <= 0 && options.onSessionEnd) {
options.onSessionEnd(prev.score)
}
return { ...prev, timeRemaining: Math.max(0, newTimeRemaining) }
})
}, 1000)
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [state.sessionId, state.timeRemaining, options.onSessionEnd])
// Actions
const startGame = useCallback(async () => {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
const session = await gameService.current.startGame(options.playerName)
setState(prev => ({
...prev,
sessionId: session.sessionId,
currentQuestion: session.firstQuestion,
score: 0,
attemptsLeft: 3,
timeRemaining: session.timeRemaining,
isLoading: false,
}))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to start game'
setState(prev => ({ ...prev, error: errorMessage, isLoading: false }))
options.onError?.(error instanceof Error ? error : new Error(errorMessage))
}
}, [options.playerName, options.onError])
const submitAnswer = useCallback(async (answer: string): Promise<boolean> => {
if (!state.sessionId || !state.currentQuestion) {
throw new Error('No active game session')
}
setState(prev => ({ ...prev, isLoading: true }))
try {
const result = await gameService.current.submitAnswer(
state.sessionId,
state.currentQuestion.id,
answer
)
setState(prev => ({
...prev,
score: result.newScore,
attemptsLeft: result.attemptsLeft,
currentQuestion: result.nextQuestion,
isLoading: false,
}))
return result.isCorrect
} catch (error) {
setState(prev => ({ ...prev, isLoading: false }))
throw error
}
}, [state.sessionId, state.currentQuestion])
const requestHint = useCallback(async (): Promise<string> => {
if (!state.sessionId || !state.currentQuestion) {
throw new Error('No active game session')
}
const hint = await gameService.current.requestHint(
state.sessionId,
state.currentQuestion.id
)
return hint
}, [state.sessionId, state.currentQuestion])
const endGame = useCallback(async () => {
if (!state.sessionId) return
await gameService.current.endGame(state.sessionId)
setState({
sessionId: null,
currentQuestion: null,
score: 0,
attemptsLeft: 3,
timeRemaining: 30 * 60,
isLoading: false,
error: null,
})
if (timerRef.current) {
clearInterval(timerRef.current)
}
}, [state.sessionId])
return [
state,
{
startGame,
submitAnswer,
requestHint,
endGame,
},
]
}
Testing Standards
Go Testing Patterns
// Comprehensive testing with testify and testcontainers
package services_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"knowfoolery/internal/services"
"knowfoolery/ent/enttest"
)
type GameServiceTestSuite struct {
suite.Suite
ctx context.Context
gameService *services.GameService
client *ent.Client
}
func (suite *GameServiceTestSuite) SetupSuite() {
suite.ctx = context.Background()
// Start PostgreSQL container for integration tests
postgresContainer, err := postgres.RunContainer(suite.ctx,
testcontainers.WithImage("postgres:15-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
)
require.NoError(suite.T(), err)
connStr, err := postgresContainer.ConnectionString(suite.ctx)
require.NoError(suite.T(), err)
// Create Ent client with test database
suite.client = enttest.Open(suite.T(), "postgres", connStr)
// Initialize service
suite.gameService = services.NewGameService(
suite.client,
services.NewQuestionService(suite.client),
services.NewSessionService(suite.client),
)
}
func (suite *GameServiceTestSuite) TearDownSuite() {
suite.client.Close()
}
func (suite *GameServiceTestSuite) SetupTest() {
// Clean database before each test
suite.client.GameSession.Delete().ExecX(suite.ctx)
suite.client.Question.Delete().ExecX(suite.ctx)
// Seed test data
suite.seedTestQuestions()
}
func (suite *GameServiceTestSuite) TestStartGame_Success() {
// Arrange
playerName := "TestPlayer"
// Act
session, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
// Assert
require.NoError(suite.T(), err)
assert.NotEmpty(suite.T(), session.ID)
assert.Equal(suite.T(), playerName, session.PlayerName)
assert.Equal(suite.T(), 0, session.TotalScore)
assert.NotNil(suite.T(), session.CurrentQuestion)
assert.True(suite.T(), session.TimeRemaining > 0)
}
func (suite *GameServiceTestSuite) TestStartGame_InvalidPlayerName() {
// Test cases for validation
testCases := []struct {
name string
playerName string
expectedErr string
}{
{"empty name", "", "required"},
{"too short", "a", "min"},
{"too long", strings.Repeat("a", 51), "max"},
{"invalid chars", "player@123", "alphanum_space"},
}
for _, tc := range testCases {
suite.T().Run(tc.name, func(t *testing.T) {
_, err := suite.gameService.StartGame(suite.ctx, "user123", tc.playerName)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErr)
})
}
}
func (suite *GameServiceTestSuite) TestStartGame_ExistingActiveSession() {
// Arrange
playerName := "TestPlayer"
// Start first game
_, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
require.NoError(suite.T(), err)
// Act - try to start second game
_, err = suite.gameService.StartGame(suite.ctx, "user123", playerName)
// Assert
require.Error(suite.T(), err)
assert.Equal(suite.T(), services.ErrGameInProgress, err)
}
func (suite *GameServiceTestSuite) seedTestQuestions() {
questions := []struct {
theme string
text string
answer string
hint string
}{
{"Geography", "What is the capital of France?", "Paris", "City of Light"},
{"History", "Who painted the Mona Lisa?", "Leonardo da Vinci", "Renaissance artist"},
{"Science", "What is the largest planet?", "Jupiter", "Gas giant"},
}
for _, q := range questions {
suite.client.Question.Create().
SetTheme(q.theme).
SetText(q.text).
SetAnswer(q.answer).
SetHint(q.hint).
SetIsActive(true).
ExecX(suite.ctx)
}
}
func TestGameServiceSuite(t *testing.T) {
suite.Run(t, new(GameServiceTestSuite))
}
// Benchmark tests
func BenchmarkGameService_StartGame(b *testing.B) {
// Setup
ctx := context.Background()
client := enttest.Open(b, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
gameService := services.NewGameService(client, nil, nil)
// Benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
playerName := fmt.Sprintf("Player%d", i)
_, _ = gameService.StartGame(ctx, fmt.Sprintf("user%d", i), playerName)
}
}
TypeScript Testing Patterns
// React component testing with Jest and Testing Library
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { GluestackUIProvider } from '@gluestack-ui/themed'
import { GameCard, GameCardProps } from '../GameCard'
// Test wrapper for Gluestack UI
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<GluestackUIProvider>{children}</GluestackUIProvider>
)
// Mock props factory
const createMockProps = (overrides: Partial<GameCardProps> = {}): GameCardProps => ({
question: "What is the capital of France?",
theme: "Geography",
timeRemaining: 1800, // 30 minutes
attemptsLeft: 3,
currentScore: 0,
onSubmitAnswer: jest.fn(),
onRequestHint: jest.fn(),
...overrides,
})
describe('GameCard', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render question and theme correctly', () => {
const props = createMockProps()
render(<GameCard {...props} />, { wrapper: TestWrapper })
expect(screen.getByTestId('question-text')).toHaveTextContent(props.question)
expect(screen.getByTestId('theme-badge')).toHaveTextContent(props.theme)
})
it('should display timer in correct format', () => {
const props = createMockProps({ timeRemaining: 125 }) // 2:05
render(<GameCard {...props} />, { wrapper: TestWrapper })
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
})
it('should show attempts and score correctly', () => {
const props = createMockProps({ attemptsLeft: 2, currentScore: 5 })
render(<GameCard {...props} />, { wrapper: TestWrapper })
expect(screen.getByTestId('attempts-counter')).toHaveTextContent('Attempts left: 2/3')
expect(screen.getByTestId('score-display')).toHaveTextContent('Score: 5 points')
})
})
describe('User Interactions', () => {
it('should call onSubmitAnswer when submit button is clicked', async () => {
const props = createMockProps()
const mockOnSubmitAnswer = jest.fn().mockResolvedValue(undefined)
props.onSubmitAnswer = mockOnSubmitAnswer
render(<GameCard {...props} />, { wrapper: TestWrapper })
// Type an answer
const input = screen.getByTestId('answer-input')
fireEvent.changeText(input, 'Paris')
// Click submit
const submitButton = screen.getByTestId('submit-button')
fireEvent.press(submitButton)
await waitFor(() => {
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
})
})
it('should not submit empty answer', () => {
const props = createMockProps()
render(<GameCard {...props} />, { wrapper: TestWrapper })
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toBeDisabled()
})
it('should call onRequestHint when hint button is clicked', async () => {
const props = createMockProps()
const mockOnRequestHint = jest.fn().mockResolvedValue('City of Light')
props.onRequestHint = mockOnRequestHint
render(<GameCard {...props} />, { wrapper: TestWrapper })
const hintButton = screen.getByTestId('hint-button')
fireEvent.press(hintButton)
await waitFor(() => {
expect(mockOnRequestHint).toHaveBeenCalled()
})
})
})
describe('Loading States', () => {
it('should disable inputs when loading', () => {
const props = createMockProps({ isLoading: true })
render(<GameCard {...props} />, { wrapper: TestWrapper })
expect(screen.getByTestId('answer-input')).toBeDisabled()
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByTestId('hint-button')).toBeDisabled()
})
})
describe('Timer States', () => {
it('should show warning color when time is low', () => {
const props = createMockProps({ timeRemaining: 300 }) // 5 minutes
render(<GameCard {...props} />, { wrapper: TestWrapper })
const timer = screen.getByTestId('timer-display')
expect(timer).toHaveStyle({ color: expect.stringContaining('warning') })
})
it('should show critical color when time is very low', () => {
const props = createMockProps({ timeRemaining: 30 }) // 30 seconds
render(<GameCard {...props} />, { wrapper: TestWrapper })
const timer = screen.getByTestId('timer-display')
expect(timer).toHaveStyle({ color: expect.stringContaining('error') })
})
})
})
// Hook testing
import { renderHook, act } from '@testing-library/react'
import { useGameSession } from '../hooks/useGameSession'
// Mock the service
jest.mock('../services/game-session-service')
describe('useGameSession', () => {
const mockGameService = {
startGame: jest.fn(),
submitAnswer: jest.fn(),
requestHint: jest.fn(),
endGame: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
// Set up service mock
;(GameSessionService as jest.Mock).mockImplementation(() => mockGameService)
})
it('should start game successfully', async () => {
mockGameService.startGame.mockResolvedValue({
sessionId: 'session123',
firstQuestion: { id: 'q1', text: 'Test question' },
timeRemaining: 1800,
})
const { result } = renderHook(() =>
useGameSession({ playerName: 'TestPlayer' })
)
await act(async () => {
await result.current[1].startGame()
})
expect(result.current[0].sessionId).toBe('session123')
expect(result.current[0].currentQuestion).toBeDefined()
})
it('should handle errors during game start', async () => {
const mockError = new Error('Failed to start game')
mockGameService.startGame.mockRejectedValue(mockError)
const onError = jest.fn()
const { result } = renderHook(() =>
useGameSession({ playerName: 'TestPlayer', onError })
)
await act(async () => {
await result.current[1].startGame()
})
expect(result.current[0].error).toBe('Failed to start game')
expect(onError).toHaveBeenCalledWith(mockError)
})
})
Code Quality Standards
Linting and Formatting
# .golangci.yml
run:
timeout: 5m
tests: true
linters:
enable:
- gofmt
- goimports
- govet
- errcheck
- staticcheck
- unused
- gosimple
- structcheck
- varcheck
- ineffassign
- deadcode
- typecheck
- gosec
- misspell
- lll
- unconvert
- dupl
- goconst
- gocyclo
linters-settings:
lll:
line-length: 120
gocyclo:
min-complexity: 15
dupl:
threshold: 150
issues:
exclude-rules:
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
// .eslintrc.json
{
"extends": [
"@react-native",
"@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react-hooks"],
"rules": {
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/prefer-const": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": "error",
"no-var": "error",
"object-shorthand": "error",
"prefer-template": "error"
}
}
Git Workflow
# Branch naming conventions
feature/add-game-session-management
bugfix/fix-timer-display-issue
hotfix/security-vulnerability-patch
refactor/improve-database-queries
# Commit message format
type(scope): description
# Types: feat, fix, docs, style, refactor, test, chore
# Examples:
feat(game): add session timeout handling
fix(auth): resolve JWT token validation issue
docs(api): update authentication endpoint documentation
test(services): add comprehensive game service tests
This comprehensive development guidelines document ensures consistent, maintainable, and high-quality code across the Know Foolery project.