# ปฏิบัติการ White Box Testing & Unit Testing (Library-Management-v2)

## ปฏิบัติการ White Box Testing & Unit Testing (การทดสอบกล่องขาวและการทดสอบหน่วย)

### Lab Objectives - วัตถุประสงค์

- คำนวณ Cyclomatic Complexity ด้วยมือ (Calculate CC manually)
- Setup Jest และเขียน Unit Tests (Setup and write tests)
- ใช้ Test Doubles (Mocks, Stubs) (Use test doubles)
- Achieve 80%+ code coverage

---

## Exercise 1: Cyclomatic Complexity Calculation - คำนวณความซับซ้อนของวัฏจักร

### **โจทย์ (Task):** วิเคราะห์โค้ดต่อไปนี้จาก Library Management v2

```javascript
// BorrowingController.borrow() - Simplified for Analysis
// จาก: src/controllers/BorrowingController.js

async borrow(req, res) {
  try {
    const { memberId, bookId, borrowDate, dueDate } = req.body;

    // Guard clause 1
    if (!memberId || !bookId || !borrowDate || !dueDate) {
      return res.status(400).json({
        error: "Member ID, book ID, borrow date, and due date are required",
      });
    }

    // Check 2: Member exists and is active
    const member = await Member.findById(memberId);
    if (!member) {
      return res.status(404).json({ error: "Member not found" });
    }
    if (member.status !== "active") {
      return res.status(400).json({ error: "Member is not active" });
    }

    // Check 3: Member borrowing limit
    const borrowCount = await Member.getBorrowingCount(memberId);
    if (borrowCount >= member.max_books) {
      return res.status(400).json({
        error: `Member has reached maximum borrowing limit (${member.max_books})`,
      });
    }

    // Check 4: Book exists and is available
    const book = await Book.findById(bookId);
    if (!book) {
      return res.status(404).json({ error: "Book not found" });
    }
    if (book.available_copies <= 0) {
      return res.status(400).json({ error: "Book is not available" });
    }

    // Check 5: Member hasn't already borrowed this book
    const currentBorrows = await Borrowing.getMemberCurrentBorrows(memberId);
    if (currentBorrows.some((b) => b.book_id === bookId)) {
      return res
        .status(400)
        .json({ error: "Member already borrowed this book" });
    }

    // Create borrow record
    const result = await Borrowing.create(
      memberId,
      bookId,
      borrowDate,
      dueDate,
    );

    // Update available copies
    await Book.updateAvailableCopies(bookId, book.available_copies - 1);

    res.status(201).json({
      success: true,
      borrow_id: result.lastID,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}
```

### **Tasks:**

1. **วาด Control Flow Graph (Draw flow graph)**
   - ระบุ nodes (statements)
   - ระบุ edges (control flow)
   - ระบุ decision points

2. **คำนวณ Cyclomatic Complexity (Calculate CC)**
   - นับ decision points
   - ใช้สูตร: CC = Decision Points + 1

3. **Identify Test Paths (ระบุ test paths)**
   - แสดง independent paths ทั้งหมด
   - อย่างน้อย CC paths

### **คำตอบตัวอย่าง (Sample Answer):**

**Decision Points:**

1. `if (!memberId || !bookId || !borrowDate || !dueDate)` → 2 branches
2. `if (!member)` → 2 branches
3. `if (member.status !== "active")` → 2 branches
4. `if (borrowCount >= member.max_books)` → 2 branches
5. `if (!book)` → 2 branches
6. `if (book.available_copies <= 0)` → 2 branches
7. `if (currentBorrows.some(...))` → 2 branches

**Cyclomatic Complexity:**

- Decision Points = 7
- **CC = 7 + 1 = 8**

**Test Paths Required:** อย่างน้อย 8 test cases เพื่อครอบคลุม paths ทั้งหมด

---

## Exercise 2: Setup Jest & Write Unit Tests - ติดตั้ง Jest และเขียน Unit Tests

### Part A: Environment Setup - ตั้งค่าสภาพแวดล้อม

```bash
# 1. Navigate to Library-Management-v2
cd Library-Management-v2

# 2. Install Jest if not already installed
npm install --save-dev jest

# 3. Update package.json with test scripts
```

**package.json (update scripts):**

```json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}
```

### Part B: Create FineCalculator Class - สร้างคลาส FineCalculator

**src/services/FineCalculator.js:**

```javascript
/**
 * FineCalculator - คำนวณค่าปรับการคืนหนังสือล่าช้า
 * Library Management v2
 */

class FineCalculator {
  constructor(dailyFeeRate = 10) {
    if (dailyFeeRate <= 0) {
      throw new Error("Daily fee rate must be positive");
    }
    this.dailyFeeRate = dailyFeeRate;
    this.maxFineAmount = 500; // Maximum fine cap
  }

  /**
   * คำนวณค่าปรับ
   * @param {string|Date} dueDate - วันครบกำหนด
   * @param {string|Date} returnDate - วันคืนจริง
   * @returns {number} ค่าปรับ (บาท)
   */
  calculateFine(dueDate, returnDate) {
    if (!dueDate || !returnDate) {
      throw new Error("Both due date and return date are required");
    }

    const due = new Date(dueDate);
    const returned = new Date(returnDate);

    // Invalid date check
    if (isNaN(due.getTime()) || isNaN(returned.getTime())) {
      throw new Error("Invalid date format");
    }

    // Cannot return before due date
    if (returned < due) {
      return 0;
    }

    // Calculate days overdue
    const daysOverdue = Math.floor((returned - due) / (1000 * 60 * 60 * 24));

    if (daysOverdue === 0) {
      return 0;
    }

    // Calculate fine with daily rate
    const fine = daysOverdue * this.dailyFeeRate;

    // Apply cap
    return Math.min(fine, this.maxFineAmount);
  }

  /**
   * ตรวจสอบว่าหนังสือส่งล่าช้าหรือไม่
   * @param {string|Date} dueDate - วันครบกำหนด
   * @param {string|Date} returnDate - วันคืนจริง
   * @returns {boolean} true ถ้าล่าช้า
   */
  isOverdue(dueDate, returnDate) {
    if (!dueDate || !returnDate) {
      throw new Error("Both due date and return date are required");
    }

    const due = new Date(dueDate);
    const returned = new Date(returnDate);

    if (isNaN(due.getTime()) || isNaN(returned.getTime())) {
      throw new Error("Invalid date format");
    }

    return returned > due;
  }

  /**
   * คำนวณจำนวนวันที่ล่าช้า
   * @param {string|Date} dueDate - วันครบกำหนด
   * @param {string|Date} returnDate - วันคืนจริง
   * @returns {number} จำนวนวันที่ล่าช้า
   */
  getDaysOverdue(dueDate, returnDate) {
    if (!dueDate || !returnDate) {
      throw new Error("Both due date and return date are required");
    }

    const due = new Date(dueDate);
    const returned = new Date(returnDate);

    if (isNaN(due.getTime()) || isNaN(returned.getTime())) {
      throw new Error("Invalid date format");
    }

    if (returned <= due) {
      return 0;
    }

    return Math.floor((returned - due) / (1000 * 60 * 60 * 24));
  }

  /**
   * ตั้งค่าอัตราค่าปรับรายวัน
   * @param {number} rate - อัตราใหม่
   */
  setDailyFeeRate(rate) {
    if (rate <= 0) {
      throw new Error("Daily fee rate must be positive");
    }
    this.dailyFeeRate = rate;
  }

  /**
   * ตั้งค่าค่าปรับสูงสุด
   * @param {number} max - ค่าปรับสูงสุดใหม่
   */
  setMaxFineAmount(max) {
    if (max <= 0) {
      throw new Error("Max fine amount must be positive");
    }
    this.maxFineAmount = max;
  }
}

module.exports = FineCalculator;
```

### Part C: Write Unit Tests - เขียน Unit Tests

**src/services/**tests**/FineCalculator.test.js:**

```javascript
const FineCalculator = require("../FineCalculator");

describe("FineCalculator Class", () => {
  describe("Constructor", () => {
    test("should create FineCalculator with default daily fee rate", () => {
      const calculator = new FineCalculator();
      expect(calculator.dailyFeeRate).toBe(10);
      expect(calculator.maxFineAmount).toBe(500);
    });

    test("should create FineCalculator with custom daily fee rate", () => {
      const calculator = new FineCalculator(15);
      expect(calculator.dailyFeeRate).toBe(15);
    });

    test("should throw error when daily fee rate is zero", () => {
      expect(() => {
        new FineCalculator(0);
      }).toThrow("Daily fee rate must be positive");
    });

    test("should throw error when daily fee rate is negative", () => {
      expect(() => {
        new FineCalculator(-5);
      }).toThrow("Daily fee rate must be positive");
    });
  });

  describe("calculateFine()", () => {
    let calculator;

    beforeEach(() => {
      calculator = new FineCalculator(10); // 10 baht per day
    });

    test("should return 0 when returned on time", () => {
      const dueDate = new Date("2024-02-15");
      const returnDate = new Date("2024-02-15");

      const fine = calculator.calculateFine(dueDate, returnDate);
      expect(fine).toBe(0);
    });

    test("should return 0 when returned before due date", () => {
      const dueDate = new Date("2024-02-15");
      const returnDate = new Date("2024-02-14");

      const fine = calculator.calculateFine(dueDate, returnDate);
      expect(fine).toBe(0);
    });

    test("should calculate fine for 1 day overdue", () => {
      const dueDate = new Date("2024-02-15");
      const returnDate = new Date("2024-02-16");

      const fine = calculator.calculateFine(dueDate, returnDate);
      expect(fine).toBe(10); // 1 day * 10 baht
    });

    test("should calculate fine for multiple days overdue", () => {
      const dueDate = new Date("2024-02-15");
      const returnDate = new Date("2024-02-22"); // 7 days later

      const fine = calculator.calculateFine(dueDate, returnDate);
      expect(fine).toBe(70); // 7 days * 10 baht
    });

    test("should apply maximum fine cap", () => {
      const dueDate = new Date("2024-02-01");
      const returnDate = new Date("2024-03-20"); // 47 days later

      const fine = calculator.calculateFine(dueDate, returnDate);
      expect(fine).toBeLessThanOrEqual(500); // Should not exceed max
      expect(fine).toBe(500); // 47 * 10 = 470, but then capped at 500
    });

    test("should work with string dates", () => {
      const fine = calculator.calculateFine("2024-02-15", "2024-02-20");
      expect(fine).toBe(50); // 5 days * 10 baht
    });

    test("should throw error when due date is missing", () => {
      expect(() => {
        calculator.calculateFine(null, new Date());
      }).toThrow("Both due date and return date are required");
    });

    test("should throw error when return date is missing", () => {
      expect(() => {
        calculator.calculateFine(new Date(), null);
      }).toThrow("Both due date and return date are required");
    });

    test("should throw error with invalid due date format", () => {
      expect(() => {
        calculator.calculateFine("invalid-date", "2024-02-15");
      }).toThrow("Invalid date format");
    });

    test("should throw error with invalid return date format", () => {
      expect(() => {
        calculator.calculateFine("2024-02-15", "invalid-date");
      }).toThrow("Invalid date format");
    });
  });

  describe("isOverdue()", () => {
    let calculator;

    beforeEach(() => {
      calculator = new FineCalculator();
    });

    test("should return false when returned on time", () => {
      const result = calculator.isOverdue("2024-02-15", "2024-02-15");
      expect(result).toBe(false);
    });

    test("should return false when returned early", () => {
      const result = calculator.isOverdue("2024-02-15", "2024-02-14");
      expect(result).toBe(false);
    });

    test("should return true when returned late", () => {
      const result = calculator.isOverdue("2024-02-15", "2024-02-16");
      expect(result).toBe(true);
    });

    test("should throw error when dates are missing", () => {
      expect(() => {
        calculator.isOverdue(null, new Date());
      }).toThrow();
    });
  });

  describe("getDaysOverdue()", () => {
    let calculator;

    beforeEach(() => {
      calculator = new FineCalculator();
    });

    test("should return 0 when returned on time", () => {
      const days = calculator.getDaysOverdue("2024-02-15", "2024-02-15");
      expect(days).toBe(0);
    });

    test("should return 0 when returned early", () => {
      const days = calculator.getDaysOverdue("2024-02-15", "2024-02-14");
      expect(days).toBe(0);
    });

    test("should calculate correct days overdue", () => {
      const days = calculator.getDaysOverdue("2024-02-15", "2024-02-22");
      expect(days).toBe(7);
    });

    test("should calculate single day overdue", () => {
      const days = calculator.getDaysOverdue("2024-02-15", "2024-02-16");
      expect(days).toBe(1);
    });
  });

  describe("setDailyFeeRate()", () => {
    let calculator;

    beforeEach(() => {
      calculator = new FineCalculator();
    });

    test("should update daily fee rate", () => {
      calculator.setDailyFeeRate(20);
      expect(calculator.dailyFeeRate).toBe(20);
    });

    test("should affect fine calculation", () => {
      calculator.setDailyFeeRate(20);
      const fine = calculator.calculateFine("2024-02-15", "2024-02-17");
      expect(fine).toBe(40); // 2 days * 20 baht
    });

    test("should throw error with invalid rate", () => {
      expect(() => {
        calculator.setDailyFeeRate(0);
      }).toThrow("Daily fee rate must be positive");
    });
  });

  describe("setMaxFineAmount()", () => {
    let calculator;

    beforeEach(() => {
      calculator = new FineCalculator(10);
    });

    test("should update max fine amount", () => {
      calculator.setMaxFineAmount(1000);
      expect(calculator.maxFineAmount).toBe(1000);
    });

    test("should affect fine calculation with new cap", () => {
      calculator.setMaxFineAmount(100);
      const fine = calculator.calculateFine("2024-02-01", "2024-03-15");
      expect(fine).toBeLessThanOrEqual(100);
    });

    test("should throw error with invalid max", () => {
      expect(() => {
        calculator.setMaxFineAmount(-1);
      }).toThrow("Max fine amount must be positive");
    });
  });

  describe("Integration scenarios - สถานการณ์รวม", () => {
    test("should handle typical borrowing return scenarios", () => {
      const calculator = new FineCalculator(10);

      // Scenario 1: On-time return
      let fine = calculator.calculateFine("2024-02-15", "2024-02-15");
      expect(fine).toBe(0);

      // Scenario 2: 3 days late
      fine = calculator.calculateFine("2024-02-15", "2024-02-18");
      expect(fine).toBe(30);

      // Scenario 3: Very late return with cap
      calculator.setMaxFineAmount(200);
      fine = calculator.calculateFine("2024-02-01", "2024-03-20");
      expect(fine).toBe(200);
    });

    test("should work with different fee rates", () => {
      const calculator = new FineCalculator(5); // 5 baht per day

      let fine = calculator.calculateFine("2024-02-15", "2024-02-20");
      expect(fine).toBe(25); // 5 days * 5 baht

      calculator.setDailyFeeRate(15); // Change to 15 baht per day

      fine = calculator.calculateFine("2024-02-15", "2024-02-20");
      expect(fine).toBe(75); // 5 days * 15 baht
    });
  });
});
```

### Part D: Testing with Mocks - ทดสอบด้วย Mocks

**src/services/BorrowingService.js:**

```javascript
/**
 * BorrowingService - บริการจัดการการยืมหนังสือ
 * รวมการตรวจสอบ validation และ business logic
 */

const FineCalculator = require("./FineCalculator");

class BorrowingService {
  constructor(memberRepository, bookRepository, borrowingRepository) {
    this.memberRepository = memberRepository;
    this.bookRepository = bookRepository;
    this.borrowingRepository = borrowingRepository;
    this.fineCalculator = new FineCalculator(10); // 10 baht per day
  }

  /**
   * ตรวจสอบและสร้างการยืมหนังสือ
   * @param {number} memberId
   * @param {number} bookId
   * @param {string|Date} borrowDate
   * @param {string|Date} dueDate
   * @returns {Object} borrowing record
   */
  async createBorrow(memberId, bookId, borrowDate, dueDate) {
    // Validate inputs
    if (!memberId || !bookId || !borrowDate || !dueDate) {
      throw new Error(
        "Member ID, book ID, borrow date, and due date are required",
      );
    }

    // Check member exists and is active
    const member = await this.memberRepository.findById(memberId);
    if (!member) {
      throw new Error("Member not found");
    }

    if (member.status !== "active") {
      throw new Error("Member is not active");
    }

    // Check borrowing limit
    const borrowCount = await this.memberRepository.getBorrowingCount(memberId);
    if (borrowCount >= member.max_books) {
      throw new Error(
        `Member has reached maximum borrowing limit (${member.max_books})`,
      );
    }

    // Check book exists and is available
    const book = await this.bookRepository.findById(bookId);
    if (!book) {
      throw new Error("Book not found");
    }

    if (book.available_copies <= 0) {
      throw new Error("Book is not available");
    }

    // Check member hasn't already borrowed this book
    const currentBorrows =
      await this.borrowingRepository.getMemberCurrentBorrows(memberId);
    if (currentBorrows.some((b) => b.book_id === bookId)) {
      throw new Error("Member already borrowed this book");
    }

    // Create borrowing record
    const result = await this.borrowingRepository.create(
      memberId,
      bookId,
      borrowDate,
      dueDate,
    );

    // Update book availability
    await this.bookRepository.updateAvailableCopies(
      bookId,
      book.available_copies - 1,
    );

    return result;
  }

  /**
   * ประมวลการคืนหนังสือและคำนวณค่าปรับ
   * @param {number} borrowId
   * @param {string|Date} returnDate
   * @returns {Object} return record with fine amount
   */
  async returnBorrow(borrowId, returnDate) {
    if (!borrowId || !returnDate) {
      throw new Error("Borrow ID and return date are required");
    }

    const borrow = await this.borrowingRepository.findById(borrowId);
    if (!borrow) {
      throw new Error("Borrowing record not found");
    }

    if (borrow.status === "returned") {
      throw new Error("Book already returned");
    }

    // Calculate fine
    const fineAmount = this.fineCalculator.calculateFine(
      borrow.due_date,
      returnDate,
    );

    // Update borrowing record
    await this.borrowingRepository.returnBook(borrowId, returnDate, fineAmount);

    // Update book availability
    const book = await this.bookRepository.findById(borrow.book_id);
    if (book) {
      await this.bookRepository.updateAvailableCopies(
        borrow.book_id,
        book.available_copies + 1,
      );
    }

    return {
      borrowId,
      returnDate,
      fineAmount,
      isOverdue: fineAmount > 0,
    };
  }
}

module.exports = BorrowingService;
```

**src/services/**tests**/BorrowingService.test.js:**

```javascript
const BorrowingService = require("../BorrowingService");

describe("BorrowingService", () => {
  let borrowingService;
  let mockMemberRepository;
  let mockBookRepository;
  let mockBorrowingRepository;

  beforeEach(() => {
    // Create mock repositories
    mockMemberRepository = {
      findById: jest.fn(),
      getBorrowingCount: jest.fn(),
    };

    mockBookRepository = {
      findById: jest.fn(),
      updateAvailableCopies: jest.fn(),
    };

    mockBorrowingRepository = {
      create: jest.fn(),
      findById: jest.fn(),
      returnBook: jest.fn(),
      getMemberCurrentBorrows: jest.fn(),
    };

    borrowingService = new BorrowingService(
      mockMemberRepository,
      mockBookRepository,
      mockBorrowingRepository,
    );
  });

  describe("createBorrow()", () => {
    test("should successfully create borrow record", async () => {
      const mockMember = { id: 1, status: "active", max_books: 5 };
      const mockBook = { id: 1, available_copies: 3 };
      const mockResult = { lastID: 1 };

      mockMemberRepository.findById.mockResolvedValue(mockMember);
      mockMemberRepository.getBorrowingCount.mockResolvedValue(1);
      mockBookRepository.findById.mockResolvedValue(mockBook);
      mockBorrowingRepository.getMemberCurrentBorrows.mockResolvedValue([]);
      mockBorrowingRepository.create.mockResolvedValue(mockResult);
      mockBookRepository.updateAvailableCopies.mockResolvedValue(true);

      const result = await borrowingService.createBorrow(
        1,
        1,
        "2024-02-15",
        "2024-03-15",
      );

      expect(result).toEqual(mockResult);
      expect(mockMemberRepository.findById).toHaveBeenCalledWith(1);
      expect(mockBookRepository.updateAvailableCopies).toHaveBeenCalledWith(
        1,
        2,
      );
    });

    test("should throw error when member not found", async () => {
      mockMemberRepository.findById.mockResolvedValue(null);

      await expect(
        borrowingService.createBorrow(999, 1, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Member not found");

      expect(mockBorrowingRepository.create).not.toHaveBeenCalled();
    });

    test("should throw error when member is not active", async () => {
      const mockMember = { id: 1, status: "inactive", max_books: 5 };
      mockMemberRepository.findById.mockResolvedValue(mockMember);

      await expect(
        borrowingService.createBorrow(1, 1, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Member is not active");
    });

    test("should throw error when borrowing limit reached", async () => {
      const mockMember = { id: 1, status: "active", max_books: 3 };
      mockMemberRepository.findById.mockResolvedValue(mockMember);
      mockMemberRepository.getBorrowingCount.mockResolvedValue(3);

      await expect(
        borrowingService.createBorrow(1, 1, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Member has reached maximum borrowing limit");
    });

    test("should throw error when book not found", async () => {
      const mockMember = { id: 1, status: "active", max_books: 5 };
      mockMemberRepository.findById.mockResolvedValue(mockMember);
      mockMemberRepository.getBorrowingCount.mockResolvedValue(1);
      mockBookRepository.findById.mockResolvedValue(null);

      await expect(
        borrowingService.createBorrow(1, 999, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Book not found");
    });

    test("should throw error when book not available", async () => {
      const mockMember = { id: 1, status: "active", max_books: 5 };
      const mockBook = { id: 1, available_copies: 0 };
      mockMemberRepository.findById.mockResolvedValue(mockMember);
      mockMemberRepository.getBorrowingCount.mockResolvedValue(0);
      mockBookRepository.findById.mockResolvedValue(mockBook);

      await expect(
        borrowingService.createBorrow(1, 1, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Book is not available");
    });

    test("should throw error when member already borrowed same book", async () => {
      const mockMember = { id: 1, status: "active", max_books: 5 };
      const mockBook = { id: 1, available_copies: 3 };
      const existingBorrow = { book_id: 1 };

      mockMemberRepository.findById.mockResolvedValue(mockMember);
      mockMemberRepository.getBorrowingCount.mockResolvedValue(1);
      mockBookRepository.findById.mockResolvedValue(mockBook);
      mockBorrowingRepository.getMemberCurrentBorrows.mockResolvedValue([
        existingBorrow,
      ]);

      await expect(
        borrowingService.createBorrow(1, 1, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow("Member already borrowed this book");
    });

    test("should throw error when required fields are missing", async () => {
      await expect(
        borrowingService.createBorrow(1, null, "2024-02-15", "2024-03-15"),
      ).rejects.toThrow(
        "Member ID, book ID, borrow date, and due date are required",
      );
    });
  });

  describe("returnBorrow()", () => {
    test("should successfully return book with no fine", async () => {
      const mockBorrow = {
        id: 1,
        book_id: 1,
        status: "borrowed",
        due_date: "2024-02-20",
      };
      const mockBook = { id: 1, available_copies: 2 };

      mockBorrowingRepository.findById.mockResolvedValue(mockBorrow);
      mockBookRepository.findById.mockResolvedValue(mockBook);
      mockBorrowingRepository.returnBook.mockResolvedValue(true);
      mockBookRepository.updateAvailableCopies.mockResolvedValue(true);

      const result = await borrowingService.returnBorrow(1, "2024-02-20");

      expect(result.fineAmount).toBe(0);
      expect(result.isOverdue).toBe(false);
      expect(mockBorrowingRepository.returnBook).toHaveBeenCalledWith(
        1,
        "2024-02-20",
        0,
      );
    });

    test("should successfully return book with fine", async () => {
      const mockBorrow = {
        id: 1,
        book_id: 1,
        status: "borrowed",
        due_date: "2024-02-15",
      };
      const mockBook = { id: 1, available_copies: 2 };

      mockBorrowingRepository.findById.mockResolvedValue(mockBorrow);
      mockBookRepository.findById.mockResolvedValue(mockBook);
      mockBorrowingRepository.returnBook.mockResolvedValue(true);
      mockBookRepository.updateAvailableCopies.mockResolvedValue(true);

      const result = await borrowingService.returnBorrow(1, "2024-02-20");

      expect(result.fineAmount).toBe(50); // 5 days * 10 baht
      expect(result.isOverdue).toBe(true);
    });

    test("should throw error when borrowing not found", async () => {
      mockBorrowingRepository.findById.mockResolvedValue(null);

      await expect(
        borrowingService.returnBorrow(999, "2024-02-20"),
      ).rejects.toThrow("Borrowing record not found");
    });

    test("should throw error when book already returned", async () => {
      const mockBorrow = {
        id: 1,
        book_id: 1,
        status: "returned",
      };

      mockBorrowingRepository.findById.mockResolvedValue(mockBorrow);

      await expect(
        borrowingService.returnBorrow(1, "2024-02-20"),
      ).rejects.toThrow("Book already returned");
    });

    test("should update book availability correctly", async () => {
      const mockBorrow = {
        id: 1,
        book_id: 1,
        status: "borrowed",
        due_date: "2024-02-20",
      };
      const mockBook = { id: 1, available_copies: 2 };

      mockBorrowingRepository.findById.mockResolvedValue(mockBorrow);
      mockBookRepository.findById.mockResolvedValue(mockBook);
      mockBorrowingRepository.returnBook.mockResolvedValue(true);
      mockBookRepository.updateAvailableCopies.mockResolvedValue(true);

      await borrowingService.returnBorrow(1, "2024-02-20");

      expect(mockBookRepository.updateAvailableCopies).toHaveBeenCalledWith(
        1,
        3,
      );
    });
  });
});
```

### Running Tests - การรัน Tests

```bash
# Run all tests
npm test

# Run with coverage
npm test -- --coverage

# Run in watch mode
npm test -- --watch

# Run specific test file
npm test -- FineCalculator.test.js
```

---

## 📊 Lab Submission Requirements - ข้อกำหนดการส่งมอบ

### Deliverables (สิ่งที่ต้องส่ง):

1. **Cyclomatic Complexity Analysis Document - เอกสารวิเคราะห์ CC**
   - Control Flow Graph (can be hand-drawn or digital)
   - CC calculation with explanation
   - List of test paths (at least 8 paths)

2. **Source Code - โค้ดต้นฉบับ**
   - `src/services/FineCalculator.js`
   - `src/services/BorrowingService.js`
   - `src/services/__tests__/FineCalculator.test.js`
   - `src/services/__tests__/BorrowingService.test.js`
   - Updated `package.json` with test scripts

3. **Test Coverage Report - รายงาน Coverage**
   - Screenshot of coverage report
   - Must achieve **80%+** coverage
   - Explanation of any uncovered lines

4. **Lab Report (PDF)** containing:
   - Your Cyclomatic Complexity analysis of BorrowingController.borrow()
   - Coverage report screenshots
   - Comparison between FineCalculator and original calculateFine function
   - Reflection: What did you learn?
   - Challenges faced and solutions

### Grading Criteria - เกณฑ์การประเมิน:

| Criteria                                  | Points   |
| ----------------------------------------- | -------- |
| Cyclomatic Complexity calculation correct | 20%      |
| All tests pass                            | 30%      |
| Code coverage ≥ 80%                       | 20%      |
| Proper use of mocks/test doubles          | 15%      |
| Code quality & best practices             | 10%      |
| Documentation & report                    | 5%       |
| **Total**                                 | **100%** |

---

## 🎯 Bonus Challenges - ท้าทายเพิ่มเติม (Optional)

1. **Challenge 1:** Achieve 90%+ code coverage on all services
2. **Challenge 2:** Write integration tests for BorrowingService with real database
3. **Challenge 3:** Implement a `MemberValidationService` class with unit tests
4. **Challenge 4:** Add parameterized tests using `test.each()` for different fee rates and overdue scenarios
5. **Challenge 5:** Calculate CC for the entire `borrow()` method from BorrowingController including all nested conditions

---

## 📚 Additional Resources - ทรัพยากรเพิ่มเติม

- Jest Documentation: https://jestjs.io/docs/getting-started
- Jest Cheat Sheet: https://github.com/sapegin/jest-cheat-sheet
- Martin Fowler - Test Doubles: https://martinfowler.com/bliki/TestDouble.html
- Cyclomatic Complexity Reference: https://en.wikipedia.org/wiki/Cyclomatic_complexity
- Test-Driven Development by Kent Beck
- Library-Management-v2 Code: See src/controllers and src/models

---

## ❓ FAQ - คำถามที่พบบ่อย

**Q: ทำไมต้องใช้ mocks? (Why use mocks?)**
A: เพื่อ isolate unit ที่ทดสอบจาก dependencies (database, API) ทำให้ test เร็วและไม่พึ่งพา external systems

**Q: Coverage 100% หมายความว่า code ไม่มี bugs ใช่ไหม? (Does 100% coverage mean no bugs?)**
A: ไม่ใช่! Coverage สูงไม่รับประกันว่าไม่มี bugs แต่ช่วยให้มั่นใจว่าโค้ดถูก execute ครบ

**Q: ต้องเขียน test ทุก method หรือไม่? (Test every method?)**
A: ควรเขียนสำหรับ business logic และ complex methods แต่ simple getters/setters อาจไม่จำเป็น

**Q: Cyclomatic Complexity เท่าไหร่ถึงจะดี? (Good CC value?)**
A: CC 1-10 ดีมาก, 11-20 moderate, >20 ควร refactor

**Q: ทำไมต้องแยก FineCalculator เป็น class แยก? (Why separate FineCalculator class?)**
A: เพื่อ Single Responsibility Principle - แต่ละ class ทำหน้าที่เดียว ทำให้ easy to test, maintain, reuse

---

## 📝 Key Differences from Original Code

### จาก Library-Management-v2:

- **Original**: `calculateFine()` เป็น inline function ใน controller
- **New**: `FineCalculator` class สำหรับ reusability และ testability

- **Original**: Business logic อยู่ใน controller
- **New**: Business logic แยกเป็น `BorrowingService`

- **Original**: ไม่มี unit tests
- **New**: Comprehensive test suite with mocks

- **Original**: ยาก test เพราะ tightly coupled
- **New**: Easy to test ด้วย dependency injection

---
