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.
1030 lines
28 KiB
Markdown
1030 lines
28 KiB
Markdown
# 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 Quality Standards
|
|
|
|
### General Code Quality Orientations
|
|
- **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
|
|
|
|
### Linting and Formatting
|
|
```yaml
|
|
# .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
|
|
```
|
|
|
|
```json
|
|
// .eslintrc.json
|
|
{
|
|
"extends": [
|
|
"plugin:solid/typescript",
|
|
"@typescript-eslint/recommended",
|
|
"prettier"
|
|
],
|
|
"parser": "@typescript-eslint/parser",
|
|
"plugins": ["@typescript-eslint", "solid"],
|
|
"rules": {
|
|
"@typescript-eslint/explicit-function-return-type": "error",
|
|
"@typescript-eslint/no-unused-vars": "error",
|
|
"@typescript-eslint/prefer-const": "error",
|
|
"solid/reactivity": "warn",
|
|
"prefer-const": "error",
|
|
"no-var": "error",
|
|
"object-shorthand": "error",
|
|
"prefer-template": "error"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Naming Conventions
|
|
|
|
#### Go Code
|
|
```go
|
|
// 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
|
|
```typescript
|
|
// Files: kebab-case
|
|
game-card.tsx
|
|
user-service.ts
|
|
auth-store.ts
|
|
|
|
// Components: PascalCase
|
|
export const GameCard: Component<GameCardProps> = () => {}
|
|
export const LeaderboardList: Component = () => {}
|
|
|
|
// Composables/stores: camelCase with "create"/"use" prefix
|
|
export const createGameSessionStore = () => {}
|
|
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
|
|
```go
|
|
// 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
|
|
```go
|
|
// 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
|
|
```go
|
|
// 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
|
|
```go
|
|
// 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
|
|
```go
|
|
// 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
|
|
|
|
### SolidJS Component Patterns
|
|
|
|
#### Component Structure
|
|
```typescript
|
|
// Component with proper TypeScript types and SolidJS patterns
|
|
import type { Component } from 'solid-js'
|
|
import { createMemo, createSignal, Show } from 'solid-js'
|
|
import {
|
|
Box,
|
|
Button,
|
|
Input,
|
|
LinearProgress,
|
|
Typography,
|
|
Chip,
|
|
} from '@suid/material'
|
|
|
|
// 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
|
|
/** Callback when answer is submitted */
|
|
onSubmitAnswer: (answer: string) => Promise<void>
|
|
/** Loading state from async operations */
|
|
loading?: boolean
|
|
}
|
|
|
|
/**
|
|
* GameCard component displays a quiz question with input and controls
|
|
*
|
|
* @param props - GameCard props
|
|
* @returns SolidJS component
|
|
*/
|
|
export const GameCard: Component<GameCardProps> = (props) => {
|
|
const [answer, setAnswer] = createSignal('')
|
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
|
|
|
const disabled = createMemo(() => props.loading || isSubmitting())
|
|
const progressValue = createMemo(() => ((30 * 60 - props.timeRemaining) / (30 * 60)) * 100)
|
|
|
|
const handleSubmit = async () => {
|
|
const value = answer().trim()
|
|
if (!value || disabled()) return
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
await props.onSubmitAnswer(value)
|
|
setAnswer('')
|
|
} catch (error) {
|
|
console.error('Failed to submit answer:', error)
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
<Chip label={props.theme} />
|
|
<Typography variant="body2">{props.timeRemaining}s</Typography>
|
|
</Box>
|
|
<LinearProgress variant="determinate" value={progressValue()} />
|
|
<Typography variant="h6" sx={{ mt: 2 }}>{props.question}</Typography>
|
|
<Typography variant="body2" sx={{ mt: 1 }}>Attempts left: {props.attemptsLeft}/3</Typography>
|
|
<Input
|
|
value={answer()}
|
|
onInput={(e) => setAnswer(e.currentTarget.value)}
|
|
disabled={disabled()}
|
|
placeholder="Enter your answer"
|
|
/>
|
|
<Button onClick={handleSubmit} disabled={disabled() || answer().trim().length === 0}>
|
|
Submit Answer
|
|
</Button>
|
|
<Show when={disabled()}>
|
|
<Typography variant="caption">Submitting...</Typography>
|
|
</Show>
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
#### Solid Composable Pattern
|
|
```typescript
|
|
import { createSignal } from 'solid-js'
|
|
import { GameSessionService } from '../services/game-session-service'
|
|
|
|
export const createGameSessionStore = (playerName: () => string) => {
|
|
const service = new GameSessionService()
|
|
const [isLoading, setIsLoading] = createSignal(false)
|
|
const [error, setError] = createSignal<string | null>(null)
|
|
const [sessionId, setSessionId] = createSignal<string | null>(null)
|
|
|
|
const startGame = async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const session = await service.startGame(playerName())
|
|
setSessionId(session.sessionId)
|
|
return session
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to start game'
|
|
setError(message)
|
|
throw err
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
return {
|
|
isLoading,
|
|
error,
|
|
sessionId,
|
|
startGame,
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Standards
|
|
|
|
### Go Testing Patterns
|
|
```go
|
|
// 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
|
|
```typescript
|
|
// SolidJS component testing with Vitest and Testing Library
|
|
import { render, screen, fireEvent } from '@solidjs/testing-library'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { GameCard, GameCardProps } from '../GameCard'
|
|
|
|
// Mock props factory
|
|
const createMockProps = (overrides: Partial<GameCardProps> = {}): GameCardProps => ({
|
|
question: "What is the capital of France?",
|
|
theme: "Geography",
|
|
timeRemaining: 1800, // 30 minutes
|
|
attemptsLeft: 3,
|
|
onSubmitAnswer: vi.fn(),
|
|
loading: false,
|
|
...overrides,
|
|
})
|
|
|
|
describe('GameCard', () => {
|
|
describe('Rendering', () => {
|
|
it('should render question and theme correctly', () => {
|
|
const props = createMockProps()
|
|
|
|
render(() => <GameCard {...props} />)
|
|
|
|
expect(screen.getByText(props.question)).toBeInTheDocument()
|
|
expect(screen.getByText(props.theme)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onSubmitAnswer when submit button is clicked', async () => {
|
|
const props = createMockProps()
|
|
const mockOnSubmitAnswer = vi.fn().mockResolvedValue(undefined)
|
|
props.onSubmitAnswer = mockOnSubmitAnswer
|
|
|
|
render(() => <GameCard {...props} />)
|
|
|
|
const input = screen.getByPlaceholderText('Enter your answer')
|
|
fireEvent.input(input, { target: { value: 'Paris' } })
|
|
|
|
const submitButton = screen.getByRole('button', { name: 'Submit Answer' })
|
|
fireEvent.click(submitButton)
|
|
|
|
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
|
|
})
|
|
})
|
|
})
|
|
```
|
|
## Git Workflow
|
|
```bash
|
|
# 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
|
|
```
|
|
|
|
## Development Commands
|
|
|
|
### Backend Development
|
|
```bash
|
|
# Navigate to service directory first
|
|
cd backend/services/{service-name}
|
|
|
|
# Start service in development mode with hot reload
|
|
go run cmd/main.go
|
|
|
|
# Service ports are env-driven via shared bootstrap helpers:
|
|
# ADMIN_SERVICE_PORT, GAME_SESSION_PORT, GATEWAY_PORT,
|
|
# LEADERBOARD_PORT, QUESTION_BANK_PORT, USER_SERVICE_PORT
|
|
|
|
# Run tests for a specific service
|
|
go test ./... -v
|
|
|
|
# Run tests with coverage
|
|
go test ./... -cover
|
|
|
|
# Build service binary
|
|
go build -o bin/{service-name} cmd/main.go
|
|
|
|
# Generate Ent schemas (from service root)
|
|
go generate ./...
|
|
|
|
# Lint Go code
|
|
golangci-lint run
|
|
|
|
# Format Go code
|
|
go fmt ./...
|
|
goimports -w .
|
|
```
|
|
|
|
### Development Environment
|
|
```bash
|
|
# Start development stack with Docker Compose
|
|
cd infrastructure/dev
|
|
docker-compose up -d
|
|
|
|
# This starts:
|
|
# - PostgreSQL database (port 5432)
|
|
# - Redis cache (port 6379)
|
|
# - Zitadel auth server (port 8080)
|
|
```
|
|
|
|
### Frontend Development
|
|
Note: This project uses Yarn with `nodeLinker: node-modules`. PnP files like `.pnp.cjs` and `.pnp.loader.mjs` are not used.
|
|
Status: `Web` commands are implemented. `Tauri cross-platform` commands are planned and require a future `frontend/apps/cross-platform` workspace.
|
|
```bash
|
|
# Web application
|
|
cd frontend
|
|
yarn install
|
|
yarn dev # Start web development server (SolidJS)
|
|
yarn build # Build all frontend workspaces
|
|
yarn test # Run Vitest suites
|
|
yarn lint # ESLint
|
|
yarn format:check # Prettier check
|
|
|
|
# Cross-platform packaging with Tauri (desktop/mobile)
|
|
cd frontend/apps/cross-platform
|
|
yarn tauri dev # Run Tauri app with embedded web UI
|
|
yarn tauri build # Build release bundles
|
|
```
|