687 lines
15 KiB
Markdown
687 lines
15 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
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`
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```text
|
|
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`
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```text
|
|
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`
|
|
|
|
```typescript
|
|
// 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**:
|
|
|
|
```text
|
|
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:
|
|
|
|
```bash
|
|
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**:
|
|
|
|
```text
|
|
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**:
|
|
|
|
```text
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
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
|