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.
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.