javascript

exercises

exercises.js
/**
 * 20.3 Mocking & Test Doubles - Exercises
 *
 * Practice creating and using test doubles
 */

// ============================================
// EXERCISE 1: Create a Complete Spy
// ============================================

/**
 * Implement a full-featured spy with all these capabilities:
 * - Track all calls with arguments
 * - Track return values
 * - Track thrown errors
 * - Support chaining calls with different return values
 * - Support conditional returns based on arguments
 */

function createAdvancedSpy() {
  // Your implementation here
}

/*
// SOLUTION:
function createAdvancedSpy(defaultImpl = () => undefined) {
    const calls = [];
    const returnSequence = [];
    const argMatchers = [];
    let callIndex = 0;
    
    const spy = function(...args) {
        const call = {
            args,
            thisArg: this,
            timestamp: Date.now(),
            callNumber: calls.length + 1
        };
        
        try {
            // Check argument matchers first
            for (const matcher of argMatchers) {
                if (matcher.matches(args)) {
                    call.returned = matcher.returns;
                    calls.push(call);
                    if (matcher.throws) {
                        call.threw = matcher.throws;
                        throw matcher.throws;
                    }
                    return matcher.returns;
                }
            }
            
            // Check return sequence
            if (callIndex < returnSequence.length) {
                const preset = returnSequence[callIndex++];
                if (preset.throws) {
                    call.threw = preset.throws;
                    calls.push(call);
                    throw preset.throws;
                }
                call.returned = preset.returns;
                calls.push(call);
                return preset.returns;
            }
            
            // Fall back to default implementation
            const result = defaultImpl.apply(this, args);
            call.returned = result;
            calls.push(call);
            return result;
            
        } catch (e) {
            if (!call.threw) {
                call.threw = e;
                calls.push(call);
            }
            throw e;
        }
    };
    
    // Call tracking
    Object.defineProperty(spy, 'calls', { get: () => [...calls] });
    Object.defineProperty(spy, 'callCount', { get: () => calls.length });
    
    spy.called = () => calls.length > 0;
    spy.calledOnce = () => calls.length === 1;
    spy.calledTwice = () => calls.length === 2;
    spy.calledThrice = () => calls.length === 3;
    spy.calledTimes = (n) => calls.length === n;
    spy.notCalled = () => calls.length === 0;
    
    spy.firstCall = () => calls[0] || null;
    spy.secondCall = () => calls[1] || null;
    spy.lastCall = () => calls[calls.length - 1] || null;
    spy.getCall = (n) => calls[n] || null;
    
    spy.calledWith = (...expectedArgs) => {
        return calls.some(call => {
            if (call.args.length !== expectedArgs.length) return false;
            return expectedArgs.every((arg, i) => {
                if (arg === spy.any) return true;
                return JSON.stringify(arg) === JSON.stringify(call.args[i]);
            });
        });
    };
    
    spy.alwaysCalledWith = (...expectedArgs) => {
        if (calls.length === 0) return false;
        return calls.every(call => {
            if (call.args.length !== expectedArgs.length) return false;
            return expectedArgs.every((arg, i) => {
                if (arg === spy.any) return true;
                return JSON.stringify(arg) === JSON.stringify(call.args[i]);
            });
        });
    };
    
    spy.neverCalledWith = (...args) => !spy.calledWith(...args);
    
    spy.returned = (value) => {
        return calls.some(call => 
            JSON.stringify(call.returned) === JSON.stringify(value)
        );
    };
    
    spy.threw = (error) => {
        if (error === undefined) {
            return calls.some(call => call.threw !== undefined);
        }
        return calls.some(call => 
            call.threw && call.threw.message === error.message
        );
    };
    
    // Setup methods
    spy.returns = (value) => {
        returnSequence.push({ returns: value });
        return spy;
    };
    
    spy.throws = (error) => {
        returnSequence.push({ throws: error });
        return spy;
    };
    
    spy.onCall = (n) => ({
        returns(value) {
            while (returnSequence.length <= n) {
                returnSequence.push({ returns: undefined });
            }
            returnSequence[n] = { returns: value };
            return spy;
        },
        throws(error) {
            while (returnSequence.length <= n) {
                returnSequence.push({ returns: undefined });
            }
            returnSequence[n] = { throws: error };
            return spy;
        }
    });
    
    spy.withArgs = (...args) => {
        const matcher = {
            args,
            returns: undefined,
            throws: null,
            matches(actualArgs) {
                if (actualArgs.length !== args.length) return false;
                return args.every((arg, i) => {
                    if (arg === spy.any) return true;
                    return JSON.stringify(arg) === JSON.stringify(actualArgs[i]);
                });
            }
        };
        
        argMatchers.push(matcher);
        
        return {
            returns(value) {
                matcher.returns = value;
                return spy;
            },
            throws(error) {
                matcher.throws = error;
                return spy;
            }
        };
    };
    
    spy.reset = () => {
        calls.length = 0;
        returnSequence.length = 0;
        argMatchers.length = 0;
        callIndex = 0;
    };
    
    spy.resetHistory = () => {
        calls.length = 0;
        callIndex = 0;
    };
    
    // Special matcher
    spy.any = Symbol('any');
    
    return spy;
}

// Test the advanced spy
function testAdvancedSpy() {
    console.log('=== Advanced Spy Tests ===\n');
    
    const spy = createAdvancedSpy();
    
    // Test return sequence
    spy.returns(1).returns(2).returns(3);
    console.assert(spy() === 1, 'First call returns 1');
    console.assert(spy() === 2, 'Second call returns 2');
    console.assert(spy() === 3, 'Third call returns 3');
    console.log('✓ Return sequence works');
    
    spy.reset();
    
    // Test withArgs
    spy.withArgs('hello').returns('world');
    spy.withArgs(1, 2).returns(3);
    
    console.assert(spy('hello') === 'world', 'Returns world for hello');
    console.assert(spy(1, 2) === 3, 'Returns 3 for 1, 2');
    console.log('✓ Argument matching works');
    
    spy.reset();
    
    // Test call tracking
    spy('a', 'b');
    spy('c');
    
    console.assert(spy.calledTwice(), 'Called twice');
    console.assert(spy.calledWith('a', 'b'), 'Called with a, b');
    console.assert(spy.firstCall().args[0] === 'a', 'First call correct');
    console.log('✓ Call tracking works');
    
    console.log('\n=== Advanced Spy Tests Complete ===\n');
}
*/

// ============================================
// EXERCISE 2: Mock HTTP Client
// ============================================

/**
 * Create a mock HTTP client that can:
 * - Register responses for specific URLs
 * - Match URLs with patterns
 * - Track all requests made
 * - Simulate network delays
 * - Simulate network errors
 */

class MockHttpClient {
  // Your implementation here
}

/*
// SOLUTION:
class MockHttpClient {
    constructor() {
        this.routes = new Map();
        this.requests = [];
        this.defaultDelay = 0;
        this.isOffline = false;
    }
    
    // Register exact URL match
    when(method, url) {
        const key = `${method.toUpperCase()}:${url}`;
        const config = {
            response: null,
            delay: this.defaultDelay,
            times: Infinity,
            callCount: 0
        };
        
        this.routes.set(key, config);
        
        return {
            respond(status, body, headers = {}) {
                config.response = { status, body, headers };
                return this;
            },
            delay(ms) {
                config.delay = ms;
                return this;
            },
            times(n) {
                config.times = n;
                return this;
            },
            once() {
                config.times = 1;
                return this;
            },
            networkError() {
                config.networkError = true;
                return this;
            },
            timeout() {
                config.timeout = true;
                return this;
            }
        };
    }
    
    whenGet(url) {
        return this.when('GET', url);
    }
    
    whenPost(url) {
        return this.when('POST', url);
    }
    
    whenPut(url) {
        return this.when('PUT', url);
    }
    
    whenDelete(url) {
        return this.when('DELETE', url);
    }
    
    // Pattern matching
    whenPattern(method, pattern) {
        const config = {
            pattern: new RegExp(pattern),
            method: method.toUpperCase(),
            response: null,
            delay: this.defaultDelay
        };
        
        this.routes.set(`PATTERN:${pattern}`, config);
        
        return {
            respond(status, body, headers = {}) {
                config.response = { status, body, headers };
                return this;
            },
            delay(ms) {
                config.delay = ms;
                return this;
            }
        };
    }
    
    setOffline(offline) {
        this.isOffline = offline;
    }
    
    setDefaultDelay(ms) {
        this.defaultDelay = ms;
    }
    
    async request(method, url, options = {}) {
        const request = {
            method: method.toUpperCase(),
            url,
            options,
            timestamp: Date.now()
        };
        
        this.requests.push(request);
        
        // Simulate offline
        if (this.isOffline) {
            throw new Error('Network error: offline');
        }
        
        // Find matching route
        const key = `${method.toUpperCase()}:${url}`;
        let config = this.routes.get(key);
        
        // Try pattern matching if no exact match
        if (!config) {
            for (const [k, v] of this.routes) {
                if (k.startsWith('PATTERN:') && 
                    v.method === method.toUpperCase() &&
                    v.pattern.test(url)) {
                    config = v;
                    break;
                }
            }
        }
        
        // No match found
        if (!config) {
            return {
                status: 404,
                body: { error: 'Not found' },
                headers: {}
            };
        }
        
        // Check if exhausted
        if (config.callCount >= config.times) {
            return {
                status: 404,
                body: { error: 'Not found' },
                headers: {}
            };
        }
        
        config.callCount++;
        
        // Simulate network error
        if (config.networkError) {
            throw new Error('Network error');
        }
        
        // Simulate timeout
        if (config.timeout) {
            throw new Error('Request timeout');
        }
        
        // Simulate delay
        if (config.delay > 0) {
            await new Promise(resolve => setTimeout(resolve, config.delay));
        }
        
        // Return response
        let response = config.response;
        
        // Support dynamic responses
        if (typeof response === 'function') {
            response = response(request);
        }
        
        return response;
    }
    
    async get(url, options) {
        return this.request('GET', url, options);
    }
    
    async post(url, body, options) {
        return this.request('POST', url, { ...options, body });
    }
    
    async put(url, body, options) {
        return this.request('PUT', url, { ...options, body });
    }
    
    async delete(url, options) {
        return this.request('DELETE', url, options);
    }
    
    // Request inspection
    getRequests() {
        return [...this.requests];
    }
    
    getRequestsTo(url) {
        return this.requests.filter(r => r.url === url);
    }
    
    getRequestsByMethod(method) {
        return this.requests.filter(r => r.method === method.toUpperCase());
    }
    
    wasRequested(method, url) {
        return this.requests.some(
            r => r.method === method.toUpperCase() && r.url === url
        );
    }
    
    requestCount() {
        return this.requests.length;
    }
    
    reset() {
        this.routes.clear();
        this.requests = [];
        this.isOffline = false;
    }
    
    verify() {
        const unmatched = [];
        
        for (const [key, config] of this.routes) {
            if (!key.startsWith('PATTERN:') && config.callCount === 0) {
                unmatched.push(key);
            }
        }
        
        if (unmatched.length > 0) {
            throw new Error(
                'Unmatched routes: ' + unmatched.join(', ')
            );
        }
    }
}

// Test the mock HTTP client
async function testMockHttpClient() {
    console.log('=== Mock HTTP Client Tests ===\n');
    
    const http = new MockHttpClient();
    
    // Register responses
    http.whenGet('/api/users')
        .respond(200, [{ id: 1, name: 'John' }]);
    
    http.whenGet('/api/users/1')
        .respond(200, { id: 1, name: 'John' });
    
    http.whenPost('/api/users')
        .respond(201, { id: 2, name: 'Jane' });
    
    // Test requests
    let response = await http.get('/api/users');
    console.assert(response.status === 200, 'GET users returns 200');
    console.log('✓ GET /api/users works');
    
    response = await http.get('/api/users/1');
    console.assert(response.body.name === 'John', 'GET user returns John');
    console.log('✓ GET /api/users/1 works');
    
    response = await http.post('/api/users', { name: 'Jane' });
    console.assert(response.status === 201, 'POST returns 201');
    console.log('✓ POST /api/users works');
    
    // Test request tracking
    console.assert(http.requestCount() === 3, 'Made 3 requests');
    console.assert(http.wasRequested('GET', '/api/users'), 'GET /api/users was called');
    console.log('✓ Request tracking works');
    
    // Test network error
    http.whenGet('/api/error').networkError();
    try {
        await http.get('/api/error');
        console.log('✗ Should have thrown');
    } catch (e) {
        console.log('✓ Network error simulation works');
    }
    
    console.log('\n=== Mock HTTP Client Tests Complete ===\n');
}
*/

// ============================================
// EXERCISE 3: Fake Event Emitter
// ============================================

/**
 * Create a fake EventEmitter that:
 * - Tracks all emitted events
 * - Tracks all registered listeners
 * - Can verify events were emitted
 * - Can verify listener registration
 */

class FakeEventEmitter {
  // Your implementation here
}

/*
// SOLUTION:
class FakeEventEmitter {
    constructor() {
        this.listeners = new Map();
        this.emittedEvents = [];
        this.registrations = [];
    }
    
    on(event, listener) {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, []);
        }
        
        const entry = {
            id: Symbol('listener'),
            listener,
            event,
            once: false,
            registeredAt: Date.now()
        };
        
        this.listeners.get(event).push(entry);
        this.registrations.push({
            type: 'on',
            event,
            listener,
            timestamp: Date.now()
        });
        
        return this;
    }
    
    once(event, listener) {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, []);
        }
        
        const entry = {
            id: Symbol('listener'),
            listener,
            event,
            once: true,
            registeredAt: Date.now()
        };
        
        this.listeners.get(event).push(entry);
        this.registrations.push({
            type: 'once',
            event,
            listener,
            timestamp: Date.now()
        });
        
        return this;
    }
    
    off(event, listener) {
        if (!this.listeners.has(event)) return this;
        
        const eventListeners = this.listeners.get(event);
        const index = eventListeners.findIndex(e => e.listener === listener);
        
        if (index !== -1) {
            eventListeners.splice(index, 1);
            this.registrations.push({
                type: 'off',
                event,
                listener,
                timestamp: Date.now()
            });
        }
        
        return this;
    }
    
    removeAllListeners(event) {
        if (event) {
            this.listeners.delete(event);
        } else {
            this.listeners.clear();
        }
        return this;
    }
    
    emit(event, ...args) {
        const emittedEvent = {
            event,
            args,
            timestamp: Date.now(),
            listenersCalled: 0
        };
        
        this.emittedEvents.push(emittedEvent);
        
        if (!this.listeners.has(event)) {
            return false;
        }
        
        const eventListeners = this.listeners.get(event);
        const toRemove = [];
        
        for (const entry of eventListeners) {
            entry.listener.apply(this, args);
            emittedEvent.listenersCalled++;
            
            if (entry.once) {
                toRemove.push(entry);
            }
        }
        
        // Remove once listeners
        for (const entry of toRemove) {
            const index = eventListeners.indexOf(entry);
            if (index !== -1) {
                eventListeners.splice(index, 1);
            }
        }
        
        return eventListeners.length > 0;
    }
    
    listenerCount(event) {
        if (!this.listeners.has(event)) return 0;
        return this.listeners.get(event).length;
    }
    
    eventNames() {
        return Array.from(this.listeners.keys()).filter(
            k => this.listeners.get(k).length > 0
        );
    }
    
    // Verification methods
    wasEmitted(event) {
        return this.emittedEvents.some(e => e.event === event);
    }
    
    wasEmittedWith(event, ...args) {
        return this.emittedEvents.some(e => {
            if (e.event !== event) return false;
            if (e.args.length !== args.length) return false;
            return args.every((arg, i) => 
                JSON.stringify(arg) === JSON.stringify(e.args[i])
            );
        });
    }
    
    emitCount(event) {
        return this.emittedEvents.filter(e => e.event === event).length;
    }
    
    getEmittedEvents(event) {
        if (event) {
            return this.emittedEvents.filter(e => e.event === event);
        }
        return [...this.emittedEvents];
    }
    
    hasListener(event) {
        return this.listenerCount(event) > 0;
    }
    
    getRegistrations() {
        return [...this.registrations];
    }
    
    reset() {
        this.listeners.clear();
        this.emittedEvents = [];
        this.registrations = [];
    }
    
    resetHistory() {
        this.emittedEvents = [];
        this.registrations = [];
    }
}

// Test the fake event emitter
function testFakeEventEmitter() {
    console.log('=== Fake EventEmitter Tests ===\n');
    
    const emitter = new FakeEventEmitter();
    
    // Test listener registration
    const listener1 = () => {};
    const listener2 = () => {};
    
    emitter.on('event1', listener1);
    emitter.once('event1', listener2);
    
    console.assert(emitter.listenerCount('event1') === 2, 'Two listeners');
    console.assert(emitter.hasListener('event1'), 'Has listener');
    console.log('✓ Listener registration works');
    
    // Test emit
    const calls = [];
    emitter.on('test', (arg) => calls.push(arg));
    
    emitter.emit('test', 'hello');
    emitter.emit('test', 'world');
    
    console.assert(calls.length === 2, 'Listener called twice');
    console.assert(emitter.wasEmitted('test'), 'Event was emitted');
    console.assert(emitter.wasEmittedWith('test', 'hello'), 'Emitted with hello');
    console.assert(emitter.emitCount('test') === 2, 'Emitted twice');
    console.log('✓ Event emission works');
    
    // Test once
    emitter.emit('event1', 'data');  // Both listeners
    emitter.emit('event1', 'data');  // Only listener1 (once removed)
    
    console.assert(emitter.listenerCount('event1') === 1, 'Once listener removed');
    console.log('✓ Once listener removed after emit');
    
    // Test event history
    const events = emitter.getEmittedEvents('test');
    console.assert(events.length === 2, 'Two test events');
    console.log('✓ Event history tracking works');
    
    console.log('\n=== Fake EventEmitter Tests Complete ===\n');
}
*/

// ============================================
// EXERCISE 4: Dependency Injection Container
// ============================================

/**
 * Create a simple DI container for testing that:
 * - Can register real or mock implementations
 * - Can resolve dependencies
 * - Supports singleton and transient lifetimes
 */

class DIContainer {
  // Your implementation here
}

/*
// SOLUTION:
class DIContainer {
    constructor() {
        this.registrations = new Map();
        this.singletons = new Map();
    }
    
    register(name, factory, options = {}) {
        this.registrations.set(name, {
            factory,
            lifetime: options.lifetime || 'transient'
        });
        return this;
    }
    
    registerSingleton(name, factory) {
        return this.register(name, factory, { lifetime: 'singleton' });
    }
    
    registerInstance(name, instance) {
        this.singletons.set(name, instance);
        this.registrations.set(name, {
            factory: () => instance,
            lifetime: 'singleton'
        });
        return this;
    }
    
    registerClass(name, Class, options = {}) {
        return this.register(name, (container) => {
            const deps = (options.inject || []).map(
                dep => container.resolve(dep)
            );
            return new Class(...deps);
        }, options);
    }
    
    resolve(name) {
        const registration = this.registrations.get(name);
        
        if (!registration) {
            throw new Error(`No registration found for: ${name}`);
        }
        
        if (registration.lifetime === 'singleton') {
            if (!this.singletons.has(name)) {
                this.singletons.set(name, registration.factory(this));
            }
            return this.singletons.get(name);
        }
        
        return registration.factory(this);
    }
    
    has(name) {
        return this.registrations.has(name);
    }
    
    mock(name, mockImpl) {
        const original = this.registrations.get(name);
        
        this.register(name, () => mockImpl, { lifetime: 'singleton' });
        this.singletons.set(name, mockImpl);
        
        return {
            restore: () => {
                if (original) {
                    this.registrations.set(name, original);
                    this.singletons.delete(name);
                } else {
                    this.registrations.delete(name);
                    this.singletons.delete(name);
                }
            }
        };
    }
    
    createChild() {
        const child = new DIContainer();
        
        // Copy registrations
        for (const [name, reg] of this.registrations) {
            child.registrations.set(name, reg);
        }
        
        // Share singleton references
        for (const [name, instance] of this.singletons) {
            child.singletons.set(name, instance);
        }
        
        return child;
    }
    
    reset() {
        this.registrations.clear();
        this.singletons.clear();
    }
}

// Test the DI container
function testDIContainer() {
    console.log('=== DI Container Tests ===\n');
    
    const container = new DIContainer();
    
    // Register services
    class Logger {
        log(msg) { console.log('[LOG]', msg); }
    }
    
    class UserRepository {
        constructor(logger) {
            this.logger = logger;
        }
        find(id) {
            this.logger.log(`Finding user ${id}`);
            return { id, name: 'John' };
        }
    }
    
    class UserService {
        constructor(repo, logger) {
            this.repo = repo;
            this.logger = logger;
        }
        getUser(id) {
            this.logger.log(`Getting user ${id}`);
            return this.repo.find(id);
        }
    }
    
    container.registerSingleton('logger', () => new Logger());
    container.registerClass('userRepo', UserRepository, {
        inject: ['logger']
    });
    container.registerClass('userService', UserService, {
        inject: ['userRepo', 'logger']
    });
    
    // Resolve
    const userService = container.resolve('userService');
    console.assert(userService instanceof UserService, 'Resolved UserService');
    console.log('✓ Resolution works');
    
    // Test singleton
    const logger1 = container.resolve('logger');
    const logger2 = container.resolve('logger');
    console.assert(logger1 === logger2, 'Same logger instance');
    console.log('✓ Singleton works');
    
    // Test mocking
    const mockLogger = { log: () => {} };
    const restore = container.mock('logger', mockLogger);
    
    console.assert(container.resolve('logger') === mockLogger, 'Mock registered');
    console.log('✓ Mocking works');
    
    restore.restore();
    console.log('✓ Mock restore works');
    
    console.log('\n=== DI Container Tests Complete ===\n');
}
*/

// ============================================
// EXERCISE 5: Test a Service with Dependencies
// ============================================

/**
 * Test this NotificationService using proper test doubles
 */

class NotificationService {
  constructor(userRepo, emailer, smsClient, logger) {
    this.userRepo = userRepo;
    this.emailer = emailer;
    this.smsClient = smsClient;
    this.logger = logger;
  }

  async notifyUser(userId, message, options = {}) {
    const user = await this.userRepo.findById(userId);

    if (!user) {
      this.logger.warn(`User ${userId} not found`);
      return { success: false, error: 'User not found' };
    }

    const results = { email: null, sms: null };

    // Send email
    if (user.email && options.email !== false) {
      try {
        await this.emailer.send(user.email, 'Notification', message);
        results.email = 'sent';
        this.logger.info(`Email sent to ${user.email}`);
      } catch (e) {
        results.email = 'failed';
        this.logger.error(`Email failed: ${e.message}`);
      }
    }

    // Send SMS
    if (user.phone && options.sms !== false) {
      try {
        await this.smsClient.send(user.phone, message);
        results.sms = 'sent';
        this.logger.info(`SMS sent to ${user.phone}`);
      } catch (e) {
        results.sms = 'failed';
        this.logger.error(`SMS failed: ${e.message}`);
      }
    }

    return {
      success: results.email === 'sent' || results.sms === 'sent',
      results,
    };
  }
}

// Write tests for NotificationService
async function testNotificationService() {
  // Your implementation here
}

/*
// SOLUTION:
async function testNotificationService() {
    console.log('=== NotificationService Tests ===\n');
    
    // Create test doubles
    function createUserRepoStub(users) {
        return {
            findById: async (id) => users.find(u => u.id === id) || null
        };
    }
    
    function createEmailerSpy() {
        const calls = [];
        return {
            calls,
            send: async (to, subject, body) => {
                calls.push({ to, subject, body });
            },
            wasSentTo: (email) => calls.some(c => c.to === email)
        };
    }
    
    function createSmsClientSpy() {
        const calls = [];
        return {
            calls,
            send: async (phone, message) => {
                calls.push({ phone, message });
            }
        };
    }
    
    function createFailingEmailer() {
        return {
            send: async () => {
                throw new Error('Email service unavailable');
            }
        };
    }
    
    function createLoggerMock() {
        return {
            logs: [],
            info: function(msg) { this.logs.push({ level: 'info', msg }); },
            warn: function(msg) { this.logs.push({ level: 'warn', msg }); },
            error: function(msg) { this.logs.push({ level: 'error', msg }); }
        };
    }
    
    // Test 1: Notify user with email and SMS
    console.log('Test 1: Full notification (email + SMS)');
    
    const users1 = [
        { id: 1, email: 'john@example.com', phone: '+1234567890' }
    ];
    
    const userRepo1 = createUserRepoStub(users1);
    const emailer1 = createEmailerSpy();
    const smsClient1 = createSmsClientSpy();
    const logger1 = createLoggerMock();
    
    const service1 = new NotificationService(
        userRepo1, emailer1, smsClient1, logger1
    );
    
    const result1 = await service1.notifyUser(1, 'Hello!');
    
    console.assert(result1.success === true, 'Should succeed');
    console.assert(result1.results.email === 'sent', 'Email sent');
    console.assert(result1.results.sms === 'sent', 'SMS sent');
    console.assert(emailer1.wasSentTo('john@example.com'), 'Email to John');
    console.assert(smsClient1.calls[0].phone === '+1234567890', 'SMS to phone');
    console.log('  ✓ Both email and SMS sent');
    
    // Test 2: User not found
    console.log('\nTest 2: User not found');
    
    const result2 = await service1.notifyUser(999, 'Hello!');
    
    console.assert(result2.success === false, 'Should fail');
    console.assert(result2.error === 'User not found', 'Correct error');
    console.assert(
        logger1.logs.some(l => l.level === 'warn'),
        'Warning logged'
    );
    console.log('  ✓ User not found handled correctly');
    
    // Test 3: Email fails, SMS succeeds
    console.log('\nTest 3: Email failure, SMS success');
    
    const userRepo3 = createUserRepoStub([
        { id: 1, email: 'john@example.com', phone: '+1234567890' }
    ]);
    const emailer3 = createFailingEmailer();
    const smsClient3 = createSmsClientSpy();
    const logger3 = createLoggerMock();
    
    const service3 = new NotificationService(
        userRepo3, emailer3, smsClient3, logger3
    );
    
    const result3 = await service3.notifyUser(1, 'Hello!');
    
    console.assert(result3.success === true, 'Should succeed (SMS worked)');
    console.assert(result3.results.email === 'failed', 'Email failed');
    console.assert(result3.results.sms === 'sent', 'SMS sent');
    console.assert(
        logger3.logs.some(l => l.level === 'error'),
        'Error logged'
    );
    console.log('  ✓ Partial failure handled correctly');
    
    // Test 4: Skip SMS via options
    console.log('\nTest 4: Skip SMS via options');
    
    const userRepo4 = createUserRepoStub([
        { id: 1, email: 'john@example.com', phone: '+1234567890' }
    ]);
    const emailer4 = createEmailerSpy();
    const smsClient4 = createSmsClientSpy();
    const logger4 = createLoggerMock();
    
    const service4 = new NotificationService(
        userRepo4, emailer4, smsClient4, logger4
    );
    
    const result4 = await service4.notifyUser(1, 'Hello!', { sms: false });
    
    console.assert(result4.results.sms === null, 'SMS skipped');
    console.assert(smsClient4.calls.length === 0, 'SMS not called');
    console.log('  ✓ SMS skipped via options');
    
    // Test 5: User with only email
    console.log('\nTest 5: User with only email');
    
    const userRepo5 = createUserRepoStub([
        { id: 1, email: 'john@example.com' }  // No phone
    ]);
    const emailer5 = createEmailerSpy();
    const smsClient5 = createSmsClientSpy();
    const logger5 = createLoggerMock();
    
    const service5 = new NotificationService(
        userRepo5, emailer5, smsClient5, logger5
    );
    
    const result5 = await service5.notifyUser(1, 'Hello!');
    
    console.assert(result5.success === true, 'Should succeed');
    console.assert(result5.results.email === 'sent', 'Email sent');
    console.assert(result5.results.sms === null, 'SMS null (no phone)');
    console.log('  ✓ Email only works correctly');
    
    console.log('\n=== NotificationService Tests Complete ===\n');
}
*/

// ============================================
// RUN EXERCISES
// ============================================

console.log('=== Mocking & Test Doubles Exercises ===');
console.log('');
console.log('Implement the following exercises:');
console.log('1. createAdvancedSpy - Feature-rich spy function');
console.log('2. MockHttpClient - HTTP client for testing');
console.log('3. FakeEventEmitter - Trackable event emitter');
console.log('4. DIContainer - Dependency injection container');
console.log('5. testNotificationService - Test with doubles');
console.log('');
console.log('Uncomment solutions to verify your implementation.');

// Export
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    NotificationService,
  };
}
Exercises - JavaScript Tutorial | DeepML