playbook/outfitter-agents/plugins/outfitter/skills/tdd/examples/feature-implementation.md

15 KiB

TDD Session: Feature Implementation

Complete TDD session implementing user authentication feature from scratch.

Session Setup

Feature: User authentication with email/password Tech Stack: TypeScript, Bun, discriminated unions for results Starting Point: No existing code Duration: ~45 minutes (3 RED-GREEN-REFACTOR cycles)

Task State Tracking

Initial todos:

1. Write failing test for user authentication [in_progress]
2. Implement authentication to pass tests [pending]
3. Refactor authentication code [pending]
4. Verify implementation [pending]

Cycle 1: Basic Authentication

RED Phase (5 min)

Starting confidence: ▓░░░░ → Writing tests to define interface

Created: src/auth/authenticate.test.ts

import { describe, test, expect } from 'bun:test'
import { authenticate } from './authenticate'

describe('authenticate', () => {
  const validCreds = {
    email: 'user@example.com',
    password: 'ValidPass123!',
  } as const

  test('returns success result with valid credentials', async () => {
    const result = await authenticate(validCreds)

    expect(result.type).toBe('success')
    if (result.type === 'success') {
      expect(result.user.email).toBe(validCreds.email)
    }
  })

  test('returns error result with invalid credentials', async () => {
    const result = await authenticate({
      email: 'wrong@example.com',
      password: 'wrong',
    })

    expect(result.type).toBe('error')
    if (result.type === 'error') {
      expect(result.code).toBe('INVALID_CREDENTIALS')
    }
  })

  test('returns error result with empty password', async () => {
    const result = await authenticate({
      email: 'user@example.com',
      password: '',
    })

    expect(result.type).toBe('error')
    if (result.type === 'error') {
      expect(result.code).toBe('MISSING_PASSWORD')
    }
  })
})

Run tests: bun test

authenticate
  ✗ returns success result with valid credentials
    Error: Cannot find module "./authenticate"
  ✗ returns error result with invalid credentials
  ✗ returns error result with empty password

3 failing

Tests fail as expected — no implementation exists yet.

Commit: test: add failing tests for user authentication

Task Update:

1. Write failing test for user authentication [completed]
2. Implement authentication to pass tests [in_progress]
3. Refactor authentication code [pending]
4. Verify implementation [pending]

GREEN Phase (8 min)

Confidence: ▓▓░░░ → Implementing minimal solution

Created: src/auth/authenticate.ts

type User = {
  id: string
  email: string
  passwordHash: string
}

type AuthSuccess = {
  type: 'success'
  user: User
}

type AuthError = {
  type: 'error'
  code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD'
}

type AuthResult = AuthSuccess | AuthError

// Minimal mock database
const users: User[] = [
  {
    id: '1',
    email: 'user@example.com',
    passwordHash: '$2a$10$hashedValidPass123!',
  },
]

async function findUserByEmail(email: string): Promise<User | undefined> {
  return users.find(u => u.email === email)
}

async function comparePassword(password: string, hash: string): Promise<boolean> {
  // Simplified for testing - in production use bcrypt
  return password === 'ValidPass123!' && hash === '$2a$10$hashedValidPass123!'
}

export async function authenticate(credentials: {
  email: string
  password: string
}): Promise<AuthResult> {
  // Check password not empty
  if (!credentials.password) {
    return { type: 'error', code: 'MISSING_PASSWORD' }
  }

  // Find user
  const user = await findUserByEmail(credentials.email)
  if (!user) {
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  // Verify password
  const passwordMatch = await comparePassword(credentials.password, user.passwordHash)
  if (!passwordMatch) {
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  return { type: 'success', user }
}

Run tests: bun test

authenticate
  ✓ returns success result with valid credentials
  ✓ returns error result with invalid credentials
  ✓ returns error result with empty password

3 passing

All tests pass — implementation complete.

Commit: feat: implement user authentication to pass tests

Task Update:

1. Write failing test for user authentication [completed]
2. Implement authentication to pass tests [completed]
3. Refactor authentication code [in_progress]
4. Verify implementation [pending]

REFACTOR Phase (7 min)

Confidence: ▓▓▓░░ → Improving structure and types

Observations:

  • Types scattered throughout file
  • Password comparison hardcoded
  • No clear separation of concerns

Refactored: src/auth/authenticate.ts

// Extract types to top
type User = {
  id: string
  email: string
  passwordHash: string
}

type AuthSuccess = {
  type: 'success'
  user: User
}

type AuthError = {
  type: 'error'
  code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD' | 'INVALID_EMAIL'
}

type AuthResult = AuthSuccess | AuthError

// Extract validation
function validateCredentials(credentials: {
  email: string
  password: string
}): AuthError | null {
  if (!credentials.password) {
    return { type: 'error', code: 'MISSING_PASSWORD' }
  }
  if (!credentials.email.includes('@')) {
    return { type: 'error', code: 'INVALID_EMAIL' }
  }
  return null
}

// Mock database (unchanged)
const users: User[] = [
  {
    id: '1',
    email: 'user@example.com',
    passwordHash: '$2a$10$hashedValidPass123!',
  },
]

async function findUserByEmail(email: string): Promise<User | undefined> {
  return users.find(u => u.email === email)
}

// Extract password verification
async function verifyPassword(password: string, hash: string): Promise<boolean> {
  // Simplified for testing - in production use bcrypt
  return password === 'ValidPass123!' && hash === '$2a$10$hashedValidPass123!'
}

// Cleaner main function
export async function authenticate(credentials: {
  email: string
  password: string
}): Promise<AuthResult> {
  // Validate input
  const validationError = validateCredentials(credentials)
  if (validationError) {
    return validationError
  }

  // Find user
  const user = await findUserByEmail(credentials.email)
  if (!user) {
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  // Verify password
  const isValid = await verifyPassword(credentials.password, user.passwordHash)
  if (!isValid) {
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  return { type: 'success', user }
}

Run tests: bun test

authenticate
  ✓ returns success result with valid credentials
  ✓ returns error result with invalid credentials
  ✓ returns error result with empty password

3 passing

Tests still pass — refactoring successful.

Commit: refactor: extract validation and password verification

Task Update:

1. Write failing test for user authentication [completed]
2. Implement authentication to pass tests [completed]
3. Refactor authentication code [completed]
4. Verify implementation [in_progress]

VERIFY Phase (3 min)

Run full test suite with coverage:

bun test --coverage
authenticate
  ✓ returns success result with valid credentials
  ✓ returns error result with invalid credentials
  ✓ returns error result with empty password

Coverage:
  File              | % Stmts | % Branch | % Funcs | % Lines
  ------------------|---------|----------|---------|--------
  authenticate.ts   |   95.45 |    100   |   100   |   95.45

3 passing

Coverage ≥80% — quality standards met.

Task Update:

1. Write failing test for user authentication [completed]
2. Implement authentication to pass tests [completed]
3. Refactor authentication code [completed]
4. Verify implementation [completed]

Cycle 2: Email Validation

Starting new cycle for email validation edge cases.

Task Update:

1. Write failing test for email validation [in_progress]
2. Implement email validation to pass tests [pending]
3. Refactor email validation [pending]
4. Verify implementation [pending]

RED Phase (4 min)

Add tests for email validation edge cases:

describe('authenticate - email validation', () => {
  test('returns error for invalid email format', async () => {
    const result = await authenticate({
      email: 'not-an-email',
      password: 'ValidPass123!',
    })

    expect(result.type).toBe('error')
    if (result.type === 'error') {
      expect(result.code).toBe('INVALID_EMAIL')
    }
  })

  test('returns error for empty email', async () => {
    const result = await authenticate({
      email: '',
      password: 'ValidPass123!',
    })

    expect(result.type).toBe('error')
    if (result.type === 'error') {
      expect(result.code).toBe('INVALID_EMAIL')
    }
  })
})

Run tests: bun test

authenticate - email validation
  ✓ returns error for invalid email format  # Already passes!
  ✗ returns error for empty email
    Expected code: 'INVALID_EMAIL'
    Received code: 'MISSING_PASSWORD'

1 failing

One test passes (basic email check exists), one fails.

Commit: test: add email validation edge case tests

GREEN Phase (3 min)

Update validation to handle empty email:

function validateCredentials(credentials: {
  email: string
  password: string
}): AuthError | null {
  if (!credentials.email) {
    return { type: 'error', code: 'INVALID_EMAIL' }
  }
  if (!credentials.password) {
    return { type: 'error', code: 'MISSING_PASSWORD' }
  }
  if (!credentials.email.includes('@')) {
    return { type: 'error', code: 'INVALID_EMAIL' }
  }
  return null
}

Run tests: bun test

authenticate - email validation
  ✓ returns error for invalid email format
  ✓ returns error for empty email

All tests passing (5 total)

Commit: feat: validate empty email addresses

REFACTOR Phase (4 min)

Extract email validation to dedicated function:

function isValidEmail(email: string): boolean {
  return email.length > 0 && email.includes('@')
}

function validateCredentials(credentials: {
  email: string
  password: string
}): AuthError | null {
  if (!isValidEmail(credentials.email)) {
    return { type: 'error', code: 'INVALID_EMAIL' }
  }
  if (!credentials.password) {
    return { type: 'error', code: 'MISSING_PASSWORD' }
  }
  return null
}

Run tests: bun test — All passing

Commit: refactor: extract email validation function

VERIFY Phase (2 min)

bun test --coverage

Coverage: 96.2% — excellent.

Cycle 3: Rate Limiting

Implementing rate limiting for failed authentication attempts.

RED Phase (6 min)

Add tests for rate limiting:

describe('authenticate - rate limiting', () => {
  test('allows authentication after successful login', async () => {
    const validCreds = {
      email: 'user@example.com',
      password: 'ValidPass123!',
    }

    const result1 = await authenticate(validCreds)
    const result2 = await authenticate(validCreds)

    expect(result1.type).toBe('success')
    expect(result2.type).toBe('success')
  })

  test('blocks authentication after 3 failed attempts', async () => {
    const invalidCreds = {
      email: 'user@example.com',
      password: 'wrong',
    }

    // 3 failed attempts
    await authenticate(invalidCreds)
    await authenticate(invalidCreds)
    await authenticate(invalidCreds)

    // 4th attempt should be rate limited
    const result = await authenticate(invalidCreds)

    expect(result.type).toBe('error')
    if (result.type === 'error') {
      expect(result.code).toBe('RATE_LIMITED')
    }
  })
})

Run tests: bun test — Rate limit tests fail as expected

Commit: test: add rate limiting tests

GREEN Phase (10 min)

Implement basic rate limiting:

type AuthError = {
  type: 'error'
  code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD' | 'INVALID_EMAIL' | 'RATE_LIMITED'
}

// Track failed attempts
const failedAttempts = new Map<string, number>()

function incrementFailedAttempts(email: string): void {
  const current = failedAttempts.get(email) || 0
  failedAttempts.set(email, current + 1)
}

function resetFailedAttempts(email: string): void {
  failedAttempts.delete(email)
}

function isRateLimited(email: string): boolean {
  const attempts = failedAttempts.get(email) || 0
  return attempts >= 3
}

export async function authenticate(credentials: {
  email: string
  password: string
}): Promise<AuthResult> {
  // Check rate limiting first
  if (isRateLimited(credentials.email)) {
    return { type: 'error', code: 'RATE_LIMITED' }
  }

  // Validate input
  const validationError = validateCredentials(credentials)
  if (validationError) {
    return validationError
  }

  // Find user
  const user = await findUserByEmail(credentials.email)
  if (!user) {
    incrementFailedAttempts(credentials.email)
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  // Verify password
  const isValid = await verifyPassword(credentials.password, user.passwordHash)
  if (!isValid) {
    incrementFailedAttempts(credentials.email)
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  // Reset on success
  resetFailedAttempts(credentials.email)
  return { type: 'success', user }
}

Run tests: bun test — All 7 tests passing

Commit: feat: implement rate limiting for failed authentication

REFACTOR Phase (6 min)

Extract rate limiting to separate module for testability:

Created: src/auth/rate-limiter.ts

export class RateLimiter {
  private attempts = new Map<string, number>()

  constructor(private maxAttempts: number = 3) {}

  increment(key: string): void {
    const current = this.attempts.get(key) || 0
    this.attempts.set(key, current + 1)
  }

  reset(key: string): void {
    this.attempts.delete(key)
  }

  isLimited(key: string): boolean {
    const attempts = this.attempts.get(key) || 0
    return attempts >= this.maxAttempts
  }
}

Update authenticate.ts to use class:

import { RateLimiter } from './rate-limiter'

const rateLimiter = new RateLimiter(3)

export async function authenticate(credentials: {
  email: string
  password: string
}): Promise<AuthResult> {
  // Check rate limiting first
  if (rateLimiter.isLimited(credentials.email)) {
    return { type: 'error', code: 'RATE_LIMITED' }
  }

  // ... rest unchanged ...

  // On failure
  if (!isValid) {
    rateLimiter.increment(credentials.email)
    return { type: 'error', code: 'INVALID_CREDENTIALS' }
  }

  // On success
  rateLimiter.reset(credentials.email)
  return { type: 'success', user }
}

Run tests: bun test — All passing

Commit: refactor: extract rate limiter to separate class

VERIFY Phase (5 min)

Final verification with mutation testing:

bun test --coverage
bun x stryker run

Results:

  • Coverage: 94.8%
  • Mutation score: 78.3%
  • All tests passing

Task: All completed

Session Summary

Duration: 45 minutes Cycles: 3 complete RED-GREEN-REFACTOR cycles Tests: 7 tests, all passing Coverage: 94.8% line coverage Mutation: 78.3% mutation score

Features implemented:

  1. Basic authentication with email/password
  2. Email validation
  3. Rate limiting for failed attempts

Code quality:

  • All types explicit
  • Functions single-purpose
  • Tests cover happy path and edge cases
  • Mutation testing verifies test quality

Next steps:

  • Add integration tests with real database
  • Implement actual bcrypt password hashing
  • Add time-based rate limit expiration