Files
terminalShop/MODELS.md

34 KiB

Technical Specifications & Architecture

This document defines the technical stack, architecture patterns, code standards, and implementation details for the Terminal Coffee Shop project. Read this before writing any code.


Table of Contents

  1. Development Workflow
  2. Technology Stack
  3. Database Schema
  4. API Specifications
  5. Code Organization
  6. Code Style Guide
  7. Error Handling
  8. Testing Standards
  9. Common Patterns

Development Workflow

Documentation-First Approach

IMPORTANT: Before implementing features using any library or technology, always fetch and read the official documentation first.

Process for Using New Libraries

  1. Identify the library/technology you need to use (e.g., Chi router, GORM, Stripe SDK)
  2. Fetch the documentation using WebFetch:
    WebFetch the official documentation URL for the library
    Read through relevant sections (getting started, API reference, examples)
    
  3. Understand the current best practices from the docs (not from memory/assumptions)
  4. Implement following the documented patterns
  5. Reference the docs if you encounter issues

Why This Matters

  • Libraries evolve - documentation ensures you use current patterns
  • Prevents deprecated patterns or outdated approaches
  • Helps discover features you might not know about
  • Ensures compatibility with the version in go.mod

Example Workflow

Task: Implement Chi router middleware

Step 1: Check go.mod for version
Step 2: WebFetch https://go-chi.io/#/pages/middleware
Step 3: Read middleware documentation
Step 4: Implement following documented patterns
Step 5: Test implementation

Documentation Sources


Technology Stack

TUI (Terminal User Interface)

Package Version Purpose Documentation
github.com/charmbracelet/bubbletea v1.3.10+ Main TUI framework (The Elm Architecture) https://github.com/charmbracelet/bubbletea
github.com/charmbracelet/lipgloss v1.1.0+ Styling and layout https://github.com/charmbracelet/lipgloss
github.com/charmbracelet/bubbles v0.21.0+ Pre-built UI components (viewport, spinner, etc.) https://github.com/charmbracelet/bubbles
github.com/charmbracelet/huh v0.8.0+ Forms and input validation https://github.com/charmbracelet/huh
github.com/charmbracelet/wish v1.4.7+ SSH server framework https://github.com/charmbracelet/wish

Backend API

Package Version Purpose Documentation
github.com/go-chi/chi/v5 v5.0.0+ HTTP router https://go-chi.io/
gorm.io/gorm v1.25.0+ ORM for database operations https://gorm.io/docs/
gorm.io/driver/postgres v1.5.0+ PostgreSQL driver https://gorm.io/docs/connecting_to_the_database.html
github.com/golang-jwt/jwt/v5 v5.0.0+ JWT token handling https://github.com/golang-jwt/jwt
github.com/go-playground/validator/v10 v10.0.0+ Struct validation https://github.com/go-playground/validator
github.com/joho/godotenv v1.5.0+ Environment variables https://github.com/joho/godotenv

Payments & Services

Package Version Purpose Documentation
github.com/stripe/stripe-go/v78 v78.12.0+ Payment processing https://stripe.com/docs/api?lang=go
rsc.io/qr v0.2.0 QR code generation https://pkg.go.dev/rsc.io/qr

Utilities

Package Version Purpose Documentation
github.com/google/uuid v1.6.0+ UUID generation https://github.com/google/uuid
golang.org/x/crypto latest Cryptography utilities https://pkg.go.dev/golang.org/x/crypto

Database Schema

Schema Design Principles

  • Use UUIDs for primary keys (not auto-incrementing integers)
  • Always include created_at and updated_at timestamps
  • Use soft deletes (deleted_at) for important records
  • Normalize data appropriately (3NF where possible)
  • Add indexes on foreign keys and frequently queried columns

Complete Schema (PostgreSQL)

-- Users table
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    fingerprint VARCHAR(255) UNIQUE,  -- SSH public key fingerprint
    email VARCHAR(255) UNIQUE,
    name VARCHAR(255),
    stripe_customer_id VARCHAR(255),  -- Stripe customer ID
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

-- Products table
CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    roast_type VARCHAR(100),  -- "Dark Roast", "Medium Roast", etc.
    ounces INTEGER,           -- Size in ounces
    bean_type VARCHAR(100),   -- "Arabica", "Robusta", etc.
    price INTEGER NOT NULL,   -- Price in cents (e.g., 350 = $3.50)
    color VARCHAR(7),         -- Hex color for UI (e.g., "#8B4513")
    image_url VARCHAR(500),
    stock INTEGER DEFAULT 0,
    active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

-- Carts table
CREATE TABLE carts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Cart items table
CREATE TABLE cart_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    cart_id UUID REFERENCES carts(id) ON DELETE CASCADE,
    product_id UUID REFERENCES products(id),
    quantity INTEGER NOT NULL DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(cart_id, product_id)  -- One entry per product per cart
);

-- Addresses table
CREATE TABLE addresses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    name VARCHAR(255),        -- Recipient name
    street VARCHAR(255) NOT NULL,
    street2 VARCHAR(255),     -- Apt, suite, etc.
    city VARCHAR(100) NOT NULL,
    state VARCHAR(2) NOT NULL,  -- US state code
    zip VARCHAR(10) NOT NULL,
    country VARCHAR(2) DEFAULT 'US',
    is_default BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Payment methods table (stores Stripe tokens, not raw card data)
CREATE TABLE payment_methods (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    stripe_payment_method_id VARCHAR(255) NOT NULL,  -- Stripe PM ID
    brand VARCHAR(50),        -- "Visa", "Mastercard", etc.
    last4 VARCHAR(4),         -- Last 4 digits
    exp_month INTEGER,
    exp_year INTEGER,
    is_default BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Orders table
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    address_id UUID REFERENCES addresses(id),
    payment_method_id UUID REFERENCES payment_methods(id),

    -- Amounts in cents
    subtotal INTEGER NOT NULL,
    shipping INTEGER NOT NULL DEFAULT 0,
    tax INTEGER NOT NULL DEFAULT 0,
    total INTEGER NOT NULL,

    status VARCHAR(50) DEFAULT 'pending',  -- pending, paid, shipped, delivered, cancelled

    stripe_payment_intent_id VARCHAR(255),  -- Stripe Payment Intent ID
    tracking_number VARCHAR(255),

    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Order items table (snapshot of products at time of order)
CREATE TABLE order_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID REFERENCES orders(id) ON DELETE CASCADE,
    product_id UUID REFERENCES products(id),

    -- Snapshot product details (in case product changes later)
    product_name VARCHAR(255),
    product_description TEXT,
    price INTEGER NOT NULL,  -- Price at time of order
    quantity INTEGER NOT NULL,

    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Indexes for performance
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_fingerprint ON users(fingerprint);
CREATE INDEX idx_carts_user_id ON carts(user_id);
CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_payment_methods_user_id ON payment_methods(user_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

GORM Model Examples

// models/user.go
package models

import (
    "time"
    "gorm.io/gorm"
    "github.com/google/uuid"
)

type User struct {
    ID               uuid.UUID      `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
    Fingerprint      string         `gorm:"uniqueIndex;size:255"`
    Email            string         `gorm:"uniqueIndex;size:255"`
    Name             string         `gorm:"size:255"`
    StripeCustomerID string         `gorm:"size:255"`
    CreatedAt        time.Time
    UpdatedAt        time.Time
    DeletedAt        gorm.DeletedAt `gorm:"index"`
}

// models/product.go
type Product struct {
    ID          uuid.UUID      `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
    Name        string         `gorm:"size:255;not null" json:"name"`
    Description string         `gorm:"type:text" json:"description"`
    RoastType   string         `gorm:"size:100" json:"roast_type"`
    Ounces      int            `json:"ounces"`
    BeanType    string         `gorm:"size:100" json:"bean_type"`
    Price       int            `gorm:"not null" json:"price"` // cents
    Color       string         `gorm:"size:7" json:"color"`
    ImageURL    string         `gorm:"size:500" json:"image_url"`
    Stock       int            `gorm:"default:0" json:"stock"`
    Active      bool           `gorm:"default:true" json:"active"`
    CreatedAt   time.Time      `json:"created_at"`
    UpdatedAt   time.Time      `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"-"`
}

// models/cart.go
type Cart struct {
    ID        uuid.UUID  `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
    UserID    uuid.UUID  `gorm:"type:uuid;not null"`
    User      User       `gorm:"foreignKey:UserID"`
    Items     []CartItem `gorm:"foreignKey:CartID"`
    CreatedAt time.Time
    UpdatedAt time.Time
}

type CartItem struct {
    ID        uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
    CartID    uuid.UUID `gorm:"type:uuid;not null"`
    ProductID uuid.UUID `gorm:"type:uuid;not null"`
    Product   Product   `gorm:"foreignKey:ProductID"`
    Quantity  int       `gorm:"not null;default:1"`
    CreatedAt time.Time
    UpdatedAt time.Time
}

API Specifications

API Design Principles

  1. RESTful routes - Use standard HTTP methods (GET, POST, PUT, DELETE)
  2. JSON responses - All responses in JSON format
  3. Consistent error format - See Error Handling section
  4. JWT authentication - Bearer token in Authorization header
  5. Versioning - All routes prefixed with /api/v1/
  6. Pagination - Use page and limit query params

API Routes

Authentication

POST   /api/v1/auth/register      - Create new user
POST   /api/v1/auth/login         - Get JWT token
POST   /api/v1/auth/refresh       - Refresh token
GET    /api/v1/auth/me            - Get current user (requires auth)
PUT    /api/v1/auth/me            - Update current user (requires auth)

Products

GET    /api/v1/products           - List all products (paginated)
GET    /api/v1/products/:id       - Get single product
POST   /api/v1/products           - Create product (admin only)
PUT    /api/v1/products/:id       - Update product (admin only)
DELETE /api/v1/products/:id       - Delete product (admin only)

Cart

GET    /api/v1/cart               - Get current user's cart
POST   /api/v1/cart/items         - Add item to cart
PUT    /api/v1/cart/items/:id     - Update item quantity
DELETE /api/v1/cart/items/:id     - Remove item from cart
DELETE /api/v1/cart               - Clear entire cart

Payment Methods

GET    /api/v1/payment-methods    - List saved payment methods
POST   /api/v1/payment-methods    - Save new payment method
DELETE /api/v1/payment-methods/:id - Remove payment method

Addresses

GET    /api/v1/addresses          - List user's addresses
POST   /api/v1/addresses          - Create new address
PUT    /api/v1/addresses/:id      - Update address
DELETE /api/v1/addresses/:id      - Delete address

Orders

GET    /api/v1/orders             - List user's orders
GET    /api/v1/orders/:id         - Get order details
POST   /api/v1/orders             - Create new order (checkout)

Request/Response Examples

GET /api/v1/products

Response (200 OK):

{
  "success": true,
  "data": {
    "products": [
      {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "name": "Espresso",
        "description": "Bold coffee...",
        "roast_type": "Dark Roast",
        "ounces": 2,
        "bean_type": "Arabica",
        "price": 350,
        "color": "#8B4513",
        "stock": 100,
        "created_at": "2024-01-01T00:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "limit": 20,
      "total": 6,
      "total_pages": 1
    }
  }
}

POST /api/v1/cart/items

Request:

{
  "product_id": "123e4567-e89b-12d3-a456-426614174000",
  "quantity": 2
}

Response (201 Created):

{
  "success": true,
  "data": {
    "cart": {
      "id": "cart-uuid",
      "items": [
        {
          "id": "item-uuid",
          "product": { /* full product object */ },
          "quantity": 2
        }
      ],
      "subtotal": 700
    }
  }
}

POST /api/v1/orders (Checkout)

Request:

{
  "address_id": "address-uuid",
  "payment_method_id": "pm-uuid"
}

Response (201 Created):

{
  "success": true,
  "data": {
    "order": {
      "id": "order-uuid",
      "subtotal": 700,
      "shipping": 500,
      "tax": 0,
      "total": 1200,
      "status": "pending",
      "items": [ /* order items */ ]
    }
  }
}

Code Organization

Directory Structure

terminalShop/
├── main.go                 # Entry point (SSH server)
├── server.go               # Current TUI entry point
├── model.go                # Bubbletea TUI models and views
│
├── api/                    # Backend API server
│   ├── main.go            # API server entry point
│   ├── routes/
│   │   ├── auth.go        # Auth routes
│   │   ├── products.go    # Product routes
│   │   ├── cart.go        # Cart routes
│   │   └── orders.go      # Order routes
│   ├── middleware/
│   │   ├── auth.go        # JWT auth middleware
│   │   ├── logger.go      # Request logging
│   │   └── cors.go        # CORS middleware
│   └── handlers/
│       ├── auth.go        # Auth handler functions
│       ├── products.go    # Product handlers
│       └── cart.go        # Cart handlers
│
├── pkg/                    # Shared packages
│   ├── models/            # Database models (GORM)
│   │   ├── user.go
│   │   ├── product.go
│   │   ├── cart.go
│   │   └── order.go
│   ├── database/          # Database connection
│   │   └── db.go
│   ├── apiclient/         # API client for TUI
│   │   └── client.go
│   ├── auth/              # Auth utilities
│   │   ├── jwt.go         # JWT generation/validation
│   │   └── password.go    # Password hashing
│   └── utils/             # Helper functions
│       ├── response.go    # Standard response helpers
│       └── validation.go  # Validation helpers
│
├── migrations/            # Database migrations
│   ├── 001_initial.sql
│   └── migrate.go
│
├── config/               # Configuration
│   └── config.go        # Load env vars
│
├── tests/               # Tests
│   ├── api/
│   └── integration/
│
├── .env                 # Environment variables (not in git)
├── .env.example         # Example env file
├── go.mod
├── go.sum
├── README.md
└── MODELS.md           # This file

Package Organization Rules

  1. One responsibility per package - Each package should have a clear, single purpose
  2. No circular dependencies - Organize imports in a clear hierarchy
  3. Shared code in pkg/ - Anything used by multiple packages goes in pkg/
  4. Keep handlers thin - Business logic goes in separate service functions
  5. Models package is database-only - No business logic in model structs

Code Style Guide

General Go Style

Follow the official Go Code Review Comments and Effective Go.

Specific Rules for This Project

1. Imports Organization

Always organize imports in three groups, separated by blank lines:

import (
    // Standard library
    "context"
    "fmt"
    "net/http"

    // External dependencies
    "github.com/go-chi/chi/v5"
    "gorm.io/gorm"

    // Internal packages
    "github.com/yourusername/terminalshop/pkg/models"
    "github.com/yourusername/terminalshop/pkg/utils"
)

2. Naming Conventions

  • Variables: camelCase for local variables, PascalCase for exported
  • Constants: PascalCase or SCREAMING_SNAKE_CASE for groups
  • Functions: PascalCase for exported, camelCase for private
  • Interfaces: Suffix with -er if single method (e.g., Reader, Writer)
  • Structs: PascalCase, descriptive nouns
  • Files: snake_case.go
// Good
type UserService struct {
    db *gorm.DB
}

func (s *UserService) CreateUser(ctx context.Context, name string) (*models.User, error) {
    // ...
}

// Bad
type userservice struct { } // should be PascalCase if exported
func (s *UserService) create_user() { } // should be camelCase if private

3. Error Messages

  • Start with lowercase
  • No punctuation at the end
  • Include context about what failed
// Good
return nil, fmt.Errorf("failed to create user: %w", err)
return nil, errors.New("user not found")

// Bad
return nil, fmt.Errorf("Error creating user: %v", err)  // no caps, use %w
return nil, errors.New("User not found.")  // no caps, no period

4. Function Organization

Order functions in files logically:

  1. Exported types/constants
  2. Constructor functions (New...)
  3. Public methods (alphabetically)
  4. Private methods (alphabetically)
  5. Helper functions
// 1. Types
type UserHandler struct {
    db *gorm.DB
}

// 2. Constructor
func NewUserHandler(db *gorm.DB) *UserHandler {
    return &UserHandler{db: db}
}

// 3. Public methods
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    // ...
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    // ...
}

// 4. Private methods
func (h *UserHandler) validateUser(user *models.User) error {
    // ...
}

// 5. Helper functions
func hashPassword(password string) (string, error) {
    // ...
}

5. Comment Style

  • Use complete sentences for package and function comments
  • Start comments with the name of the thing being described
  • Use // for single-line, /* */ for multi-line only when needed
// UserHandler handles HTTP requests related to users.
type UserHandler struct {
    db *gorm.DB
}

// CreateUser creates a new user in the database and returns it.
// Returns an error if validation fails or database operation fails.
func (h *UserHandler) CreateUser(ctx context.Context, user *models.User) error {
    // Validate user input
    if err := h.validateUser(user); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }

    // Create user in database
    if err := h.db.Create(user).Error; err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }

    return nil
}

6. Struct Tags

Always include JSON tags and use snake_case for JSON field names:

type Product struct {
    ID          uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
    Name        string    `gorm:"size:255" json:"name"`
    Price       int       `gorm:"not null" json:"price"`
    RoastType   string    `gorm:"size:100" json:"roast_type"`  // snake_case!
    CreatedAt   time.Time `json:"created_at"`
}

7. Context Usage

Always pass context.Context as the first parameter:

// Good
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) {
    return s.db.WithContext(ctx).First(&user, id).Error
}

// Bad
func (s *UserService) GetUser(id uuid.UUID) (*models.User, error) {
    // No context!
}

Error Handling

Principles

  1. Always handle errors - Never ignore errors with _
  2. Wrap errors - Use fmt.Errorf("context: %w", err) to add context
  3. Don't panic - Only panic in main() or init() for unrecoverable errors
  4. Return early - Use guard clauses and early returns

Standard Error Response Format (API)

All API errors should follow this format:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input data",
    "details": {
      "email": "must be a valid email address",
      "password": "must be at least 8 characters"
    }
  }
}

Error Response Helper

// pkg/utils/response.go
package utils

import (
    "encoding/json"
    "net/http"
)

type ErrorResponse struct {
    Success bool   `json:"success"`
    Error   Error  `json:"error"`
}

type Error struct {
    Code    string                 `json:"code"`
    Message string                 `json:"message"`
    Details map[string]interface{} `json:"details,omitempty"`
}

func RespondError(w http.ResponseWriter, code int, errCode, message string, details map[string]interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(ErrorResponse{
        Success: false,
        Error: Error{
            Code:    errCode,
            Message: message,
            Details: details,
        },
    })
}

func RespondSuccess(w http.ResponseWriter, code int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "success": true,
        "data":    data,
    })
}

Common Error Codes

const (
    ErrCodeValidation      = "VALIDATION_ERROR"
    ErrCodeUnauthorized    = "UNAUTHORIZED"
    ErrCodeForbidden       = "FORBIDDEN"
    ErrCodeNotFound        = "NOT_FOUND"
    ErrCodeConflict        = "CONFLICT"
    ErrCodeInternal        = "INTERNAL_ERROR"
    ErrCodeBadRequest      = "BAD_REQUEST"
    ErrCodePaymentFailed   = "PAYMENT_FAILED"
)

Error Handling Pattern (API Handlers)

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
    // Extract ID from URL
    productID := chi.URLParam(r, "id")
    if productID == "" {
        utils.RespondError(w, http.StatusBadRequest, ErrCodeBadRequest, "product ID required", nil)
        return
    }

    // Parse UUID
    id, err := uuid.Parse(productID)
    if err != nil {
        utils.RespondError(w, http.StatusBadRequest, ErrCodeValidation, "invalid product ID", nil)
        return
    }

    // Get product from database
    product, err := h.productService.GetProduct(r.Context(), id)
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            utils.RespondError(w, http.StatusNotFound, ErrCodeNotFound, "product not found", nil)
            return
        }
        // Log the actual error internally
        log.Printf("failed to get product: %v", err)
        utils.RespondError(w, http.StatusInternalServerError, ErrCodeInternal, "internal server error", nil)
        return
    }

    // Success response
    utils.RespondSuccess(w, http.StatusOK, product)
}

Error Handling in TUI

For the TUI, errors should be displayed to the user in a friendly way:

// In model.go Update() function
switch msg := msg.(type) {
case error:
    m.errorMessage = api.GetErrorMessage(msg)
    m.showError = true
    return m, nil
}

// In View() function
func (m model) View() string {
    if m.showError {
        errorBox := lipgloss.NewStyle().
            Foreground(lipgloss.Color("#FF0000")).
            Bold(true).
            Render("Error: " + m.errorMessage)
        return errorBox + "\n" + m.normalView()
    }
    return m.normalView()
}

Testing Standards

Testing Strategy

  1. Unit tests for business logic and utilities
  2. Integration tests for API endpoints with database
  3. No tests for TUI initially (focus on API)

Test File Organization

  • Test files live next to the code they test
  • Name: <file>_test.go
  • Use table-driven tests for multiple cases

Example Test Structure

// api/handlers/product_test.go
package handlers

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestProductHandler_GetProduct(t *testing.T) {
    tests := []struct {
        name           string
        productID      string
        setupMock      func(*mockDB)
        expectedStatus int
        expectedError  string
    }{
        {
            name:           "success",
            productID:      "valid-uuid",
            setupMock:      func(m *mockDB) { /* setup */ },
            expectedStatus: 200,
        },
        {
            name:           "not found",
            productID:      "nonexistent-uuid",
            setupMock:      func(m *mockDB) { /* setup */ },
            expectedStatus: 404,
            expectedError:  "NOT_FOUND",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Test implementation
            // Use require for setup assertions
            // Use assert for test assertions
        })
    }
}

Testing Libraries

  • testing - Standard library
  • github.com/stretchr/testify - Assertions and mocking
  • Test database: Use in-memory SQLite or Docker PostgreSQL

Common Patterns

Pattern 1: Service Layer

Separate business logic from HTTP handlers:

// pkg/services/user_service.go
type UserService struct {
    db *gorm.DB
}

func NewUserService(db *gorm.DB) *UserService {
    return &UserService{db: db}
}

func (s *UserService) CreateUser(ctx context.Context, name, email string) (*models.User, error) {
    user := &models.User{
        Name:  name,
        Email: email,
    }

    if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
        return nil, fmt.Errorf("failed to create user: %w", err)
    }

    return user, nil
}

// api/handlers/user_handler.go
type UserHandler struct {
    userService *services.UserService
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    // Parse request
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        utils.RespondError(w, 400, ErrCodeBadRequest, "invalid request body", nil)
        return
    }

    // Call service
    user, err := h.userService.CreateUser(r.Context(), req.Name, req.Email)
    if err != nil {
        // Handle error
        return
    }

    utils.RespondSuccess(w, 201, user)
}

Pattern 2: Middleware for Auth

// api/middleware/auth.go
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Extract token from Authorization header
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                utils.RespondError(w, 401, ErrCodeUnauthorized, "missing auth token", nil)
                return
            }

            // Validate token
            tokenString := strings.TrimPrefix(authHeader, "Bearer ")
            token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
                return []byte(jwtSecret), nil
            })

            if err != nil || !token.Valid {
                utils.RespondError(w, 401, ErrCodeUnauthorized, "invalid token", nil)
                return
            }

            // Extract user ID from claims
            claims := token.Claims.(jwt.MapClaims)
            userID := claims["user_id"].(string)

            // Add to context
            ctx := context.WithValue(r.Context(), "user_id", userID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// Usage in routes
r.Group(func(r chi.Router) {
    r.Use(middleware.AuthMiddleware(jwtSecret))
    r.Get("/api/v1/cart", handlers.GetCart)
    r.Post("/api/v1/cart/items", handlers.AddCartItem)
})

Pattern 3: Repository Pattern (Optional)

For more complex queries, separate database access:

// pkg/repositories/product_repository.go
type ProductRepository struct {
    db *gorm.DB
}

func (r *ProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) {
    var product models.Product
    err := r.db.WithContext(ctx).Where("id = ? AND active = ?", id, true).First(&product).Error
    return &product, err
}

func (r *ProductRepository) List(ctx context.Context, page, limit int) ([]models.Product, int64, error) {
    var products []models.Product
    var total int64

    offset := (page - 1) * limit

    r.db.Model(&models.Product{}).Where("active = ?", true).Count(&total)
    err := r.db.WithContext(ctx).
        Where("active = ?", true).
        Limit(limit).
        Offset(offset).
        Find(&products).Error

    return products, total, err
}

Pattern 4: Bubbletea View Switching

// In model.go
type page int

const (
    shopPage page = iota
    cartPage
    accountPage
    paymentPage
)

type model struct {
    currentPage page
    // ... other fields
}

func (m model) SwitchToPage(p page) (model, tea.Cmd) {
    m.currentPage = p
    // Reset page-specific state
    switch p {
    case shopPage:
        m.cursor = 0
    case cartPage:
        m.cartCursor = 0
    // ...
    }
    return m, nil
}

func (m model) View() string {
    header := m.buildHeader()
    footer := m.buildFooter()

    var content string
    switch m.currentPage {
    case shopPage:
        content = m.buildShopView()
    case cartPage:
        content = m.buildCartView()
    case accountPage:
        content = m.buildAccountView()
    default:
        content = "Unknown page"
    }

    return lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}

Configuration Management

Environment Variables

Use a .env file for local development:

# .env (DO NOT COMMIT)
DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop
JWT_SECRET=your-super-secret-jwt-key-change-this
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
API_PORT=8080
SSH_PORT=2222
ENVIRONMENT=development

Config Package

// config/config.go
package config

import (
    "os"
    "github.com/joho/godotenv"
)

type Config struct {
    DatabaseURL      string
    JWTSecret        string
    StripeSecretKey  string
    StripePublicKey  string
    APIPort          string
    SSHPort          string
    Environment      string
}

func Load() (*Config, error) {
    // Load .env file in development
    if os.Getenv("ENVIRONMENT") != "production" {
        godotenv.Load()
    }

    return &Config{
        DatabaseURL:      os.Getenv("DATABASE_URL"),
        JWTSecret:        os.Getenv("JWT_SECRET"),
        StripeSecretKey:  os.Getenv("STRIPE_SECRET_KEY"),
        StripePublicKey:  os.Getenv("STRIPE_PUBLIC_KEY"),
        APIPort:          getEnvOrDefault("API_PORT", "8080"),
        SSHPort:          getEnvOrDefault("SSH_PORT", "2222"),
        Environment:      getEnvOrDefault("ENVIRONMENT", "development"),
    }, nil
}

func getEnvOrDefault(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

Security Best Practices

  1. Never commit secrets - Use .env and add to .gitignore
  2. Hash passwords - Use bcrypt with cost >= 12
  3. Validate all inputs - Never trust user input
  4. Use parameterized queries - GORM does this automatically
  5. HTTPS in production - Always use TLS
  6. Rate limiting - Add to API endpoints
  7. CORS configuration - Only allow trusted origins

Reference Implementation

The terminal-shop-source/ directory contains the complete terminal.shop codebase. When in doubt:

  1. Check similar functionality in their code
  2. Copy patterns, not code verbatim - adapt to our stack
  3. Understand before implementing - don't blindly copy

Key files to reference:

  • packages/go/pkg/tui/*.go - TUI views and components
  • packages/go/pkg/api/api.go - API client integration
  • packages/core/src/ - Backend business logic (TypeScript, but concepts apply)

Quick Reference Checklist

Before writing new code, ask:

  • Does this follow the file organization structure?
  • Are imports organized correctly?
  • Are errors wrapped with context?
  • Are all errors handled?
  • Does this need a test?
  • Are naming conventions followed?
  • Is the API response in the standard format?
  • Are database queries using context?
  • Is sensitive data in environment variables?
  • Does this match patterns from terminal.shop reference code?

Remember: Consistency is more important than perfection. Follow these patterns throughout the codebase, and reference this document frequently!