018 / SI 679 · 2026 秋季

後端開發
學習指南。

先讀每課的說明與程式,看過重點整理,再檢查推理。從訂披薩開始。

第 1–6 週 + TypeScript + HW142 道回想題6 份完整原始指南
課程 → 重點整理 → 檢查 → 解釋 → 重試。搜尋概念 /

展開推理前先選答案,再口頭解釋;離開筆記,用新例子練習。

沒有符合的主題。請縮短關鍵字,或清除搜尋。

00

第 0 節 · 不需要先懂程式名詞

寫程式之前:先訂一份披薩。

0.1 · 從你已經懂的事情開始

想像你用手機訂披薩:看菜單、選披薩、按下「送出訂單」,然後收到訂單編號。這樣就足以開始;這一節不需要任何程式碼。

訂餐時誰在跟誰對話你,也就是使用者,在手機 App 裡按下「菜單」。App 是用戶端,屬於前端:你看得到、摸得到的部分。第 1 步:用戶端送出請求「請傳菜單給我」給伺服器,也就是後端的程式。第 2 步:伺服器送回回應,也就是菜單資料。第 3 步:用戶端顯示菜單。你使用者本人按下「菜單」前端你看得到也摸得到的部分用戶端手機 App3顯示菜單1請求「請傳菜單給我」2回應菜單資料後端畫面背後的工作伺服器負責回覆的程式
注意:你從不直接跟伺服器對話。用戶端替你提出要求,而且只有用戶端會畫出畫面。

手機 App 是用戶端:提出要求的軟體。它送出的訊息是請求。伺服器是接收請求並回覆的程式(這個詞也可以指執行它的電腦)。這個回覆就是回應。

前端是你看得到、摸得到的部分;後端是畫面背後的工作:接收、檢查、完成工作、回覆。這門課教的是後端。

重點整理

你是使用者,App 是用戶端。它送出請求,伺服器回覆回應,再由用戶端顯示。前端:你操作的部分。後端:畫面背後的工作。

檢查 · 先預測再選擇

伺服器回傳菜單資料後,接著會怎樣?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 客戶端顯示菜單 — 接收資料與繪製菜單是不同的工作。
  • 回應自行繪製畫面 — 回覆攜帶資料,由客戶端程式決定如何顯示。
  • 使用者變成伺服器 — 人使用軟體;伺服器仍是接收請求的程式。

0.2 · 兩種提出要求的方式

看菜單和送出訂單是不同的動作。每個請求都用方法說明動作,用路徑說明對象。

讀取與建立兩個請求並排。讀取菜單:方法 GET,路徑 /menu,沒有主體;回覆是菜單資料。送出訂單:方法 POST,路徑 /orders,主體用 JSON 寫出披薩是起司口味、數量是 1;回覆是訂單編號,或訂單被拒絕的原因。讀取打開菜單請求方法GET路徑/menu主體無回覆菜單資料建立送出訂單請求方法POST路徑/orders主體{"pizza":"cheese","quantity":1}回覆訂單編號,或是被拒絕的原因
注意:只有要建立資料的請求帶有主體,而且回覆也可以是拒絕。
  • API 是雙方約定的提出要求方式:伺服器支援哪些請求、要送什麼,以及每種回覆代表什麼。
  • HTTP 是每個網頁請求和回應都遵守的格式。
  • JSON 是書寫結構化資料的常見方式。訂單的主體是兩個有標籤的資訊:披薩是起司口味,數量是一份。
  • REST 是一套慣例,用來圍繞資源設計 API,例如菜單和訂單。

跟著一張訂單走

逐步看成功訂單,再比較遭拒的訂單。這是本機教學模擬。

  1. 客戶端 → 檢查 → Controller → Service → 儲存 → 回覆

遭拒請求會在儲存訂單前停止。由客戶端顯示回覆。

重點整理

GET /menu 讀取;POST /orders 建立,訂單以 JSON 主體送出。API 是這類請求的約定。HTTP 承載每則訊息,JSON 組織其中的資料。

檢查 · 先預測再選擇

圖書館 App 要建立預約。哪種請求符合這個設計?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • GET /reservations,儲存新預約 — 此設計的 GET 用於讀取,不應建立預約。
  • JSON /reservations — JSON 是資料格式,不是 HTTP 方法。
  • POST /reservations,附上書籍資料 — 此處用 POST 建立資源,並在主體附上必要資料。

0.3 · 打開廚房的門

伺服器內部由幾個角色分工,就像廚房裡的各個工作站。它們是同一個程式的不同部分,不是真人,也不是各自獨立的電腦。

伺服器內部在伺服器內部,請求依序經過五個角色:路由挑選工作站,中介軟體檢查訂單,控制器建立回覆,服務套用規則,資料庫保存訂單簿。Express 負責前三個,MongoDB 就是資料庫,Node.js 執行全部。檢查失敗會立刻回覆。TypeScript 在你寫程式時檢查程式碼;測試預演各種請求。檢查失敗就回覆請求伺服器路由挑選工作站中介軟體檢查訂單控制器建立回覆服務套用規則資料庫保存訂單簿由 Express 負責這三個角色MongoDBNode.js 執行整個 JavaScript 程式TypeScript 檢查程式碼在你撰寫的時候,不檢查送進來的訂單。測試是預演:送出已知的訂單,核對回覆。
注意:檢查一旦失敗,伺服器會在訂單送到服務或資料庫之前就回覆。
  • 路由:挑出處理這個請求的程式碼。
  • 中介軟體:在請求進來的路上檢查。資料能用嗎(驗證)?是誰提出的(身分驗證)?他可以這樣做嗎(權限檢查)?檢查失敗就立刻回覆。
  • 控制器:讀取請求,交辦工作,建立回應。
  • 服務:套用規則,例如哪些披薩還有供應。
  • 資料庫:保存重新啟動後仍然存在的紀錄,就像一本存好的訂單簿。
  • 模型與文件:模型是訂單的形狀,就像一張空白表格;文件是 MongoDB 裡一筆已儲存的訂單。

課程工具:

  • JavaScript:撰寫程式碼的語言。
  • TypeScript:在你寫程式時檢查值的型別。它從不檢查送進來的訂單,所以仍然需要驗證。
  • Node.js:在瀏覽器之外執行 JavaScript;在這裡就是執行整個伺服器。
  • Express:接收請求、執行路由和中介軟體、送出回應。
  • MongoDB:儲存訂單的資料庫。
  • 測試:預演。送出有效訂單,預期被接受;送出數量 −2,預期被拒絕。

重點整理

路由分派,中介軟體檢查,控制器處理 HTTP,服務套用規則,資料庫保存。Node 執行全部,Express 處理網頁的部分,MongoDB 儲存,測試負責預演。

檢查 · 先預測再選擇

訂單數量為 −2。儲存前應先怎樣?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Model 說數量是數字,所以照樣儲存 — 數字型別不會自動限制數值必須為正。
  • 請資料庫繪製錯誤訊息 — 後端回覆錯誤,由客戶端呈現。
  • 驗證並拒絕無效數量 — 執行時驗證應在接受訂單前阻止無效資料。

0.4 · 用自己的話說說看

打開答案前,先停下來想一想。大致說得出來就夠了。

你按下「送出訂單」。你是用戶端嗎?請求是什麼?

你是使用者本人,App 才是用戶端。請求是要求伺服器建立訂單的那一則訊息。API 是送出這則訊息的約定方式。

伺服器送出菜單資料。誰把它顯示在畫面上?每次索取菜單都一定要用資料庫嗎?

用戶端。資料庫只是一種可能的來源;伺服器也可以讀取檔案或記憶體。回應是資料,不是畫面。

訂單的數量寫成 −2。哪種工作應該抓到這個問題?接下來會怎樣?

資料驗證,在接受訂單之前。中介軟體可以用錯誤回應拒絕它,再由用戶端顯示。TypeScript 無法檢查收到的訊息。

同樣的模式適用於任何 App:

  • 天氣:選擇安娜堡 → 用戶端索取當地預報 → 伺服器回覆資料 → 用戶端畫出下雨圖示。
  • 圖書館:按下「預約」 → 用戶端送出預約請求 → 伺服器檢查規則並記錄下來 → 用戶端顯示回覆。

看到任何範例,都問三個問題:使用者想要什麼?用戶端送出什麼訊息?伺服器回覆前必須做什麼?

沒用過終端機?先把電腦準備好。

前往第 1 節:看看程式碼如何寫出這段對話。。遇到新名詞不知道該放在哪裡時,隨時回來看這個故事。

重點整理

不看筆記,連起各項工作:人 → 客戶端 → 請求 → 伺服器 → 回應 → 客戶端顯示。再解釋失敗情況:無效輸入應在接受工作前被拒絕。

檢查 · 先預測再選擇

商店 API 回覆「訂單遭拒」。可以得出哪個結論?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 沒有送出請求 — 實際請求可以得到錯誤回覆。
  • 伺服器一定已儲存訂單 — 拒絕不表示寫入成功;需檢查約定與狀態。
  • 回應也可以回報失敗 — 回覆傳達結果,也包括被拒絕的請求。

來源依據 · 新增的日常教學範例,串連保留的第 1–4 週與 HW1 主題;API 路徑是示意範例,不是作業端點。

0+

第 1 週之前 · 終端機、Node、編輯器、git 與第一個伺服器

先把電腦準備好。

開啟終端機並切換資料夾

連回第 0 節:披薩故事裡有一個接收請求的伺服器。這一節讓你自己的電腦準備好執行它。

終端機是一個視窗:你輸入指令、按 Enter,然後閱讀電腦印出的文字。這門課的工具都靠輸入指令來啟動,所以一切從這裡開始。

  • macOS:按 Cmd+Space,輸入 Terminal,再按 Enter。
  • Windows:開啟「開始」功能表,輸入 PowerShell,再按 Enter。

終端機會顯示提示字元:一小段文字,表示它正在等你輸入。在本指南中,以 $ 開頭的行是要輸入的指令,輸入時不含 $。其他的行都是終端機印出的內容。

macOS 終端機 · 範例輸出,你的資料夾會不同
$ pwd
/Users/you
$ ls
Desktop     Documents   Downloads
$ mkdir si679
$ cd si679
$ pwd
/Users/you/si679
  • pwd 會印出你目前所在的資料夾。
  • ls 會列出那個資料夾裡的內容。
  • mkdir si679 會建立名為 si679 的新資料夾。
  • cd si679 會進入那個資料夾。cd .. 會回到上一層。

路徑是資料夾或檔案的位址:由外到內,一層一層寫出抵達它要經過的資料夾。/Users/you/si679 的意思是:Users 資料夾,接著是你自己的資料夾,最後是 si679。符號 ~ 代表你自己的資料夾,所以 ~/si679 是同一個位置。

Windows 的路徑用磁碟機代號和反斜線書寫。同樣四個指令在 PowerShell 也能用,其中 pwd 與 mkdir 會印出一個小表格。

Windows PowerShell · 範例輸出,你的日期會不同
PS C:\Users\you> pwd

Path
----
C:\Users\you


PS C:\Users\you> mkdir si679


    Directory: C:\Users\you


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----          9/1/2026   9:14 AM                si679


PS C:\Users\you> cd si679
PS C:\Users\you\si679>

多數設定問題,都是在錯的資料夾輸入了正確的指令。出錯時,pwd 是第一個該執行的指令。

重點整理

終端機執行你輸入的指令。pwd 顯示你在哪裡,ls 顯示那裡有什麼,mkdir 建立資料夾,cd 移動位置。路徑就是資料夾的位址。

檢查 · 先預測再選擇

你執行 mkdir si679,再執行 pwd。路徑結尾不是 si679。為什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • mkdir 失敗了,卻沒有顯示訊息 — mkdir 失敗時會印出錯誤。沒有錯誤就表示資料夾已建立。
  • mkdir 建立了資料夾,但沒有把你移進去 — 建立資料夾和進入資料夾是兩個步驟。執行 cd si679 才會移動。
  • pwd 永遠印出你的個人資料夾 — pwd 印出你現在所在的資料夾。執行 cd 之後它會改變。

安裝 Node 並確認

Node 在你的電腦上執行 JavaScript,不需要瀏覽器。在這門課裡,它執行你的伺服器程式。

npm 會和 Node 一起安裝。它下載套件,也就是別人寫好的程式碼,並執行專案定義的指令碼。

  1. 從 nodejs.org ↗ 下載 LTS 安裝程式並執行。LTS 是長期支援的意思:適合日常使用的版本。
  2. 關閉終端機再開一個新的,它才找得到新安裝的程式。
  3. 向這兩個程式詢問版本。
macOS 終端機或 Windows PowerShell · 範例輸出,你的數字可能不同
$ node --version
v24.21.0
$ npm --version
11.17.0

兩個指令都印出版本號碼,就表示安裝成功。指令和輸出在 macOS 與 Windows 上相同。

第 3 週課程筆記說,程式碼以 Node 24.21.0 測試過;接近的版本(22 以上)大概都能用;低於 20 的版本應該升級。筆記沒有指定 npm 版本。上面的 npm 數字只是某次 Node 24 安裝的範例。

如果終端機回答「command not found」,請看這一節的最後一個主題。

重點整理

Node 在瀏覽器之外執行 JavaScript。npm 下載套件並執行專案指令碼。兩個指令都印出版本號碼,就表示已安裝。

檢查 · 先預測再選擇

node --version 印出以 v18 開頭的數字。課程筆記建議怎麼做?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 升級 Node,因為筆記說低於 20 的版本應該升級 — 筆記以 Node 24 測試,並認為低於 20 的版本舊到該升級。
  • 不用處理,因為只要有版本號碼,程式碼就能執行 — 有數字只證明 Node 已安裝,不能證明版本夠新。
  • 重新安裝 npm,因為 Node 的版本由 npm 決定 — npm 隨 Node 一起安裝,不是反過來。請安裝較新的 Node。

用程式碼編輯器開啟專案

程式碼編輯器是用來撰寫純文字檔案的程式,例如 server.js。文書處理軟體不適合:它會存入隱藏的格式,程式碼裡不能有這些。

本指南使用 Visual Studio Code,簡稱 VS Code,是 Microsoft 的免費編輯器。任何程式碼編輯器都可以。請從 code.visualstudio.com ↗ 下載並安裝。

  1. 開啟 VS Code。
  2. 選擇 File,再選 Open Folder。
  3. 選取你的 si679 資料夾並確認。

要開啟資料夾,不是單一檔案。這樣左側面板會列出專案裡的每個檔案,你新增的檔案也會存在專案裡。

VS Code 內建終端機:選擇 Terminal,再選 New Terminal。它會從你開啟的資料夾開始,所以不需要 cd。

你也可以從終端機開啟資料夾。那個點代表你目前所在的資料夾。

Shell · 不會印出內容,會開啟 VS Code 視窗
$ cd ~/si679
$ code .

在 Windows 上,安裝程式通常會設定好 code 指令。在 macOS 上要自己設定一次:在 VS Code 按 Cmd+Shift+P,輸入 shell command,再選擇安裝該指令的項目。

重點整理

程式碼編輯器用來撰寫純文字檔案。要開啟整個專案資料夾,不是單一檔案。內建終端機會從那個資料夾開始。

檢查 · 先預測再選擇

你希望 VS Code 列出專案的每個檔案,並讓終端機從專案裡開始。你該開啟什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 只開啟 server.js 這個檔案 — 只開一個檔案,不會帶入它的專案。檔案清單會是空的。
  • 一份名為 server.js 的文書處理文件 — 文書處理軟體存入的格式不是純文字。Node 需要純文字。
  • 專案資料夾:選 File,再選 Open Folder — 開啟資料夾,編輯器和它的終端機才能掌握整個專案。

用 git 與 GitHub 儲存並繳交作業

git 是記錄專案資料夾各個版本的程式。由 git 追蹤的資料夾稱為儲存庫。

commit 是一個存下來的版本,附上一則簡短訊息說明改了什麼。commit 存在你的電腦上。

GitHub 是一個網站,替你在網路上保管一份儲存庫副本。clone 是把 GitHub 上的儲存庫下載到你的電腦。push 是把你新的 commit 送上 GitHub。

Shell · 某台 Mac 的範例輸出
$ git --version
git version 2.50.1 (Apple Git-155)
  • macOS:第一次執行 git 時,系統會詢問是否安裝開發者工具。同意後再執行一次指令。
  • Windows:從 git-scm.com ↗ 安裝 Git for Windows,然後開啟新的終端機。

在 github.com 建立免費帳號 ↗,要在第一份作業之前完成。

課程透過 Classroom50 發放作業。第 1 週頁面描述的流程是:接受作業、clone 產生的儲存庫、完成作業,然後 push。第 3 週筆記另外加上 npm install,作為 clone 之後的一個步驟。

  1. 開啟作業連結並接受。GitHub 會為你建立一個儲存庫。
  2. 在該儲存庫的頁面按 Code 按鈕,複製 URL。
  3. clone 它,進入新資料夾,並安裝它的套件。把範例 URL 換成你自己的。
  4. 在編輯器裡完成作業並儲存檔案。
  5. 把你改過的檔案加入暫存,commit,然後 push。
Shell · 步驟 3 · 節錄的範例輸出,你的名稱與數字會不同
$ cd ~/si679
$ git clone https://github.com/ORGANIZATION/si-679-f-26-week1-nyt-yourname.git
Cloning into 'si-679-f-26-week1-nyt-yourname'...
$ cd si-679-f-26-week1-nyt-yourname
$ npm install

added 68 packages, and audited 69 packages in 3s
Shell · 步驟 5 · 節錄的範例輸出
$ git add server.js
$ git commit -m "Add catalog route"
[main ee6030f] Add catalog route
 1 file changed, 3 insertions(+)
$ git push
To https://github.com/ORGANIZATION/si-679-f-26-week1-nyt-yourname.git
   e317026..ee6030f  main -> main
  • git add server.js 把該檔案加入暫存:標記這項修改要放進下一個 commit。請寫出每個你改過的檔案。
  • git commit -m "..." 把暫存的修改連同你的訊息存成一個 commit。
  • git push 把你的 commit 送到 GitHub。在 push 之前,成果只存在你的電腦上。
  • git status 列出哪些檔案改了、哪些已加入暫存。不確定時就執行它。

在新電腦上,第一次 commit 可能會停下並顯示「Please tell me who you are」。執行它印出的兩個 git config 指令,填入你自己的姓名與電子郵件,然後再 commit 一次。第一次 push 可能會要求你登入 GitHub。

重點整理

儲存庫是 git 追蹤的資料夾。clone 把它下載下來,commit 在你的電腦上存下一個版本,push 把 commit 送到 GitHub。Classroom50 流程:接受、clone、npm install、完成作業、add、commit、push。

檢查 · 先預測再選擇

你執行了 git commit。GitHub 上的儲存庫頁面仍顯示起始程式碼。為什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • GitHub 需要一天才會顯示新的 commit — commit 一經 push,GitHub 就會顯示。現在什麼都還沒送出。
  • git commit 把修改刪掉了 — commit 把修改存進你電腦上的儲存庫,不會刪除任何東西。
  • 在你執行 git push 之前,commit 只留在你的電腦上 — commit 把版本記錄在你的電腦上。push 才把它送到 GitHub。

建立專案並安裝 Express

以下是第 1 週課程頁面的最初步驟。專案就是一個含有 package.json 檔案的資料夾。

Shell · npm 11 的範例輸出,你的路徑與數量會不同
$ cd ~/si679
$ mkdir week1hello
$ cd week1hello
$ npm init -y
Wrote to /Users/you/si679/week1hello/package.json:

{
  "name": "week1hello",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}

$ npm install express@latest

added 68 packages, and audited 69 packages in 3s

28 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
$ ls
node_modules        package-lock.json   package.json
  • npm init -y 會建立 package.json,這個檔案描述專案:名稱、指令碼,以及需要的套件。-y 會接受所有預設答案。
  • npm install express@latest 會下載 Express,以及 Express 本身需要的套件。@latest 表示要最新的版本。
  • node_modules 是 npm 放置下載套件的資料夾。絕對不要編輯它。
  • package-lock.json 記錄每個已安裝套件的確切版本。

Express 是協助 Node 程式接收 HTTP 請求的函式庫。安裝它還不會啟動任何東西。

現在開啟 package.json,在編輯器裡做第 1 週頁面所做的三項修改。

  • "type": "module" 會啟用 import,也就是你的檔案載入其他程式碼的方式。
  • "main": "server.js" 指定啟動程式的檔案。
  • 其中的 start 指令碼決定 npm start 要執行什麼。node --watch 會在你每次存檔時重新啟動程式。
package.json · 完成三項修改後
{
  "name": "week1hello",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node --watch server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "express": "^5.2.1"
  }
}

JSON 的規則很嚴格。保留雙引號,項目之間加逗號,最後一項後面不加逗號。dependencies 這一項是由 npm install 加入的。第 1 週頁面顯示相同的 Express 版本範圍。

重點整理

npm init -y 建立 package.json。npm install 把套件放進 node_modules,並列在 dependencies 底下。type 那一行啟用 import,start 指令碼讓 npm start 有東西可以執行。

檢查 · 先預測再選擇

有人不小心刪除了 node_modules。package.json 沒有被動到。怎樣把套件找回來?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 在專案資料夾執行 npm install — npm 會讀取 package.json 列出的 dependencies,再下載一次。
  • 再執行一次 npm init -y — npm init 建立 package.json,不會下載任何套件。
  • 手動重新輸入套件檔案 — node_modules 是產生出來的。npm 會依清單重建它。

執行伺服器並造訪它

在 week1hello 資料夾中,建立名為 server.js 的檔案,內容是第 1 週頁面的 Hello World 路由。

JavaScript · server.js
import express from 'express';

const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port);

第 1 節會逐行說明這段程式碼。現在只要知道:最後一行會啟動一個程式,在連接埠 3000 等待請求。

如果這是你第一次看 JavaScript,設定章節之後的 JavaScript 章節會一次教一小塊,帶你學會這個語言。

Shell · 範例輸出
$ npm start

> week1hello@1.0.0 start
> node --watch server.js

終端機印出它執行的指令碼,然後等待。不會出現新的提示字元。這是正常的:伺服器正在執行,在你停止它之前,這個終端機都被占用。第 1 週頁面用 node ./server.js 啟動同一個檔案,這樣完全不會印出任何東西。

用瀏覽器前往 http://localhost:3000。

瀏覽器 · 頁面顯示
Hello World!
  • localhost 是這台電腦的名稱。請求不會離開你的電腦。
  • 連接埠是電腦上有編號的門。程式在某個連接埠上監聽,同一個連接埠一次只能給一個程式使用。這個伺服器使用 3000,所以位址結尾是 :3000。

要停止伺服器,點一下它的終端機,再按 Ctrl+C。在 Mac 上也是用 Ctrl 鍵。提示字元會回來,瀏覽器也無法再載入那個頁面。

如果前一個伺服器還在執行時又啟動一次,Node 會停下並顯示這個錯誤。

Shell · 一長串錯誤中最重要的那一行
Error: listen EADDRINUSE: address already in use :::3000

意思是連接埠 3000 已被占用,通常是你自己在另一個終端機視窗裡的伺服器。找到那個終端機,按 Ctrl+C,再重新啟動。

重點整理

npm start 執行伺服器,終端機會停在那裡等待。localhost 指這台電腦,連接埠是伺服器監聽的那扇門。Ctrl+C 可停止它。EADDRINUSE 表示連接埠已被占用。

檢查 · 先預測再選擇

npm start 印出兩行,之後終端機就停在那裡。發生了什麼事?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 伺服器當掉了,沒有任何訊息 — 當掉會印出錯誤。安靜地等待才是正常的執行狀態。
  • 伺服器正在執行,等待請求 — 伺服器的工作就是等待。開啟 http://localhost:3000 向它送出請求。
  • npm 還在下載 Express — npm start 執行指令碼。下載發生在 npm install 的時候。

用 Postman 送出請求

Postman 是一個應用程式,可以送出你親手組成的 HTTP 請求,並顯示完整的回應:狀態、標頭與主體。請從 postman.com/downloads ↗ 下載並安裝。

需要它,是因為瀏覽器網址列只能送出 GET。第 1 節說明為什麼 POST 路由需要另一種用戶端。

第 1 週頁面這樣設定 Postman。

  1. 建立名為 SI 679 的集合,並在裡面建立名為 Week1 的資料夾。集合是一組存起來的請求。
  2. 新增名為 hello 的請求。方法設為 GET,URL 填入 http://localhost:3000/。
  3. 用 npm start 啟動伺服器,然後按 Send。
Postman · 回應
200 OK

Hello World!

200 OK 是狀態:伺服器用來表示成功的簡短代碼。下面的主體和瀏覽器顯示的文字相同。

如果 Postman 無法連線,表示伺服器沒有執行,或 URL 裡的連接埠不對。

第 1 節後面會用同樣的步驟送出帶有 JSON 主體的 POST。

重點整理

Postman 可以送出任何 HTTP 方法,並顯示狀態、標頭與主體。網址列只會送出 GET。按 Send 之前先啟動伺服器。

檢查 · 先預測再選擇

你要試一個用 app.post 註冊的路由。哪個工具適合?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Postman,把方法設為 POST — Postman 讓你選擇方法並加入主體。
  • 瀏覽器網址列,輸入同一個 URL — 網址列送出 GET,所以 POST 路由不會執行。
  • 終端機指令 npm install — npm install 下載套件,不會向你的伺服器送出請求。

第 3 週之前:安裝 MongoDB

第 1、2 週用不到這個主題。第 3 週一開始上課就會用到資料庫,課程的準備頁面要求你在上課前先把它裝好。

MongoDB 是資料庫:一種儲存資料的程式,伺服器停止後資料仍然保留。準備頁面列出三樣要安裝的東西。

  • MongoDB Community Edition 是資料庫伺服器。mongod 是它的程式名稱。
  • mongosh 是 Mongo shell,用來和那個伺服器溝通的命令列工具。
  • MongoDB Compass 是有視窗和按鈕的應用程式,用來查看同一份資料。
  1. 依照 MongoDB 針對你的平台提供的說明進行安裝 ↗。準備頁面擷取時寫的是:目前版本為 8.3,安裝最新版即可。
  2. 執行 mongosh --version。如果找不到這個指令,請另外安裝 shell:mongosh 安裝說明 ↗。有些平台的伺服器套件不含 shell。
  3. 安裝適用於你平台的 Compass ↗

在伺服器程式 mongod 執行之前,什麼都連不上。各平台的安裝說明會把它設定成隨電腦啟動的背景服務,或是要你手動啟動。只要平台提供背景服務,準備頁面建議優先採用。

如果你手動啟動,或它無法持續執行,就給它一個存放資料的資料夾。任何你能寫入的資料夾都可以。

macOS 或 Linux
$ mkdir -p ~/data/mdata
$ mongod --dbpath ~/data/mdata
Windows PowerShell
PS C:\Users\you> mkdir c:\data\mdata
PS C:\Users\you> mongod --dbpath c:\data\mdata

mongod 會印出許多紀錄訊息並持續執行,就像你的 Express 伺服器一樣。讓那個終端機保持開啟。它監聽連接埠 27017,也就是 MongoDB 的預設值。

開啟第二個終端機,用 shell 連線。它會先印出幾行連線資訊,然後出現提示字元。

Shell · 改編自第 3 週筆記,你的大小數值會不同
$ mongosh
test> show databases
admin     40.00 KiB
config    72.00 KiB
local     40.00 KiB

看到像 test> 這樣的提示字元,表示 shell 已連上伺服器。三個資料庫是 admin、config 與 local。它們由 MongoDB 自行建立,所以看到它們就表示有真正的伺服器回應。輸入 exit 可離開 shell。

Shell · 伺服器沒有執行
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017

這個錯誤表示沒有程式在監聽該連接埠。檢查你啟動 mongod 的終端機,或背景服務的狀態。

接著在 Compass 做同樣的檢查。

  1. 開啟 Compass,按 Add new connection。
  2. URI 欄位應該是 mongodb://localhost:27017。如果不是,就改成這個值。URI 是伺服器的位址:連線種類、電腦,以及連接埠。
  3. 按 Connect,再展開左側邊欄中的連線。

當 mongosh 出現提示字元,而且 Compass 顯示 admin、config 與 local,就完成了。第 3 週的程式碼把同一個位址寫成 mongodb://127.0.0.1:27017,因為 127.0.0.1 是 localhost 的數字形式。

重點整理

mongod 是資料庫伺服器,必須保持執行。mongosh 與 Compass 是查看內容的兩種方式。預設連接埠是 27017。mongosh 出現提示字元,並看到 admin、config 與 local 資料庫,就表示運作正常。

檢查 · 先預測再選擇

mongosh 停下並顯示 MongoNetworkError: connect ECONNREFUSED。你先檢查什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Compass 是否已安裝 — Compass 是另一個檢視工具。mongosh 不需要它也能連線。
  • mongod 是否正在執行 — ECONNREFUSED 表示沒有程式在監聽該連接埠。先啟動伺服器,再試一次。
  • admin 資料庫是否被刪除 — 這個錯誤發生在讀取任何資料庫之前。根本沒有伺服器回應。

設定出問題時

先讀錯誤訊息的第一行,再做其他事。它通常會點出問題。以下五種涵蓋了大多數設定失敗的情況。

Shell · 真實訊息,路徑已改成範例資料夾
zsh: command not found: node

npm error enoent Could not read package.json: Error: ENOENT: no such file or directory, open '/Users/you/si679/package.json'

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'express' imported from /Users/you/si679/week1hello/server.js

Error: listen EADDRINUSE: address already in use :::3000
  • command not found:終端機找不到那個程式。它還沒安裝,或這個終端機是在安裝完成前開啟的。開一個新的終端機再試一次。PowerShell 會把同樣的問題寫成「is not recognized」。
  • Could not read package.json:你在錯的資料夾。先用 pwd 確認,再用 cd 進入專案資料夾。
  • Cannot find package:套件還沒下載。clone 下來的起始專案要先執行一次 npm install 才能執行。
  • 瀏覽器無法連線:沒有程式在監聽。用 npm start 啟動伺服器,讓它的終端機保持開啟,並檢查 URL 裡的連接埠。
  • address already in use:另一個程式占用了連接埠,通常是你先前啟動的伺服器。在那個終端機按 Ctrl+C,再重新啟動。

桌邊速查有一張更完整的表格,供後面幾週使用。

重點整理

五種原因涵蓋了大多數設定失敗:程式沒安裝、資料夾不對、套件沒安裝、伺服器沒執行、連接埠已被占用。先讀錯誤的第一行,再用 pwd 確認你在哪裡。

檢查 · 先預測再選擇

你 clone 了一個起始儲存庫,立刻執行 npm start。它停下並顯示 Cannot find package 'express'。怎麼修正?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 重新安裝 Node — Node 有執行並回報了錯誤,所以 Node 沒問題。缺的是套件。
  • 更改連接埠號碼 — 程式在開始監聽之前就停了。和連接埠無關。
  • 在那個資料夾執行 npm install,再重新啟動 — clone 下來的起始專案只列出套件,並不包含套件。npm install 會把它們下載下來。

來源依據 · 第 1 週課程頁面(Express 專案、Postman、Now You Try 流程)、第 3 週筆記中的 Node 版本,以及第 3 週準備頁面。終端機、編輯器與 git 的基礎屬於一般用法。範例輸出擷取自一台 Mac。

JS

第 1 週之前 · 值、物件、函式、回呼函式、模組、await 與錯誤

先學會這個語言。

會 Python 的話,從這裡開始

JavaScript 的大部分寫法在 Python 裡都有對應。下表每一列把兩者並排,並點出陷阱;之後的每個主題也會用兩種語言並排呈現它的範例。兩種都沒學過?改從 執行一行 JavaScript 開始。

PythonJavaScript注意
print('Order received')console.log('Order received');Node 用自己的格式印出物件,而不是 JSON。
x = 1const x = 1;
let x = 1;
名稱只建立一次,用 const 或 let。用 const 建立的名稱不能換成新的值。
items = ['a', 'b']const items = ['a', 'b'];這叫陣列。它的 push 就是 Python 的 append。讀過尾端時,例如 items[5],會得到 undefined,而不是報錯。
order = {'pizza': 'cheese'}const order = { pizza: 'cheese' };這叫物件。鍵都是字串,寫的時候不加引號,讀取時用 order.pizza。缺少的鍵會得到 undefined,而不是報錯。
def total(price, quantity):
lambda price: price * 2
function total(price, quantity) { }
(price) => price * 2
沒有 return 時,結果是 undefined,不是 None;課程程式碼最常用的是箭頭寫法。
for item in items:for (const item of items) { }要寫 of,不是 in:用 for...in 寫的迴圈拿到的是索引,而且是字串。
if / elif / else
and, or, not
if / else if / else
&&, ||, !
條件外面加小括號,每個區塊外面加大括號。空陣列是真值。
=====用三個。兩個等號會先轉換型別,所以 '5' == 5 的結果是 true。
f'{quantity} pizzas'`${quantity} pizzas`用反引號而不是引號,大括號前面還要加錢號。
Nonenull
undefined
undefined 在 Python 裡沒有對應。缺少的欄位、缺少的引數,以及沒有 return 的函式,都會得到它。
try:
except ValueError as error:
try { }
catch (error) { }
Python 的 raise 是 throw。一個 catch 會接住所有錯誤,而 error.message 存著錯誤的文字。
from menu import menuimport { menu } from './menu.js';引號裡的部分是路徑,要寫 ./ 與 .js。只有標上 export 的名稱才能被匯入。
async def
await
async function
await
概念相同。Node 允許在模組最上層寫 await,Python 則需要 asyncio.run 才能開始。
len(items)items.length這是屬性,所以不加括號。
'a' + 1 # TypeError'a' + 1 // 'a1'JavaScript 會直接接起來,不會失敗。先轉換:Number('5') 是 5,就像 Python 的 int('5')。
  • 區塊由大括號標出,不是靠縮排。但還是要縮排,人才讀得懂。
  • 分號結束一個敘述。JavaScript 通常省略分號也能執行,但課程會寫,這份指南也照寫。
  • 每個名稱都用 const 建立。改用 let,只限於名稱必須換成新值的時候。
  • undefined 是真正的值,不是錯誤。你會在程式錯誤裡遇到它:拼錯的欄位、漏掉的 await,或沒有 return 敘述的函式。

重點整理

大部分 Python 寫法在 JavaScript 都有對應:print 是 console.log,dict 是物件,list 是陣列,f-string 是範本字面值。要留意的是用 === 而不是 ==、用大括號而不是縮排、const,以及 Python 沒有的 undefined。

檢查 · 先預測再選擇

JavaScript
const quantity = '2';
console.log(quantity + 1);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 3 — quantity 存的是字串 2,不是數字。只要有一邊是字串,+ 就會把文字接起來。
  • 21 — 字串 2 和數字 1 被當成文字接起來。Python 在這裡會因 TypeError 而停下。
  • 一個 TypeError,和 Python 一樣 — Python 拒絕把字串和數字相加。JavaScript 則把 1 轉成文字再接起來,不會報錯。

執行一行 JavaScript

連回設定章節:Node 已經安裝好,編輯器也能開啟資料夾。這一節教 Node 執行的語言,一次教一小塊。

JavaScript 是一種語言,用來寫出電腦要照著做的指令。程式就是裝著這些指令的檔案。這裡每個主題都補上課程程式碼的一塊。

先建立一個練習用的資料夾。在你的 si679 資料夾裡,建立名為 js-primer 的資料夾,並像設定章節那樣用編輯器開啟它。接著建立名為 hello.js 的檔案,寫入這四行後存檔。

JavaScript · hello.js
// A line that starts with // is a comment. Node skips it.
console.log('Order received');
console.log(2 + 3);
console.log('Done');

在終端機裡,切換到 js-primer 資料夾,請 Node 執行這個檔案。

Shell · 輸出
$ node hello.js
Order received
5
Done

Python

# A line that starts with # is a comment. Python skips it.
print('Order received')  # → Order received
print(2 + 3)  # → 5
print('Done')  # → Done

JavaScript

// A line that starts with // is a comment. Node skips it.
console.log('Order received'); // → Order received
console.log(2 + 3); // → 5
console.log('Done'); // → Done
  • 每一行結尾是 ; 的就是一個敘述:一個完整的指令。
  • Node 由上往下執行敘述,一次一個。
  • console.log(...) 會把括號裡的內容印到終端機。除非有敘述要求,否則程式不會印出任何東西。
  • 文字要放在引號裡。2 + 3 沒有引號,所以 Node 會算出結果再印出來。

如果 Node 回答 Cannot find module,表示它找不到那個檔案。先檢查檔名有沒有拼錯。再用 pwd 與 ls 確認終端機位於放著那個檔案的資料夾。

重點整理

程式是由敘述組成的檔案。node hello.js 會由上往下執行它們。console.log 把值印到終端機。以 // 開頭的行是註解,不會執行。

檢查 · 先預測再選擇

JavaScript
console.log('Take order');
// console.log('Bake pizza');
console.log(4 + 4);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Take order,接著是 Bake pizza,接著是 8 — 第二行以 // 開頭,所以是註解。Node 會跳過它。
  • Take order,接著是 8 — 註解被跳過,而 4 + 4 沒有引號,所以 Node 會算出結果。
  • Take order,接著是 4 + 4 — 只有引號裡的文字才會照原樣印出。沒有引號時,Node 會把數字相加。

值:文字、數字,以及 true 或 false

值是一筆資料。每個值都有型別,也就是它屬於哪一種資料。這門課一直在用其中五種。

  • 字串是文字。'cheese' 就是一個字串:引號標出文字從哪裡開始、到哪裡結束。單引號和雙引號都可以,只要開頭和結尾用同一種。
  • 數字沒有引號。2 與 5.99 都是數字,而這些符號 +, -, * 與 / 分別把數字相加、相減、相乘、相除。
  • true 與 false 是僅有的兩個布林值。布林值回答的是「是或不是」的問題。
  • undefined 表示從來沒有給過值。
  • null 表示有人刻意寫下「這裡沒有東西」。

typeof 會回報一個值的型別。把這幾行存成 values.js 並執行這個檔案。

JavaScript · values.js
console.log(typeof 'cheese');
console.log(typeof 2);
console.log(typeof true);
console.log(typeof undefined);

console.log(5 + 5);
console.log('5' + '5');
console.log('5' + 5);
console.log(Number('5') + 5);
Shell · 輸出
$ node values.js
string
number
boolean
undefined
10
55
55
10

Python

print(5 + 5)  # → 10
print('5' + '5')  # → 55
print('5' + 5)  # → TypeError: can only concatenate str (not "int") to str

JavaScript

console.log(5 + 5); // → 10
console.log('5' + '5'); // → 55
console.log('5' + 5); // → 55

最後四行說明了型別為什麼重要。兩邊都是數字時,+ 會相加。只要有一邊是字串,+ 就改成把文字接起來,所以 '5' + 5 會是文字 55。Python 遇到這一行則會直接報錯。

第 1 週就會遇到這件事。用戶端放進網址的所有內容,送達時都是字串,就算看起來像數字也一樣。Number('5') 會把字串轉成數字,之後 + 才會相加。

重點整理

字串是放在引號裡的文字,數字沒有引號,布林值是 true 或 false。undefined 表示從來沒給過值;null 表示刻意留空。字串 5 和數字 5 是不同的值:+ 會把字串接起來,把數字相加。

檢查 · 先預測再選擇

JavaScript
console.log('2' + 3);
console.log(2 + 3);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 23,接著是 5 — 只要有一邊是字串,+ 就會把文字接起來。兩個數字才會相加。
  • 5,接著是 5 — 引號有影響。第一個 2 是字串,而 + 會把字串和旁邊的東西接起來。
  • 出現錯誤,接著是 5 — JavaScript 不會拒絕把字串和數字混在一起。它會悄悄把兩者接起來,所以這種錯誤很容易漏看。

替值取名字:const 與 let

變數是一個保存著值的名稱,後面的程式行就能用名稱取用那個值。const 與 let 都能建立變數。

JavaScript · names.js
const pizza = 'cheese';
let quantity = 1;

quantity = quantity + 1;

console.log(pizza);
console.log(quantity);
Shell · 輸出
$ node names.js
cheese
2

Python

pizza = 'cheese'
quantity = 1

quantity = quantity + 1

print(pizza)  # → cheese
print(quantity)  # → 2

JavaScript

const pizza = 'cheese';
let quantity = 1;

quantity = quantity + 1;

console.log(pizza); // → cheese
console.log(quantity); // → 2
  • const pizza = 'cheese'; 會建立名稱 pizza,並把字串存進去。
  • 單獨一個 = 的意思不是「等於」。它的意思是:先算出右邊,再把結果存到左邊的名稱底下。所以 quantity = quantity + 1 才說得通。
  • 用 const 建立的名稱會保留它的值。用 let 建立的名稱之後可以換成新的值。Python 沒有這兩個字:任何名稱都能改。

給 const 名稱新的值會讓程式停下來。把 pizza = 'veggie'; 放在 const pizza = 'cheese'; 的下一行,Node 就會印出這個。

Shell · 一長串錯誤中最重要的那一行
TypeError: Assignment to constant variable.

這門課幾乎到處都用 const,因為不會改變的名稱比較容易看懂。只有在名稱稍後才拿到值,或需要換成新值時,才用 let:第 3 週的資料庫連線,以及第 2 週刪除後被整個換掉的書籍清單。

重點整理

const 和 let 替值取名字。單獨一個 = 會把右邊存到左邊的名稱底下。const 名稱保留它的值;let 名稱可以換成新的值。除非值一定要改變,否則用 const。

檢查 · 先預測再選擇

JavaScript
let quantity = 2;
const price = 10;
quantity = quantity + 1;
console.log(quantity * price);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 20 — 第三行把新的值存進 quantity,所以最後一行執行時它是 3。
  • 出現錯誤,因為 quantity 改變了 — quantity 是用 let 建立的,所以可以改變。只有 const 名稱才會拒絕新的值。
  • 30 — quantity 變成 3,而 3 * 10 是 30。

物件:有標籤的資訊

第 0 節把一筆訂單讀成兩個有標籤的資訊:披薩是起司口味,數量是一份。JavaScript 用物件把有標籤的資訊放在一起。每個標籤連同它的值稱為屬性。本指南也稱它為欄位。

JavaScript · objects.js
const order = { pizza: 'cheese', quantity: 1 };

console.log(order.pizza);
console.log(order.size);

order.quantity = 2;
console.log(order);
console.log(JSON.stringify(order));
Shell · 輸出
$ node objects.js
cheese
undefined
{ pizza: 'cheese', quantity: 2 }
{"pizza":"cheese","quantity":2}

Python

import json
order = {'pizza': 'cheese', 'quantity': 1}

print(order['pizza'])  # → cheese
print(order.get('size'))  # → None

order['quantity'] = 2
print(json.dumps(order))  # → {"pizza": "cheese", "quantity": 2}

JavaScript

const order = { pizza: 'cheese', quantity: 1 };

console.log(order.pizza); // → cheese
console.log(order.size); // → undefined

order.quantity = 2;
console.log(JSON.stringify(order)); // → {"pizza":"cheese","quantity":2}

Python 用中括號讀取不存在的鍵時,會因 KeyError 而停下。JavaScript 則給出 undefined,然後繼續執行。

  • order.pizza 會讀取一個欄位:物件的名稱、一個點,再加上欄位的名稱。
  • 讀取不存在的欄位會得到 undefined。這不是錯誤,所以拼錯欄位名稱時不會有任何提示。
  • order.quantity = 2 會把新的值存進欄位。即使物件是用 const 建立的也可以。const 只是讓名稱 order 不能改指向另一個物件。它不會阻止欄位改變。

最後兩行用兩種形式印出同一個物件。第一種是 Node 顯示物件的方式。第二種是 JSON:規則更嚴格的文字,例如每個名稱都要加雙引號,這樣才能放在訊息裡傳送。JSON.stringify 會把物件轉成 JSON 文字。物件存在於執行中的程式裡。JSON 則是請求、回應或檔案裡的文字。

物件裡的值可以是另一個物件,點也可以一個接一個。order.customer.name 會先讀取 customer,再讀取其中的 name 欄位。

重點整理

物件把有標籤的值放在一起。用點讀取欄位,不存在的欄位是 undefined,不是錯誤。const 固定的是名稱,不是欄位。JSON 是同一個概念的文字形式,用在訊息裡。

檢查 · 先預測再選擇

JavaScript
const book = { title: 'Dune', year: 1965 };
console.log(book.author);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • undefined — book 沒有 author 欄位,而讀取不存在的欄位會得到 undefined。
  • 出現錯誤,因為 author 不存在 — 讀取不存在的欄位不是錯誤。所以拼錯欄位名稱時不會有任何提示。
  • null — null 是有人刻意寫下的值。從來沒寫過的欄位是 undefined。

陣列:有順序的清單

陣列是一串依序排列的值。中括號標出它的開頭和結尾,項目之間用逗號分開。

JavaScript · arrays.js
const menu = ['cheese', 'pepperoni', 'veggie'];

console.log(menu[0]);
console.log(menu.length);
console.log(menu[3]);

menu.push('hawaiian');
console.log(menu.length);
console.log(menu);

const books = [
  { title: 'Dune', year: 1965 },
  { title: 'The Hobbit', year: 1937 }
];
console.log(books[1].title);
Shell · 輸出
$ node arrays.js
cheese
3
undefined
4
[ 'cheese', 'pepperoni', 'veggie', 'hawaiian' ]
The Hobbit

Python

menu = ['cheese', 'pepperoni', 'veggie']

print(menu[0])  # → cheese
print(len(menu))  # → 3
print(menu[3])  # → IndexError: list index out of range

JavaScript

const menu = ['cheese', 'pepperoni', 'veggie'];

console.log(menu[0]); // → cheese
console.log(menu.length); // → 3
console.log(menu[3]); // → undefined
  • 每個項目都有一個位置,稱為索引,而第一個索引是 0。menu[0] 是第一個項目。
  • .length 是項目的數量。最後一個項目位於索引 length - 1,所以在三個項目的清單裡,menu[3] 已經超出結尾,會得到 undefined,而不是錯誤。
  • push 會在結尾加上一個項目。就像物件的欄位一樣,用 const 建立的陣列,裡面的項目還是可以改變。
  • 陣列可以裝物件。books[1].title 會先讀取第二本書,再讀取它的 title 欄位。

這門課大部分的資料都是最後這種形狀:一串書籍、菜單品項或產品,每一個都是物件。

重點整理

陣列是有順序的清單。索引從 0 開始,.length 計算項目數量,push 在結尾加上一個項目。課程資料通常是由物件組成的陣列。

檢查 · 先預測再選擇

JavaScript
const sizes = ['small', 'medium', 'large'];
sizes.push('party');
console.log(sizes[1]);
console.log(sizes.length);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • small,接著是 4 — 索引從 0 開始,所以索引 1 是第二個項目。
  • medium,接著是 4 — 索引 1 是第二個項目,而 push 讓清單變成四個項目。
  • medium,接著是 3 — 即使名稱是用 const 建立的,push 還是會改變陣列。const 只固定名稱指向哪一個陣列。

做決定:if、else 與比較

比較會提出一個問題,並產生一個布林值。

  • === 在兩邊是同一型別的同一個值時為 true。!== 則相反。
  • <, >, <= 與 >= 用來比較大小。

if 只有在小括號裡的條件為 true 時,才會執行大括號裡的敘述。else 則在條件不是 true 時執行自己的大括號。一對大括號之間的敘述稱為區塊。

JavaScript · decisions.js
const order = { pizza: 'cheese', quantity: 0 };

if (order.quantity === 0) {
  console.log('Quantity is zero');
} else {
  console.log('Quantity is not zero');
}

if (!order.quantity) {
  console.log('Looks missing, but it is 0');
}

if (order.size === undefined || order.pizza === '') {
  console.log('Something is missing');
}

console.log(order.quantity < 1 && order.pizza !== 'veggie');
console.log('5' === 5);
Shell · 輸出
$ node decisions.js
Quantity is zero
Looks missing, but it is 0
Something is missing
true
false

Python

order = {'pizza': 'cheese', 'quantity': 0}

if not order['quantity']:
    print('Looks missing, but it is 0')  # → Looks missing, but it is 0

print(order['quantity'] < 1 and order['pizza'] != 'veggie')  # → True
print('5' == 5)  # → False

JavaScript

const order = { pizza: 'cheese', quantity: 0 };

if (!order.quantity) {
  console.log('Looks missing, but it is 0'); // → Looks missing, but it is 0
}

console.log(order.quantity < 1 && order.pizza !== 'veggie'); // → true
console.log('5' === 5); // → false

&& || ! 依序就是 Python 的 and or not。有一個陷阱:空陣列是真值,Python 的空 list 卻是假值。

看第二個 if。它的條件不是比較,只是一個值。這時 JavaScript 會把那個值當成 true 或 false。下列的值會被當成 false,稱為假值:false, 0, ''(裡面什麼都沒有的字串)、null, undefined 與 NaN(數字轉換失敗時得到的結果)。其他的值都會被當成 true,稱為真值。就連字串 '0' 也是真值。

所以 !order.quantity 在數量不存在時是 true,在數量是 0 時也是 true。這門課不只一次在這裡出錯,因為價格 0 或空字串可能是真正的答案。當你的意思是「不存在」時,請直接和 undefined 比較,就像第三個 if 那樣。

最後一行說明了這門課為什麼寫三個等號。'5' === 5 是 false,因為字串和數字永遠不會是同一個值。

重點整理

=== 比較值和型別。條件為 true 時 if 執行它的區塊;否則執行 else。false、0、空字串、null、undefined 和 NaN 都是假值,所以 !value 分不出 0 和不存在。

檢查 · 先預測再選擇

JavaScript
const price = 0;
if (!price) {
  console.log('Price is required');
} else {
  console.log('Price accepted');
}
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Price accepted — 0 是假值,所以 !price 是 true,執行的是第一個區塊。免費的產品會被擋下來。
  • 什麼都不會印出 — 有 if 和 else 時,兩個區塊一定會執行其中一個。
  • Price is required — 0 是假值,所以 !price 是 true。原本要檢查價格是否存在的條件,就這樣把價格 0 也拒絕了。

迴圈:對每個項目重複一次

迴圈會把同樣的敘述執行不只一次。這門課用到兩種,而且只出現在少數地方。

JavaScript · loops.js
const products = [
  { name: 'Notebook', price: 3 },
  { name: 'Pencil', price: 1 },
  { name: 'Stapler', price: 9 }
];

let total = 0;
for (const product of products) {
  console.log(product.name);
  total = total + product.price;
}
console.log(total);

let count = 3;
while (count > 0) {
  console.log(count);
  count = count - 1;
}
Shell · 輸出
$ node loops.js
Notebook
Pencil
Stapler
13
3
2
1

Python

total = 0
for product in products:
    print(product['name'])  # → Notebook, Pencil, Stapler
    total = total + product['price']

print(total)  # → 13

JavaScript

let total = 0;
for (const product of products) {
  console.log(product.name); // → Notebook, Pencil, Stapler
  total = total + product.price;
}
console.log(total); // → 13
  • for (const product of products) 會依序對陣列裡的每個項目執行一次它的區塊。每一次,product 都是目前這個項目的名稱。
  • total 在迴圈之前用 let 建立,因為每一輪都會給它新的值。
  • while (count > 0) 只要條件是 true,就會一再執行它的區塊。區塊裡必須有東西改變條件,否則迴圈永遠不會結束。

你會在第 4 週的測試裡遇到 for,它在那裡一次儲存一個產品;也會在第 3 週遇到一次 while,它在那裡一次讀取一筆資料庫結果。課程大部分的程式碼用另一種方式走過陣列:用函式。接下來兩個主題會一步步講到那裡。

重點整理

for...of 對陣列的每個項目執行一次區塊。while 在條件為 true 時不斷重複。跨越每一輪累積起來的值,要在迴圈之前用 let 建立。

檢查 · 先預測再選擇

JavaScript
const quantities = [2, 1, 3];
let total = 0;
for (const quantity of quantities) {
  total = total + quantity;
}
console.log(total);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 6 — 迴圈先加 2,再加 1,再加 3。console.log 在結尾的大括號之後,所以只執行一次。
  • 3 — 迴圈不會只留下最後一個項目。每一輪都會加到已經累積的總和上。
  • 2,接著是 3,接著是 6 — 那需要把 console.log 放在大括號裡面。這裡它在大括號之後,所以等迴圈結束後只執行一次。

函式:有名字的一組指令

函式是一組有名字的敘述。寫出函式並不會執行它。呼叫才會:先寫名稱,再加上小括號。

JavaScript · functions.js
function orderTotal(price, quantity) {
  return price * quantity;
}

const orderTotalArrow = (price, quantity) => {
  return price * quantity;
};

const orderTotalShort = (price, quantity) => price * quantity;

console.log(orderTotal(12, 2));
console.log(orderTotalArrow(12, 2));
console.log(orderTotalShort(12, 2));

const logOrder = (pizza) => {
  console.log('Order: ' + pizza);
};

const result = logOrder('cheese');
console.log(result);

const titleOf = book => book.title;
console.log(titleOf({ title: 'Dune', year: 1965 }));
Shell · 輸出
$ node functions.js
24
24
24
Order: cheese
undefined
Dune

Python

def orderTotal(price, quantity):
    return price * quantity


orderTotalShort = lambda price, quantity: price * quantity

print(orderTotal(12, 2))  # → 24
print(orderTotalShort(12, 2))  # → 24

JavaScript

function orderTotal(price, quantity) {
  return price * quantity;
}

const orderTotalShort = (price, quantity) => price * quantity;

console.log(orderTotal(12, 2)); // → 24
console.log(orderTotalShort(12, 2)); // → 24

沒有 return 時,函式會回傳 undefined,就像 Python 函式會回傳 None,所以 result 存的就是它。箭頭函式就是 lambda,只是還能放進一整個區塊。

  • price 與 quantity 是參數:代表輸入的名稱,只能在函式裡面使用。
  • orderTotal(12, 2) 會呼叫這個函式。12 和 2 這兩個值是它的引數,依順序對應到參數。
  • return 會把一個值交回給呼叫的地方,並結束函式。呼叫 orderTotal(12, 2) 就變成 24 這個值。

這個檔案把同一個函式寫了三次。第一種用了 function 這個字。第二種是箭頭函式:先寫參數,再寫 =>,然後是函式主體,並用 const 存到一個名稱底下。第三種是箭頭函式的簡短寫法。當主體只有一個值時,可以省略大括號和 return 這個字,那個值就會被回傳。

這門課幾乎到處都寫箭頭函式。剛好只有一個參數時,參數外面的小括號可以省略。book => book.title 是一個完整的函式,它接收一本書並回傳書名。

重點整理

函式是有名字、可以重複使用的程式碼。參數替輸入取名字,return 把值交回去。箭頭函式的簡短寫法會回傳它唯一的值。有大括號時必須寫 return,否則函式會交回 undefined。

檢查 · 先預測再選擇

JavaScript
const double = (n) => {
  n * 2;
};
console.log(double(4));
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 8 — 有大括號時,只有 return 才會把值交回去。只有不加大括號的簡短寫法才會自動回傳它的值。
  • undefined — 主體在大括號裡,而且沒有 return,所以函式會交回 undefined。
  • 4 — 函式不會把引數原樣交回去。沒有 return 時,它會交回 undefined。

回呼函式:把函式交給另一個函式

函式也是一個值。它可以存到名稱底下,也可以當成引數交給另一個函式。交出去讓別的程式碼呼叫的函式,就是回呼函式。它是你寫的。但什麼時候執行、會收到什麼,由另一段程式碼決定。

先用陣列來看。filter 是每個陣列都有的函式。像這樣屬於某個值的函式稱為方法。filter 會對每個項目呼叫一次你的回呼函式,並留下回呼函式回傳 true 的項目。

JavaScript · callbacks.js
const books = [
  { title: 'Dune', year: 1965 },
  { title: 'The Hobbit', year: 1937 },
  { title: 'Emma', year: 1815 }
];

const isOld = (book) => book.year < 1960;

const oldBooks = books.filter(isOld);
console.log(oldBooks);

const newBooks = books.filter((book) => book.year >= 1960);
console.log(newBooks);

books.forEach((book) => {
  console.log(book.title);
});
Shell · 輸出
$ node callbacks.js
[ { title: 'The Hobbit', year: 1937 }, { title: 'Emma', year: 1815 } ]
[ { title: 'Dune', year: 1965 } ]
Dune
The Hobbit
Emma

Python

isOld = lambda book: book['year'] < 1960

oldBooks = list(filter(isOld, books))

newBooks = list(filter(lambda book: book['year'] >= 1960, books))
print(newBooks)  # → [{'title': 'Dune', 'year': 1965}]

JavaScript

const isOld = (book) => book.year < 1960;

const oldBooks = books.filter(isOld);

const newBooks = books.filter((book) => book.year >= 1960);
console.log(newBooks); // → [ { title: 'Dune', year: 1965 } ]
  • books.filter(isOld) 交出去的是函式本身。isOld 後面沒有小括號,因為你並不是在呼叫它。filter 會呼叫它三次,每次給它一本書。
  • 第二個 filter 直接在原地寫出回呼函式,沒有先替它取名字。這是課程最常用的寫法。
  • forEach 會對每個項目呼叫一次回呼函式,不留下任何結果。它做的事和 for 迴圈一樣。

現在把同樣的概念用在 Express。這是設定章節的伺服器,多加了兩行 console.log。把它存成 server.js,放在你的 week1hello 資料夾裡並執行。接著在瀏覽器載入 http://localhost:3000 兩次。

JavaScript · week1hello 裡的 server.js
import express from 'express';

const app = express();

app.get('/', (req, res) => {
  console.log('Handler ran');
  res.send('Hello World!');
});

app.listen(3000);
console.log('Waiting for requests');
Shell · 兩次請求之後的輸出
$ node server.js
Waiting for requests
Handler ran
Handler ran

app.get 被呼叫時帶了兩個引數:路徑 '/' 和一個回呼函式。把這個呼叫讀成:當針對這個路徑的 GET 請求送達時,執行這個函式。Express 會把回呼函式留著,每有一個符合的請求就呼叫它一次。它會把請求當成 req 傳進去,把等著填寫的回應當成 res 傳進去。課程頁面把這個回呼函式稱為處理函式。Flask 則改用裝飾器來標出處理函式。

輸出顯示了時間順序。Waiting for requests 最先印出,雖然它是檔案的最後一行,因為註冊回呼函式並不會執行它。Handler ran 只有在請求送達時才會印出,每個請求一次。按 Ctrl+C 停止伺服器。

重點整理

回呼函式是你交給別的程式碼、讓它稍後呼叫的函式。filter 對每個項目呼叫一次你的函式。app.get(path, handler) 會把你的函式留著,每當符合的請求送達時就呼叫它,而不是在檔案開始執行時。

檢查 · 先預測再選擇

JavaScript · week1hello 裡的一個檔案
import express from 'express';
const app = express();

app.get('/menu', (req, res) => {
  console.log('B');
  res.send('menu');
});

app.listen(3000);
console.log('A');
你用 Node 啟動這個檔案。還沒有任何請求送出。這時已經印出了什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • B,接著是 A — app.get 只是註冊回呼函式。在請求送達之前,裡面的程式碼都不會執行。
  • A,接著是 B — 回呼函式不會在啟動時先執行一次。只有針對 /menu 的 GET 請求送達時,它才會執行。
  • 只有 A — 註冊路由不會執行它的回呼函式。B 之後才會印出,每個送到 /menu 的請求印一次。

課程到處都在用的三種簡寫

這三種都只是把你已經會的東西寫得更短。幾乎每個課程檔案裡都會出現。

JavaScript · syntax.js
const order = { pizza: 'cheese', quantity: 2 };

const { pizza, quantity } = order;
console.log(`Order: ${quantity} ${pizza}`);
console.log(`Total: ${quantity * 12} dollars`);

const size = 'large';
const summary = { pizza, size };
console.log(summary);

const { crust } = order;
console.log(crust);
Shell · 輸出
$ node syntax.js
Order: 2 cheese
Total: 24 dollars
{ pizza: 'cheese', size: 'large' }
undefined

Python

order = {'pizza': 'cheese', 'quantity': 2}

pizza, quantity = order['pizza'], order['quantity']
print(f'Order: {quantity} {pizza}')  # → Order: 2 cheese

size = 'large'
summary = {'pizza': pizza, 'size': size}
print(summary)  # → {'pizza': 'cheese', 'size': 'large'}

JavaScript

const order = { pizza: 'cheese', quantity: 2 };

const { pizza, quantity } = order;
console.log(`Order: ${quantity} ${pizza}`); // → Order: 2 cheese

const size = 'large';
const summary = { pizza, size };
console.log(summary); // → { pizza: 'cheese', size: 'large' }
  • 解構。const { pizza, quantity } = order; 會用兩個同名的欄位建立兩個名稱。它的意思等同於 const pizza = order.pizza; 再加上 const quantity = order.quantity;。不存在的欄位會得到 undefined,就像 crust 的結果。
  • 範本字面值。用反引號(`)而不是引號寫成的字串,裡面可以放 ${...}。JavaScript 會算出大括號裡的內容,再把結果放進文字裡。
  • 屬性簡寫。{ pizza, size } 的意思等同於 { pizza: pizza, size: size }。當欄位和保存它的值的變數同名時,名稱寫一次就好。

第 1 週把前兩種一起用。const { itemid } = req.query; 從請求裡取出一個欄位,再由範本字面值把它放進回覆。

第 2 節有一張表,列出課程用到的陣列方法(例如 find 和 map),以及另外兩種簡寫:展開與其餘。

重點整理

解構把欄位取出來變成名稱。範本字面值用反引號和 ${...} 把值放進文字。{ pizza, size } 是 { pizza: pizza, size: size } 的簡寫。三種都是簡寫,不是新的行為。

檢查 · 先預測再選擇

JavaScript
const book = { title: 'Dune', year: 1965 };
const { title, author } = book;
console.log(`${title} by ${author}`);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • Dune by — 不存在的值不會留白。範本字面值會把 undefined 變成文字 undefined。
  • 出現錯誤,因為 book 沒有 author — 解構不存在的欄位不是錯誤。名稱還是會建立,值是 undefined。
  • Dune by undefined — book 沒有 author 欄位,所以 author 是 undefined,範本字面值會把它當成文字印出。

模組:一個檔案使用另一個檔案

真正的專案有很多檔案。模組就是一個裝著程式碼的檔案。export 標出這個檔案提供給其他檔案的東西,而 import 把那些名稱帶進另一個檔案。

使用 import 的資料夾需要先做一項設定。照設定章節的做法。在終端機裡,於 js-primer 資料夾內執行 npm init -y。接著把 "type": "module" 寫進新的 package.json 檔案,再建立下面兩個檔案。

JavaScript · menu.js
export const menu = ['cheese', 'pepperoni', 'veggie'];

export const priceOf = (size) => {
  if (size === 'large') {
    return 15;
  }
  return 12;
};
JavaScript · order.js
import { menu, priceOf } from './menu.js';

console.log(menu[0]);
console.log(priceOf('large'));
Shell · 輸出
$ node order.js
cheese
15

Python

# order.py, beside menu.py
from menu import menu, priceOf

print(menu[0])  # → cheese
print(priceOf('large'))  # → 15

JavaScript

// order.js, beside menu.js
import { menu, priceOf } from './menu.js';

console.log(menu[0]); // → cheese
console.log(priceOf('large')); // → 15

Python 依模組名稱匯入,而且最上層的每個名稱都能匯入。JavaScript 依路徑匯入,而且只提供標上 export 的名稱。

  • import { menu, priceOf } from './menu.js'; 要求取得兩個名稱。大括號裡的名稱必須和匯出的名稱完全一致。
  • 引號裡的部分是一個字串,說明程式碼在哪裡。./ 表示「和這個檔案在同一個資料夾」,檔名要完整寫出,結尾加上 .js。
  • 沒有 ./ 的字串,指的則是 npm 安裝在 node_modules 裡的套件。這就是為什麼 from 'express' 與 from './menu.js' 看起來不一樣。

匯入一個檔案時,會把它由上往下執行一次,然後交出它匯出的東西。只定義東西的檔案,例如 menu.js,被匯入時不會印出任何東西。

少了 ./,Node 就會去找套件,而不是你的檔案。order-broken.js 複製自 order.js,只是改寫成 from 'menu.js',執行後會這樣失敗。

Shell · 一長串錯誤中最重要的那一行;資料夾已改成範例資料夾
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'menu.js' imported from /Users/you/si679/js-primer/order-broken.js

重點整理

模組就是一個檔案。export 提供名稱,import 把它帶進另一個檔案。引號裡的部分是路徑:./ 表示這個資料夾,沒有 ./ 則表示已安裝的套件。匯入一個檔案會把它執行一次。

檢查 · 先預測再選擇

JavaScript · 同一個資料夾裡的兩個檔案
// special.js
export const special = 'veggie';
console.log('special.js ran');

// today.js
import { special } from './special.js';
console.log(special);
你執行 node today.js。會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • special.js ran,接著是 veggie — 匯入 special.js 時會把它由上往下執行一次,所以它的 console.log 先執行。接著 today.js 才繼續。
  • 只有 veggie — import 不會只複製一個名稱而跳過其他部分。被匯入的整個檔案會執行一次。
  • veggie,接著是 special.js ran — 被匯入的檔案,會在匯入它的檔案的敘述之前執行。

等待慢的工作:Promise、async、await

有些工作很慢:詢問資料庫、呼叫另一台伺服器。JavaScript 不會停下來等。開始慢工作的函式會立刻回傳,而它回傳的是 Promise:一個物件,代表還沒準備好的結果。之後 Promise 不是交出那個值,就是帶著錯誤失敗。

await 就是取得那個值的方法。把它寫在 Promise 前面,它會暫停你的函式,直到 Promise 完成,再把值交給你。

想不靠資料庫試試看,可以把這個替身存成 db.js。它的 findProduct 會在十分之一秒後回答,就像資料庫呼叫需要一點時間。接著存好 product.js 並執行。

JavaScript · db.js,資料庫的替身
// db.js stands in for a database. Copy it as it is.
const products = [
  { id: 1, name: 'Notebook', price: 3 },
  { id: 2, name: 'Stapler', price: 9 }
];

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const findProduct = async (id) => {
  await wait(100);
  for (const product of products) {
    if (product.id === id) {
      return product;
    }
  }
  return null;
};
JavaScript · product.js
import { findProduct } from './db.js';

const product = await findProduct(2);
console.log(product.name);

const showName = async (id) => {
  const found = await findProduct(id);
  console.log(found.name);
};
await showName(1);

const forgot = findProduct(2);
console.log(forgot);
console.log(forgot.name);
Shell · 輸出
$ node product.js
Stapler
Notebook
Promise { <pending> }
undefined

Python

import asyncio
from db import findProduct
async def main():
    product = await findProduct(2)
    print(product['name'])  # → Stapler
    forgot = findProduct(2)
    print(forgot)  # → <coroutine object findProduct>
asyncio.run(main())

JavaScript

import { findProduct } from './db.js';

const product = await findProduct(2);
console.log(product.name); // → Stapler

const forgot = findProduct(2);
console.log(forgot); // → Promise { <pending> }

Python 的 async def 與 await 的運作方式相同,但只有 Python 會對從未被 await 的協程發出警告。

await 在哪裡暫停兩條時間軸,各自由上往下讀。有 await 時,程式碼是 const product = await findProduct(2)。findProduct(2) 立刻回傳一個 Promise。接著時間軸變成虛線:await 讓你的函式停在這裡,直到 Promise 完成。暫停結束後,product 就是產品物件,console.log(product.name) 印出 Stapler。沒有 await 時,程式碼是 const forgot = findProduct(2)。findProduct(2) 立刻回傳一個 Promise。沒有暫停:下一行立刻執行。forgot 是 Promise,不是產品,console.log(forgot.name) 印出 undefined。有 awaitconst product = await findProduct(2);findProduct(2) 立刻回傳一個 Promise。await 讓你的函式停在這裡,直到 Promise 完成。product 就是產品物件。console.log(product.name) 會印出 Stapler沒有 awaitconst forgot = findProduct(2);findProduct(2) 立刻回傳一個 Promise。沒有暫停:下一行立刻執行。forgot 是 Promise,不是產品。console.log(forgot.name) 會印出 undefined
請注意那段虛線:有 await 時,你的函式在那裡等待那個值;沒有 await 時,Promise 還沒完成,下一行就執行了。
  • await findProduct(2) 會等待答案。這一行之後,product 就是產品物件,所以 product.name 會印出 Stapler,和預期一樣。
  • await 可以用在模組的最上層(就像這裡),也可以用在標示為 async 的函式裡。用在其他函式裡則是錯誤。所以課程裡使用資料庫的處理函式都寫成 async (req, res) => { ... } 這個樣子。
  • 每個 async 函式都會回傳 Promise,所以呼叫 showName(1) 時前面也加了 await。
  • 最後三行少了 await,所以 forgot 就是 Promise 本身。Node 把它顯示成 Promise { <pending> },意思是還沒完成。Promise 沒有 name 欄位,所以 forgot.name 會得到 undefined,而且不會出現任何錯誤。

最後這種情況就是第 3 週要記住的錯誤。程式不會當掉,也不會印出錯誤。程式碼原本期待拿到產品,卻帶著 Promise 繼續往下走。當資料庫來的值意外變成 undefined,或印出來是 Promise 時,先找找看是不是少了 await。

動手重建 · 把程式行排好

先等到值,再使用它

重建這個主題的 product.js,但不含最後三行。把程式行排好,讓檔案先印出 Stapler,再印出 Notebook。

顯示排好的程式碼
import { findProduct } from './db.js';
const product = await findProduct(2);
console.log(product.name);
const showName = async (id) => {
  const found = await findProduct(id);
  console.log(found.name);
};
await showName(1);

await 會把值交出來,所以用到值的每一行,都排在等待它的那一行之後。檔案最外層如此,showName 裡面也一樣。

重點整理

Promise 代表還沒準備好的結果。await 會暫停 async 函式,直到值送達。忘了 await,你拿到的就是 Promise,不是那個值:它印出來是尚未完成的 Promise,而它的欄位都是 undefined。

檢查 · 先預測再選擇

JavaScript · 使用本主題的 db.js
import { findProduct } from './db.js';

const product = findProduct(1);
console.log(product.price);
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 3 — 沒有 await 時,product 是 Promise,不是產品。價格 3 根本到不了這一行。
  • 出現錯誤,因為產品還沒準備好 — 這裡少了 await 並不會引發錯誤。這個問題難發現的原因就在這裡。
  • undefined — findProduct 回傳的是 Promise。Promise 沒有 price 欄位,所以讀取它會得到 undefined。

程式出錯時:throw、try、catch

錯誤會讓程式停在發生錯誤的那一行。後面的行不會執行。把這段存成 receipt.js 並執行。這筆訂單沒有 customer 欄位。

JavaScript · receipt.js
const order = { pizza: 'cheese', quantity: 2 };

const customerName = (order) => {
  return order.customer.name;
};

console.log('Before');
console.log(customerName(order));
console.log('After');
Shell · 實際輸出,資料夾已改成範例資料夾;你的 Node 版本可能不同
$ node receipt.js
Before
file:///Users/you/si679/js-primer/receipt.js:4
  return order.customer.name;
                        ^

TypeError: Cannot read properties of undefined (reading 'name')
    at customerName (file:///Users/you/si679/js-primer/receipt.js:4:25)
    at file:///Users/you/si679/js-primer/receipt.js:8:13
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.19.0

Python

order = {'pizza': 'cheese', 'quantity': 2}
def customerName(order):
    return order['customer']['name']

print('Before')  # → Before
print(customerName(order))  # → KeyError: 'customer'
print('After')

JavaScript

const order = { pizza: 'cheese', quantity: 2 };
const customerName = (order) => {
  return order.customer.name;
};
console.log('Before'); // → Before
console.log(customerName(order));
// → TypeError: Cannot read properties of undefined (reading 'name')
console.log('After');

Python 會早一步停下,停在缺少的鍵,而且它的 traceback 把最新的呼叫列在最後。Node 則把它列在最前面。

Before 印出來了,而 After 沒有。程式停在兩者之間。請照下面的順序閱讀 Node 印出的內容。

  1. 找出以錯誤種類加上冒號開頭的那一行:TypeError: Cannot read properties of undefined (reading 'name')。它是錯誤本身的第一行,說明了哪裡出錯。這裡是程式碼想讀取 name,但被讀取的那個值其實是 undefined,而它沒有任何欄位。
  2. 看它上面的幾行。它們指出檔案和行號 receipt.js:4,並顯示那一行,在出錯的位置下方標上 ^。
  3. 以 at 開頭的行是堆疊追蹤:一路走到這裡的呼叫鏈,最新的排在最前面。找出最前面幾行提到你自己檔案的。receipt.js:4:25 在 customerName 裡面。下一個是 receipt.js:8:13,也就是呼叫它的那一行。提到 node:internal 的行屬於 Node,可以略過。

order.customer 從來沒有設定過,而值是 undefined 的東西沒有欄位可以讀取。這一則訊息(括號裡換成別的字)就是你在這門課最常看到的錯誤。

throw 讓你自己的程式碼引發錯誤,而 try 搭配 catch 則讓它接住錯誤。

JavaScript · quantity.js
const checkQuantity = (quantity) => {
  if (quantity < 1) {
    throw new Error('Quantity must be at least 1');
  }
  return quantity;
};

try {
  console.log(checkQuantity(2));
  console.log(checkQuantity(0));
  console.log('This line is skipped');
} catch (error) {
  console.log('Caught: ' + error.message);
}

console.log('Still running');
Shell · 輸出
$ node quantity.js
2
Caught: Quantity must be at least 1
Still running
  • throw new Error('...') 會用你的訊息建立一個錯誤,並讓函式當場停下來。
  • 當 try 區塊裡有任何東西拋出錯誤,那個區塊剩下的部分會被跳過,改為執行 catch 區塊。error.message 是錯誤的文字內容。

當 Promise 失敗時,它前面的 await 會在那一行拋出錯誤,所以同樣的 try 與 catch 也能處理出錯的資料庫呼叫。

重點整理

錯誤會讓程式停在發生的地方。先讀寫著錯誤種類和訊息的那一行,再讀第一個提到你檔案的 at 行。throw 引發錯誤;try 和 catch 讓程式繼續執行。

檢查 · 先預測再選擇

JavaScript
try {
  console.log('A');
  throw new Error('stop');
  console.log('B');
} catch (error) {
  console.log('C');
}
console.log('D');
Node 執行這個檔案時會印出什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • A,接著是 B,接著是 C,接著是 D — throw 會立刻停止 try 區塊。它後面的那一行永遠不會執行。
  • A,接著是 C,接著是 D — throw 跳過 try 剩下的部分,catch 執行,然後程式繼續往下。
  • A,接著是 C — 被接住的錯誤不會結束程式。catch 區塊後面的那一行會照常執行。

來源依據 · 一般的 JavaScript 用法,不是課程內容。範例沿用本指南的披薩訂單、書籍與產品。每個範例和頁面上的每段輸出,都在一台 Mac 上用 Node 24 實際執行過。

01

第 1 週 · HTTP、Node、Express、表單與 JSON

請求如何變成回應。

先從雙方的對話理解

連回第 0 節:現在要把菜單對話對應到實際的網頁名稱與程式碼。如果用戶端、API 或請求還不清楚,先回去看披薩故事。

本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的是:請求與回應。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫本章
本章點亮的是兩個箭頭:用戶端送出的請求,以及伺服器傳回的回應。

標出地圖 · 先點名稱,再點空格

替一個請求的各部分命名

把每個名稱放到地圖上它該在的位置。

名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 4 個編號空格。1?瀏覽器、Postman、curl2?3?伺服器4?中介軟體控制器服務資料庫

這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

一個 用戶端 提出需求。 伺服器 執行程式並送回答案。瀏覽器、Postman、curl 和 Supertest 測試都能充當用戶端。 API 規範允許哪些請求,以及回應代表什麼意思。

Node 讓 JavaScript 在瀏覽器以外的環境執行。Express 是一個函式庫,協助 Node 程式接收 HTTP 請求、比對路由、解析資料並送出回應。

一個請求,一個回應用戶端與伺服器交換兩則訊息。第一則:用戶端送出一個 HTTP 請求,內容有方法 POST、路徑 /tracks、標頭 Host 與 Content-Type,以及主體,也就是 JSON 格式的曲目資料。伺服器上的 Express 處理函式接著執行。第二則:伺服器送回一個 HTTP 回應,內容有狀態碼 200 OK、標頭 Content-Type,以及主體,也就是 JSON 格式的新增曲目。用戶端伺服器1HTTP 請求方法POST路徑/tracks標頭Host: localhost:3000Content-Type: application/json主體{"artist":"Radiohead","title":"Creep","year":1992}Express 處理函式2HTTP 回應狀態碼200 OK標頭Content-Type: application/json主體新增的曲目(JSON 格式)
請注意,每則訊息都由幾個有名稱的部分組成,而且一個請求只會得到一個回應。
HTTP 請求
POST /tracks HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{"artist":"Radiohead","title":"Creep","year":1992}

逐行解說

  1. POST /tracks HTTP/1.1起始行。方法 是 POST, 路徑 是 /tracks,HTTP/1.1 則是通訊協定。
  2. Host: localhost:3000一個標頭。它指出請求要送往的主機與連接埠。這裡的 localhost 代表你的電腦,連接埠則決定由哪個正在等待的程式接收。
  3. Content-Type: application/json另一個標頭。標頭用來描述請求;這一個說明主體是 JSON。
  4. {"artist":"Radiohead","title":"Creep","year":1992}空一行之後是主體,裡面帶著送出的曲目。
JavaScript · 為本指南撰寫,以 Express 5.2 實際執行
import express from 'express';
const app = express();
app.use(express.json());

app.post('/tracks', (req, res) => {
  const { artist, title, year } = req.body;
  res.json({ artist, title, year });
});

app.listen(3000);

Python

@app.post('/tracks')
def tracks():
    body = request.get_json()
    artist, title, year = body['artist'], body['title'], body['year']
    return {'artist': artist, 'title': title, 'year': year}  # → 200 {"artist":"Radiohead","title":"Creep","year":1992}

JavaScript

app.use(express.json());
app.post('/tracks', (req, res) => {
  const { artist, title, year } = req.body;
  res.json({ artist, title, year });
});

少了 Content-Type 標頭,Flask 會回應 415,這個路由則回應 500:req.body 仍是 undefined。

URL 包含通訊協定、主機、可省略的連接埠、路徑,以及可省略的查詢字串。

部分範例意義
方法GET, POST讀取或提交資料;方法加上路徑決定要使用的路由。
路徑/tracks用戶端要求存取的端點。
查詢字串?author=Tolkien&year=1937URL 中具名的輸入值,常用於篩選。各組鍵值之間以此符號分隔: &.
標頭Content-Type: application/json中繼資料;描述提交主體的格式。
主體{"title":"Dune"}提交的內容;常見於 POST 與 PATCH。
回應狀態碼201 Created表示發生了什麼事,與畫面上看得到的主體內容是兩回事。

重點整理

HTTP 請求包含方法、路徑、標頭,有時還有主體。回應提供狀態與資料。就像信封的地址與內容,各自有不同用途。

檢查 · 先預測再選擇

在瀏覽器網址列輸入 /submitform。你測試了什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • POST /submitform — 需用表單、Postman、curl 或測試客戶端送出 POST。
  • 該路徑的所有路由 — 路由比對同時使用方法與路徑。
  • GET /submitform — 網址列送出 GET,即使同一路徑有 POST 路由。

知道輸入資料放在哪裡

來源請求範例讀取位置
路徑參數/play/artist/Radiohead/song/Creepreq.params.artist 與 req.params.song
查詢參數/catalog?itemid=222req.query.itemid
表單主體表單使用 name="zipcode"req.body.zipcode,並先解析 URL 編碼資料
JSON 主體POST /tracks 搭配 JSONreq.body.artist,並先解析 JSON
標頭Authorization: Bearer SI679req.headers.authorization

在以下路徑中的冒號: /person/:id 用來定義預留位置。用戶端會使用實際路徑,例如 /person/42;不會真的送出 :id。URL 編碼會將空白表示為 %20,Express 會將它解碼。路由參數是文字;查詢值也必須先檢查,才能當成單一字串或數字使用。

URL 的組成URL http://localhost:3000/play/artist/Radiohead 後面接著查詢字串 title=Creep 與 year=1992,拆成七個編號的部分。1 是通訊協定 http。2 是主機 localhost,也就是你自己的電腦。3 是連接埠 3000,用來指定正在監聽的程式。4 是路徑 /play/artist/Radiohead,對應到路由 /play/artist/:artist。5 是路徑參數 Radiohead,Express 把它放在 req.params.artist。6 是查詢參數 title=Creep,Express 把它放在 req.query.title。7 是查詢參數 year=1992,Express 把它以文字放在 req.query.year。問號是查詢字串的開頭,& 符號用來分隔每一組鍵值。同一個 URL,分成兩行顯示5http://localhost:3000/play/artist/Radiohead1234?title=Creep&year=1992671通訊協定http2主機localhost 就是你自己的電腦3連接埠3000 指定正在監聽的程式4路徑對應到路由 /play/artist/:artist5路徑參數req.params.artist 是 'Radiohead'6查詢參數req.query.title 是 'Creep'7查詢參數req.query.year 是 '1992',是文字? 是查詢字串的開頭。& 用來分隔每一組鍵值。
請注意,Express 把 URL 的兩個部分當成輸入交給你的程式:路徑參數放在 req.params,每個查詢參數放在 req.query,而且一律是文字。

在 HTML 中,表單的 action 決定 URL, method 決定使用 POST 或 GET,而每個輸入欄位的 name 決定主體中的鍵名。輸入欄位的 id 用來連結標籤,並不決定提交時的鍵名。郵遞區號應保留為字串,這樣 "01234" 才能保留開頭的零。

JavaScript · 改編的教學範例
app.use(express.urlencoded({ extended: true })); // HTML form bodies
app.use(express.json());                        // JSON bodies

app.post('/submitform', (req, res) => {
  const { address, city, zipcode } = req.body;
  res.send(`Ship to ${address}, ${city}, ${zipcode}`);
});

逐行解說

  1. app.use(express.urlencoded({ extended: true }));在路由之前對每個請求執行,把 HTML 表單主體解析進 req.body.
  2. app.use(express.json());對 JSON 寫成的主體做同樣的事。
  3. app.post('/submitform', (req, res) => {註冊一條路由:收到對 /submitform 的 POST 請求時,Express 會執行這個函式。
  4. const { address, city, zipcode } = req.body;解構:從主體的三個欄位建立三個名稱,每個名稱都對應一個表單輸入的 name。
  5. res.send(`Ship to ${address}, ${city}, ${zipcode}`);用範本字面值組出回覆並送出,請求就此結束。

Python

@app.post('/submitform')
def submitform():
    body = request.form or request.get_json()
    address, city, zipcode = body['address'], body['city'], body['zipcode']
    return f'Ship to {address}, {city}, {zipcode}'  # → 200 Ship to 1 Elm St, Troy, 48083

JavaScript

app.use(express.urlencoded({ extended: true })); // HTML form bodies
app.use(express.json());                        // JSON bodies

app.post('/submitform', (req, res) => {
  const { address, city, zipcode } = req.body;
  res.send(`Ship to ${address}, ${city}, ${zipcode}`);
});

Flask 不需要解析器那幾行:它等處理函式要用時才解析主體,也就是讀取 request.form 或 request.get_json().

JSON 是具有明確語法的文字格式。 它支援字串、數字、布林值、null、陣列與物件。鍵名與字串值使用雙引號。註解、結尾多餘的逗號,以及 undefined 都不是合法 JSON。 JSON.parse() 將文字轉成值; JSON.stringify() 將值轉成文字。

重點整理

把輸入放對抽屜:路徑 → req.params;查詢 → req.query;解析後的表單/JSON → req.body;標頭 → req.headers。表單 name 決定提交的鍵;使用前需驗證值。

檢查 · 先預測再選擇

GET /browse/books?keywords=coffee:兩項輸入在哪裡?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • category 在 query;keywords 在 params — 這把位置顛倒了;請分開讀取路徑與 ?。
  • category 在 params;keywords 在 query — 路徑區段填入佔位欄位;? 後面的文字是查詢。
  • 兩者都在 JSON 主體 — 這些輸入在 URL 裡,不在提交的 JSON 主體。

由左至右讀懂路由

第一次接觸 JavaScript?JavaScript 章節會先教這段程式碼的每一塊,其中的回呼函式主題也用同樣的方式讀 app.get。

JavaScript · 簡化自第 1 週內容
import express from 'express';
const app = express();
app.use(express.json());

app.get('/catalog', (req, res) => {
  const { itemid } = req.query;
  if (typeof itemid !== 'string' || itemid.trim() === '' ||
      Number.isNaN(Number(itemid))) {
    res.status(400).send('Error: itemid must be a number');
    return;
  }
  res.send(`You asked for item ${itemid}`);
});

app.listen(3000);

逐行解說

  1. import express from 'express';載入 Express 函式庫,讓這個檔案可以使用它。
  2. const app = express();建立應用程式。路由與中介軟體都掛在它上面。
  3. app.use(express.json());在任何路由執行之前,先解析 JSON 請求主體。
  4. app.get('/catalog', (req, res) => {這裡的 app.get 註冊一條路由。相符的請求抵達時,會執行它的回呼函式:名稱 req 保存請求,而 res 用來建立回應。
  5. const { itemid } = req.query;解構替物件屬性取一個區域名稱:這裡是 itemid 這個查詢值。
  6. if (typeof itemid !== 'string' || itemid.trim() === '' ||使用輸入之前先驗證。這個條件延續到下一行:只要 itemid 不是單一字串、是空白,或無法轉成數字,條件就成立。
  7. res.status(400).send('Error: itemid must be a number');先由 res.status(400) 設定狀態碼,再由 send() 送出文字。status() 單獨使用不會結束回應。
  8. return;送出錯誤回應後停止回呼函式,避免送出第二個回應。
  9. res.send(`You asked for item ${itemid}`);只有輸入有效時才會執行到這裡。範本字面值使用反引號與 ${...} 插入值。
  10. app.listen(3000);啟動伺服器,開始在連接埠 3000 等待請求。

動手重建 · 把程式行排好

先驗證再回應的路由

重建第 1 週的 catalog 路由。把程式行排好,讓處理函式讀取輸入、檢查輸入,輸入不對就回應 400 並停止,輸入正確才回應那個項目。

顯示排好的程式碼
app.get('/catalog', (req, res) => {
  const { itemid } = req.query;
  if (typeof itemid !== 'string' || itemid.trim() === '' ||
      Number.isNaN(Number(itemid))) {
    res.status(400).send('Error: itemid must be a number');
    return;
  }
  res.send(`You asked for item ${itemid}`);
});

先讀取、再檢查、最後回應:400 和它的 return 排在成功回應之前,所以不正確的 itemid 只會得到一個回應,也不會走到使用它的程式碼。

Python

from flask import Flask, request
app = Flask(__name__)
# no express.json() twin: request.get_json() parses on demand
@app.get('/catalog')
def catalog():
    itemid = request.args.get('itemid')
    return f'You asked for item {itemid}'  # → 200 You asked for item 222
app.run(port=3000)  # → * Running on http://127.0.0.1:3000

JavaScript

import express from 'express';
const app = express();
app.use(express.json());
app.get('/catalog', (req, res) => {
  const { itemid } = req.query;
  res.send(`You asked for item ${itemid}`);
});
app.listen(3000);

回應有三種寫法:res.send() 傳送文字或 HTML,而 res.json() 把值序列化成 JSON,最後 res.sendStatus(404) 則設定狀態碼並送出對應的文字標籤。

重點整理

路由把方法與路徑連到處理函式。req 讀取訊息,res 建立回覆。先驗證;送出錯誤後 return,避免回覆兩次。status() 本身不會完成回應。

檢查 · 先預測再選擇

處理函式只呼叫 res.status(400)。為何客戶端可能一直等?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • req 會自動送出回應 — req 保存輸入;res 是回應介面。
  • 只設定狀態,還沒送出回應 — 需適當呼叫 send/json/end;只設定狀態並不完整。
  • 400 一定表示伺服器必須重啟 — 400 描述無效請求,不是重啟指令。

一張表掌握第 1 週所有活動

其中的 week1hello 的跟做範例監聽於 1679。第 1 週的 Now You Try 監聽於 3000。概念相似不代表路徑可以互換。

week1hello 的跟做範例理解重點
GET /, GET /about簡單的文字回應與路由註冊。
GET /contact → POST /submit顯示名字與姓氏表單,再從主體解構取出 fname 和 lname。
GET /people?letter=a使用陣列,找出第一個以指定字母開頭的名字,忽略大小寫。缺少或空白的 letter 需要防護;這個起始範例示範概念,沒有完整驗證。
GET /league/:leaguename/team/:teamname在同一路由中使用兩個具名路徑參數。
GET /person/:id轉換並檢查數值輸入;以 400 拒絕無法使用的輸入。
POST /postData從已解析的 JSON 讀取 firstName 與 lastName。
第 1 週 Now You Try 路由輸入與結果
GET /catalog?itemid=222驗證查詢值是非空白的數字文字;回傳描述所要求商品的句子,或回傳 400。
GET /play/artist/:artist/song/:song將解碼後的兩個路徑值插入描述正在聽音樂的句子。
GET /getform回傳地址、城市與郵遞區號表單,提交目標為 POST /submitform。
POST /submitform使用表單欄位描述寄送目的地。
GET /browse/:category?keywords=coffee結合一個路徑參數與一個查詢輸入。
GET /hithere?name=Ada使用查詢值回傳問候語。
POST /tracks要求 artist 與 title 都是非空白字串;回傳 status、trackAdded 與 ISO 時間戳記。本機成功狀態為 200。
GET /products/:department/:category?keywords=lcd,smart,sony使用 split(",") 再接 join(" AND "),產生 lcd AND smart AND sony。
預測:GET /catalog 沒有 itemid 時會如何?為什麼?

400。值不是字串,因此防護條件會在型別轉換或成功回應之前停止處理函式。

重點整理

第 1 週組合文字路由、路徑/查詢輸入、表單與 JSON。需遵守各專案的方法、路徑、連接埠及回應約定;熟悉名稱不代表知道行為。

檢查 · 先預測再選擇

GET /catalog 沒有 itemid。保存的第 1 週檢查應怎樣處理?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 把缺少的值轉成商品零 — 缺少輸入未通過檢查,不是刻意提供的商品編號。
  • 一律回覆 404 — 這是無效輸入,不是完成查詢後發現資源不存在。
  • 轉換前回覆 400 — 檢查先拒絕缺少的非空字串,再考慮 Number() 與成功回覆。

環境設定與手動用戶端練習

Shell · 新練習專案,現有儲存庫不需要這些步驟
mkdir week1hello
cd week1hello
npm init -y
npm install express
npm pkg set type="module"
# Create server.js and a start script: node --watch server.js
npm start

逐行解說

  1. mkdir week1hello替專案建立一個新資料夾。
  2. cd week1hello讓終端機進入那個資料夾,接下來的指令就會作用在它上面。
  3. npm init -y建立 package.json,用來描述專案與指令碼。
  4. npm install express把 Express 加為相依套件。相依套件放在 node_modules 底下。
  5. npm pkg set type="module"把 "type":"module" 寫進 package.json,選用 ES 模組的 import 語法。
  6. # Create server.js and a start script: node --watch server.js這是寫給你看的說明,不是指令。start 指令碼使用 Node 的監看模式,儲存程式碼就會重新啟動程序;記憶體中的陣列也會在重啟時消失。
  7. npm start執行 start 指令碼。

在 Postman 建立 SI 679 集合與各週資料夾,設定方法與完整 URL,再送出 GET /。測試 POST 時,選擇 Body → raw → JSON,輸入資料,並檢查狀態碼與主體。要先新增路由,才能期待請求成功。講義使用連接埠 3000,你儲存的 week1hello 則使用 1679。

測試 /people 時,試試 letter=c、f、3,再試不帶 letter 或錯寫為 ltr。這能看出「找不到相符的人」和「無法安全地取用輸入的索引」之間的差異。筆記中的 /people/33 範例與其 /person/:id 路由不一致;測試實際註冊的路由時請用 /person/33。第 1 週商品路由在本機使用 department/category 作為預留位置名稱;無論名稱如何,公開路徑範例都是 electronics/tvs 與 household/kitchen。

重點整理

安裝套件只是準備專案;執行啟動腳本才會啟動。客戶端需使用正確連接埠與方法。監看模式重啟會清除程式內陣列,像擦掉暫存便條。

檢查 · 先預測再選擇

已安裝 Express,卻沒有程式監聽請求。缺少什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 每個第 1 週路由都要先匯入 MongoDB 資料 — 這些入門路由並非都需要資料庫。
  • 執行呼叫 listen() 的伺服器程式 — 安裝函式庫不會自動建立正在運作的監聽程式。
  • 把每個請求改成 POST — 改變方法無法啟動接收請求的程式。

來源依據 · 第 1 週課程頁面、兩個本機 Express 活動,以及第 1 週讀書計畫。

02

TypeScript 基礎 · 圖書目錄練習

型別描述你的假設。

TypeScript 改變了什麼

TypeScript 在 JavaScript 上加入編譯時期檢查。它能在程式執行前協助找出欄位拼字錯誤、引數型別錯誤,或可能缺少的值。編譯器會產生 JavaScript,交由 Node 執行。型別註記在執行時期會消失;它們不會驗證用戶端傳來的請求。

本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章在伺服器下方加上 TypeScript 註記:型別在你撰寫程式時檢查程式碼。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫TypeScript 型別在你撰寫程式時檢查本章
本章加上 TypeScript 註記:型別在你撰寫時檢查伺服器的程式碼,程式執行時型別已經不在了。

標出地圖 · 先點名稱,再點空格

哪些會執行,哪些不會

把每個名稱放到地圖上它該在的位置。

名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。1?瀏覽器、Postman、curl2?3?伺服器4?中介軟體控制器服務5?

這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

Python

def add_order(quantity: int) -> dict:
    return {'quantity': quantity}

print(add_order('-2'))  # → {'quantity': '-2'}

TypeScript

interface Order { quantity: number }
function addOrder(quantity: number): Order {
  return { quantity };
}
addOrder('-2');
// → tsc: error TS2345: Argument of type 'string'
//   is not assignable to parameter of type 'number'.

Python 的型別提示可寫可不寫,執行時會被忽略,所以 Python 那邊的呼叫照樣執行。下圖畫出 TypeScript 這一邊:型別註記由 tsc 檢查,然後被抹除,所以到了執行時期,兩種語言都還是得驗證傳進來的資料。

練習從能正常運作的 src/index.js 圖書目錄開始,將它轉成 src/index.ts ,並保留原本輸出。六個編號任務依序是介面、具型別的陣列、列舉、函式簽章、聯集型別別名與泛型。六項都要理解。

TypeScript
enum BookStatus {
  Available = "available",
  CheckedOut = "checked-out",
  Lost = "lost"
}

interface Book {
  title: string;
  author: string;
  year: number;
  status: BookStatus;
}

const catalog: Book[] = [];

function findBookByTitle(catalog: Book[], title: string): Book | undefined {
  return catalog.find(book => book.title === title);
}

逐行解說

  1. enum BookStatus {enum 替允許的狀態值取名字。這一行和下面三行,列出一本書可以有的所有狀態。
  2. interface Book {開頭的 interface Book 描述物件有哪些欄位。它不會建立任何東西,只是用來檢查值。
  3. year: number;一個欄位:先寫名稱,再寫冒號,最後是型別。
  4. status: BookStatus;欄位可以使用你自己宣告的型別。這裡只接受那三個 enum 值。
  5. const catalog: Book[] = [];型別 Book[] 是書籍陣列,一開始是空的。關鍵字 const 防止變數被重新指定,但你仍然可以把項目 push 進陣列,或修改物件的欄位。
  6. function findBookByTitle(catalog: Book[], title: string): Book | undefined {函式的簽章。它描述兩個輸入,並在最後一個冒號之後描述結果:可能是 Book,也可能是 undefined。
  7. return catalog.find(book => book.title === title);回傳第一本書名相符的書。沒有相符的書時,find 會給 undefined,所以簽章才允許兩種結果。
型別在執行時已經消失一個 TypeScript 檔案宣告 addOrder,參數 quantity 標成 number。tsc 編譯器把它轉成 JavaScript 檔案,quantity 不再有型別。Node 執行這份 JavaScript。一個請求送來,quantity 是字串 "-2"。執行中的程式沒有任何東西檢查它,所以程式碼仍然需要執行階段驗證,以 400 拒絕它。order.tsfunction addOrder(quantity: number型別註記) { … }tsc編譯order.jsfunction addOrder(quantity型別不見了) { … }交給 Node 執行執行中的 JavaScript沒有型別可以檢查請求{"quantity":"-2"}仍然需要驗證執行階段驗證是字串,不是數字:回覆 400
請注意:請求抵達之前,型別註記就已經消失,所以只有執行階段的檢查能拒絕 "-2"。

重點整理

TypeScript 在執行前檢查程式;Node 執行產出的 JavaScript。型別像藍圖,不是門口的保全;傳入請求仍需執行時驗證。

檢查 · 先預測再選擇

Product 註記 price 是 number,但 Postman 送來 "cheap"。什麼能保護執行中的 API?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 檢查收到值的執行時程式 — 註記會消失;需在執行時檢查型別、有限值及業務規則。
  • 只有型別註記 — 編譯時宣告不會檢查網路資料。
  • const 讓所有物件欄位都有效 — const 防止重新指定變數,不防止錯誤欄位或物件修改。

使用聯集型別前先縮小範圍

TypeScript
type BookFilter = string | number;

function findBooksBy(catalog: Book[], filter: BookFilter): Book[] {
  if (typeof filter === "number") {
    return catalog.filter(book => book.year === filter);
  }
  return catalog.filter(book => book.author === filter);
}

const maybeBook = findBookByTitle(catalog, "Dune");
if (maybeBook !== undefined) {
  console.log(maybeBook.title);
}
console.log(maybeBook?.status);

逐行解說

  1. type BookFilter = string | number;型別 string | number 是聯集型別:值可能是其中一種。type 這個字替這個聯集取了名字。
  2. function findBooksBy(catalog: Book[], filter: BookFilter): Book[] {filter 可能是文字,也可能是數字,所以程式使用它之前要先弄清楚是哪一種。
  3. if (typeof filter === "number") {用 typeof 檢查會縮小聯集的範圍。在這個分支裡,TypeScript 知道 filter 是數字。
  4. return catalog.filter(book => book.year === filter);所以可以拿它和數字型別的 year 比較。
  5. return catalog.filter(book => book.author === filter);只有上面的檢查不成立時才會到這裡。這時 filter 一定是字串,所以拿它和 author 比較。
  6. const maybeBook = findBookByTitle(catalog, "Dune");結果是 Book 或 undefined,正如那個函式的簽章所說。
  7. if (maybeBook !== undefined) {再縮小一次範圍。在大括號裡,這個值是 Book,所以可以讀取它的 title。
  8. console.log(maybeBook?.status);這裡的 ?. 是可選串連:若值是 null 或 undefined,就停止存取並產生 undefined。它能避免程式崩潰,卻不保證值一定存在。

Python

BookFilter = str | int

def findBooksBy(catalog: list[Book], filter: BookFilter) -> list[Book]:
    if isinstance(filter, int):
        return [book for book in catalog if book.year == filter]
    return [book for book in catalog if book.author == filter]

print([book.title for book in findBooksBy(catalog, 1965)])  # → ['Dune']

TypeScript

type BookFilter = string | number;

function findBooksBy(catalog: Book[], filter: BookFilter): Book[] {
  if (typeof filter === "number") {
    return catalog.filter(book => book.year === filter);
  }
  return catalog.filter(book => book.author === filter);
}

找不到結果的 .find() 會回傳 undefined。Mongo 的 findOne() 找不到結果時會回傳 null。兩者都代表不存在,但它們是不同的值;宣告程式實際回傳的那一個。

重點整理

Union 表示幾種可能型別。先檢查、縮小範圍,再使用特定型別操作。Optional chaining 可避免存取 null/undefined,卻不會補出資料。

檢查 · 先預測再選擇

value 是 string | number。何時能安全呼叫 value.toUpperCase()?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 在 typeof value === "string" 分支內 — 檢查把值縮小到具有此方法的型別。
  • 只要 Union 包含 string — 縮小範圍前,值仍可能是數字。
  • 用了 Optional chaining 後,不管型別都可以 — Optional chaining 檢查缺值,不證明值是字串。

用泛型保留型別

TypeScript
function getFirstItem<Item>(items: Item[]): Item | undefined {
  return items[0];
}

const firstNumber = getFirstItem([1, 2, 3]); // number | undefined
const firstBook = getFirstItem(catalog);   // Book | undefined

逐行解說

  1. function getFirstItem<Item>(items: Item[]): Item | undefined {名稱 Item 是型別參數,每次呼叫時指定或推論出來。輸入陣列的元素型別,會成為輸出型別。
  2. return items[0];第一個元素。空陣列沒有第一個元素,這就是可能得到 undefined 的原因。
  3. const firstNumber = getFirstItem([1, 2, 3]);TypeScript 從引數推論出 Item 是 number,所以結果是 number 或 undefined。
  4. const firstBook = getFirstItem(catalog);同一個函式,這次推論出 Item 是 Book。

Python

from typing import TypeVar
Item = TypeVar('Item')

def getFirstItem(items: list[Item]) -> Item | None:
    return items[0] if items else None  # [][0] raises IndexError

firstNumber = getFirstItem([1, 2, 3])  # int | None
print(firstNumber, getFirstItem([]))  # → 1 None

TypeScript

function getFirstItem<Item>(items: Item[]): Item | undefined {
  return items[0];
}

const firstNumber = getFirstItem([1, 2, 3]); // number | undefined

如果函式改用 any,TypeScript 就不再保護輸入與輸出之間的關聯。

型別/語法可以理解為用途
void沒有可使用的回傳值第 2 週服務中的 addBook。
booleantrue 或 falsecheckoutBook 與 returnBook 回報是否成功。
unknown使用前必須先檢查的值檢查 HW1 中不可信任的配料項目。
any停用此值的型別檢查寬鬆的輸入型別仍可在執行時期檢查;能使用精確型別時,應優先使用。
field?: string欄位可能不存在訂單的選填客製內容或更新欄位。
Partial<Product>Product 的每個欄位都變成選填第 3 週更新與第 4 週提供的欄位。
Omit<Book, "id">Book 除了 id 以外的所有欄位描述可編輯的書籍資料。
Partial<Omit<Book, "id">>不包含識別欄位的選填變更第 2 週 updateBook 的簽章。
Promise<Product | null>未來取得的商品,或沒有結果第 4 週非同步 get 服務。

重點整理

Generic 保留型別關係:firstItem with Book 回傳 Book 或 undefined。它不像 any 會失去檢查。Partial 讓欄位可省略;Promise 描述稍後才取得的結果。

檢查 · 先預測再選擇

Generic firstItem 收到空的 Book 陣列。哪個回傳型別適合?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 一定是 string — Generic 保留輸入輸出的型別關係,不是固定字串型別。
  • Book | undefined — 元素型別仍是 Book,但空陣列沒有第一項。
  • 一定是 Book — 這個宣告忽略了空陣列情況。

每週都用得到的 JavaScript 工具

這張表以 JavaScript 章節為基礎,那一章會先教值、物件、陣列、函式和回呼函式。

操作回傳內容/執行行為範例意義
find(predicate)第一個相符項目,或 undefined依 id 找到一項菜單品項。
filter(predicate)包含所有相符項目的新陣列保留某位作者的書;保留其他 id 的書,就能移除指定書籍。
map(callback)新陣列,每個項目對應一個轉換結果文件 → Product,或 Product → 製造商名稱。
forEach(callback)對每個項目執行操作;回傳 undefined收集所有驗證問題。
some(predicate)布林值:至少有一個相符項目某個尺寸選項是否存在?
every(predicate)布林值:所有元素都通過篩選後的所有項目是否都屬於指定類別?
includes(value)布林值:是否包含指定項目是否提供某種餅皮?
Object.assign(book, changes)將欄位複製到現有物件第 2 週的部分更新會直接修改找到的書籍物件。
{ ...fields, id }將可列舉欄位複製到新物件;後面的鍵值覆蓋前面的用插入後的 id 取代候選 id。
const { id, ...changes } = body將 id 與其餘屬性分開第 2 週 PATCH 主體。
value ?? fallback只有 null 或 undefined 才使用備用值保留價格 0,與依真假值判斷的預設方式不同。
JavaScript · 為本指南撰寫,以 Node 24 實際執行
const books = [
  { title: 'The Hobbit', author: 'Tolkien', year: 1937 },
  { title: 'Dune', author: 'Herbert', year: 1965 },
];
const hobbit = books.find(book => book.year === 1937);
const herbert = books.filter(book => book.author === 'Herbert');
const asText = books.some(book => book.year === '1937');
console.log(hobbit.title, herbert.length, asText);

Python

books = [{'title': 'The Hobbit', 'author': 'Tolkien', 'year': 1937},
         {'title': 'Dune', 'author': 'Herbert', 'year': 1965}]
hobbit = next(book for book in books if book['year'] == 1937)
herbert = [book for book in books if book['author'] == 'Herbert']
asText = any(book['year'] == '1937' for book in books)
print(hobbit['title'], len(herbert), asText)  # → The Hobbit 1 False

JavaScript

const hobbit = books.find(book => book.year === 1937);
const herbert = books.filter(book => book.author === 'Herbert');
const asText = books.some(book => book.year === '1937');
console.log(hobbit.title, herbert.length, asText); // → The Hobbit 1 false

找不到符合的項目時,next() 會拋出 StopIteration,而 find 會回傳 undefined;要避免例外,就傳入預設值,寫成 next(..., None).

async 一定回傳 Promise; await 會暫停該非同步函式,直到 Promise 成功或失敗。它不會讓整個 Node 伺服器停止運作。

這些專案使用 ES 模組/NodeNext 設定,本機 TypeScript 匯入路徑通常以 .js結尾,因為 Node 載入的是編譯後的 JavaScript。 import type 匯入的宣告會在執行時期消失;一般 import 則會匯入可執行的程式碼。HW1 使用課程提供的不含副檔名匯入設定,請遵循儲存庫現有設定。

重點整理

find 找一項;filter 保留符合項目;map 轉換每一項。?? 只在 null/undefined 時使用預設值,因此保留零。await 等待非同步函式內的工作,不會停止整個伺服器。

檢查 · 先預測再選擇

免費商品的 price 是 0。哪個運算式能保留它?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • price || 10 — 零是 falsy,因此 || 會把它換成 10。
  • price.map(10) — map 用於轉換陣列;數字價格不是陣列。
  • price ?? 10 — 零是存在的值;?? 只對 null 或 undefined 使用預設值。

說明圖書目錄的行為

addBook 將 Book 加入陣列。 findBookByTitle 找到一本書,或回傳 undefined。 checkoutBook 只有書存在且狀態為 available 時才成功;第二次借閱會回傳 false。 returnBook 將已存在的書恢復為 available。篩選接受作者字串或出版年份數字。泛型函式安全地回傳第一個項目。

示範資料有 The Hobbit、Dune、Foundation 與 Children of Time。Foundation 一開始是 lost。Dune 第一次借閱為 true,第二次為 false;歸還後,狀態為 available。轉成 TypeScript 不應改變這些執行結果,或 README 中的預期輸出。

為什麼 TypeScript 的 Product 型別註記無法拒絕 Postman 傳來的 {"price":"cheap"}?

型別註記在執行的程式中會被移除。收到的資料必須透過 typeof、Number.isFinite 與 Array.isArray 等執行時期檢查來驗證。

重點整理

把書籍目錄轉成 TypeScript 應保留原本行為。書可借時第一次借閱成功;歸還前再次借閱失敗。型別描述狀態,不會取代狀態。

檢查 · 先預測再選擇

Dune 已成功借出,未歸還就再次借閱。第二次結果是什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • TypeScript 自動歸還書 — 編譯器不會執行應用程式的歸還操作。
  • false — 書已不在可借狀態,規則拒絕再次借閱。
  • true,因為書仍存在 — 存在與可借是不同條件。

來源依據 · TypeScript README、src/index.ts、其讀書計畫,以及第 2–4 週與 HW1 的型別使用方式。

2+

TypeScript 基礎之後 · 十二種寫法、刪掉型別的訣竅,以及讀懂紅色錯誤

讀懂每一行。

唯一的訣竅:把型別刪掉

TypeScript 就是 JavaScript 加上寫給型別檢查器看的註記。一行程式碼看起來像一堵符號牆時,就在腦中把這些註記刪掉。剩下的就是 Node 真正執行的 JavaScript,大多數行其實都很短。型別描述你的假設說明了原因:程式執行前,這些註記就被抹掉了(型別被抹除的圖).

刪掉這些:

  • : string,或任何接在名稱、參數或函式的 () 後面、冒號之後的型別。
  • <Product> 以及任何接在名稱後面、角括號裡的東西,例如 Promise<Product>。角括號也一起刪掉。
  • 整行刪掉:開頭是 import type 或 type 的行,例如 type Role = 'customer' | 'staff';。它們從來不會執行。

有兩個例外,人人都會踩到:

  1. 物件字面值裡的冒號要留著。 在 { id: user.id } 裡,冒號是把欄位和它的值配對。這是 JavaScript,不是型別。
  2. as const 要刪掉。 它看起來像程式碼,其實只是告訴型別檢查器這個值不會變。import * as name 不一樣:它是真正的 JavaScript 改名,所以要留著。

這是第 6 週一個產生中介軟體的函式,刪掉前與刪掉後:

TypeScript · 第 6 週 · src/middleware/require-role.ts:10–11
export const requireRole = (role: Role): Middleware => {
  return (req: Request, res: Response, next: NextFunction): void => {
JavaScript · 刪掉型別後
export const requireRole = (role) => {
  return (req, res, next) => {

再看一行,裡面每個冒號都要留著,因為它們都在物件字面值裡:

TypeScript · 第 6 週 · src/services/auth-service.ts:41
  return { id: user.id, username: user.username, role: user.role };

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

重點整理

讀 TypeScript 時,先刪掉它的註記:冒號後的型別、角括號、import type 與 type 開頭的行,還有 as const。剩下的就是會執行的 JavaScript。物件字面值裡的冒號是在配對欄位與值,所以要留著。

檢查 · 先預測再選擇

把第 6 週 return { id: user.id, username: user.username, role: user.role }; 的型別刪掉。執行的是什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • return { id, username, role }; — 這樣把值都丟掉了。這裡的冒號是在配對欄位與值,不是型別。
  • 同一行,完全不變 — 每個冒號都在物件字面值裡,所以這一行沒有任何型別註記。
  • return { user.id, user.username, user.role }; — 這不是合法的 JavaScript。物件字面值的每個值前面都要有欄位名稱。

十二種寫法

課堂程式碼一再重複使用同樣的十幾種寫法。這裡依照它們在課程 .ts 檔案中出現的頻率排序,次數是大約值。每一種都附上一行真實程式碼與它的檔案、怎麼把它讀出來,以及刪掉型別後的樣子。

1. 箭頭函式 · 約 380 次

TypeScript · 第 6 週 · src/config.ts:6
export const getJwtSecret = (): string => {

讀出來:「建立一個名為 getJwtSecret 的函式。它不接收任何輸入,回傳一個字串。」

  • const name = (…) => 的意思是「這個名稱存的是一個函式」。
  • : string 緊接在 () 後面,是回傳型別:出來的是什麼。
JavaScript · 刪掉型別後
export const getJwtSecret = () => {

2. 解構 · 約 168 次

TypeScript · 第 2 週 · src/books-router.ts:53
  const { title, author, year } = req.query;

讀出來:「把大括號裡列出的欄位從 req.query 取出來,各自放進同名的變數。」它是下面這種寫法的簡寫:const title = req.query.title; 每個欄位寫一次。

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

3. 範本字面值 · 約 93 次

TypeScript · 第 6 週 · src/middleware/require-role.ts:13
      throw new AuthorizationError(`Only ${role} may do this`);

讀出來:「組出一個字串。寫著 ${role} 的地方,放進 role 的值。」如果 role 是 staff,訊息就是 Only staff may do this。開啟這個功能的是反引號,不是引號。

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

4. 泛型與聯集型別 · 約 63 次

TypeScript · 第 6 週 · src/db/products-repository.ts:29
export const findProduct = async (id: string): Promise<Product | null> => {

讀出來:findProduct 接收一個字串型別的 id。它回傳一個 Promise,之後裝的會是一個 Product ,或是找不到時的 null。

  • <…> 讀作「的」。Promise<Product> 是「裝著產品的 Promise」,Partial<Product> 是「所有欄位都可省略的產品」。
  • | 讀作「或」。
JavaScript · 刪掉型別後
export const findProduct = async (id) => {

5. import type · 約 47 次

TypeScript · 第 6 週 · src/middleware/require-jwt.ts:1
import type { NextFunction, Request, Response } from 'express';

讀出來:「從 Express 匯入三個型別名稱。它們只用在型別註記裡,例如 req: Request,從不當成值使用。」課堂的 tsconfig 開啟了 verbatimModuleSyntax,所以只匯入型別時,一定要用 type 這個字標明。

刪掉型別後: 整行刪掉。它從來不會執行。

6. ?? 運算子 · 約 44 次

TypeScript · 第 6 週 · src/services/products-service.ts:52
  const quantity = changes.quantity ?? existing.quantity;

讀出來:「使用送來的數量。如果它不存在,也就是 undefined 或 null,就保留現有的數量。」它和 || 在零的處理上不同,而零是一個真實的數量:0 ?? 5 得到 0,而 0 || 5 得到 5。

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

7. as,有兩種用途 · 約 35 次

TypeScript · 第 3 週 · src/__tests__/products-changes.test.ts:73
  it.each(['get', 'patch', 'delete'] as const)('%s answers 400', async (method) => {

讀出來:as const 告訴 TypeScript,這個清單就是這三個字,不會有別的。正因如此,下一行的 request(app)[method] 才能通過型別檢查。課堂程式碼裡大多數的 as 做的是另一件事:匯入時改名。

TypeScript · 第 6 週 · src/routes/products-router.ts:2
import * as productsController from '../controllers/products-controller.js';

讀出來:「把那個檔案匯出的所有東西,當成一個名為 productsController 的物件匯入。」之後 productsController.getProducts 就是其中一個匯出項目。

JavaScript · 刪掉型別後
  it.each(['get', 'patch', 'delete'])('%s answers 400', async (method) => {

其中的 as const ;import * as 那一行則完全保留。

8. .map、.filter 與 .find · 約 34 次

TypeScript · 第 6 週 · src/services/products-service.ts:34
  return products.filter((product) => product.quantity < threshold);

讀出來:「逐一走過 products,只留下數量低於門檻的項目,並回傳這個較短的清單。」

  • .filter(test) 保留通過測試的項目。
  • .map(change) 把每個項目都變成新的東西。第 6 週的資料存取層寫的是 docs.map((doc) => toProduct(doc)),於是每份 MongoDB 文件都變成一個 Product。
  • .find(test) 回傳第一個符合的項目,什麼都沒找到時回傳 undefined,第 2 週的書籍服務就是這樣用的。

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

9. 可選串連 · 約 9 次

TypeScript · 第 6 週 · src/controllers/auth-controller.ts:10
    req.body?.username,

讀出來:「如果有主體,就讀出它的 username。如果根本沒有主體,就給 undefined,而不是讓程式當掉。」少了 ?.,當 req.body 是 undefined 時,讀取 req.body.username 就會拋出錯誤。

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

10. 展開與其餘 · 約 9 次

TypeScript · 第 4 週 · src/services/product-service.ts:19–21
  const { id, ...fields } = productFromFields(productInfo);
  const { insertedId } = await db.addToCollection(db.PRODUCTS, fields);
  return productFromFields({ ...fields, id: insertedId.toString() });

讀出來:第一行是「其餘」:「把 id 取出來,其他所有欄位都收進 fields。」最後一行是「展開」:「建立一個新物件,把 fields 的每個欄位都複製進去,再加上 id。」同樣是三個點,方向相反:在等號左邊是收集,在右邊的大括號裡是複製出去。

刪掉型別後: 這幾行完全不變,它們本來就是純 JavaScript。

11. class … extends Error · 5 次,但很重要

TypeScript · 第 6 週 · src/errors.ts:7
export class AuthenticationError extends Error {}

讀出來:「建立一種新的錯誤,名為 AuthenticationError。它是普通的 Error,只是有自己的名字。」空的大括號沒問題:重點就是這個名字,這樣錯誤處理器才能問 err instanceof AuthenticationError 並用 401 狀態碼.

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

12. async、await、try 與 catch

TypeScript · 第 6 週 · src/middleware/require-jwt.ts:17–21
  try {
    res.locals.user = jwt.verify(token, getJwtSecret());
  } catch {
    throw new AuthenticationError('Invalid or expired token');
  }

讀出來:「試著驗證 token。如果驗證時拋出任何錯誤,就接住它,改丟出我們自己的 401 錯誤。」

TypeScript · 第 6 週 · src/services/auth-service.ts:32
  const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);

讀出來:「開始雜湊(這很慢),等它完成,再把結果放進 hashedPassword。」await 只能用在標了 async 的函式裡,否則就會出現 錯誤 TS1308,見下方。等待慢的工作 說明了底下的 Promise。

刪掉型別後: 這幾行完全不變,它們本來就是純 JavaScript。

重點整理

十二種寫法涵蓋了大部分的程式碼:箭頭函式、解構、範本字面值、泛型、import type、??、as、清單方法、可選串連、展開與其餘、錯誤類別,以及 async 搭配 try 與 catch。每一種都先讀出來,再刪掉它的型別。

檢查 · 先預測再選擇

第 6 週執行 const quantity = changes.quantity ?? existing.quantity;,其中 changes.quantity 是 0,existing.quantity 是 12。quantity 是多少?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 12,因為 0 算是不存在 — 那是 || 的行為。?? 只把 undefined 和 null 當成不存在。
  • undefined — 兩邊都不是 undefined,而且 ?? 一定回傳兩邊的其中一個。
  • 0,因為 ?? 只取代 undefined 或 null — 零是真實的值,所以保留左邊。

還會遇到的五種寫法

A. 控制器的結構

TypeScript · 第 6 週 · src/controllers/products-controller.ts:4–7
export const getProducts = async (
  req: Request,
  res: Response
): Promise<void> => {

一段一段讀:

部分意義
export其他檔案可以匯入它
const getProducts =名稱
async裡面可以用 await,而且它回傳 Promise
(req: Request, res: Response)兩個輸入:進來的請求與要送出的回應
: Promise<void>一個什麼都不裝的 Promise:它的工作是送出回應,不是回傳值
=> {函式本體從這裡開始
JavaScript · 刪掉型別後
export const getProducts = async (req, res) => {

B. 物件字面值與 res.status().json()

TypeScript · 第 6 週 · src/services/auth-service.ts:41
  return { id: user.id, username: user.username, role: user.role };
TypeScript · 第 6 週 · src/controllers/products-controller.ts:21
    res.status(404).json({ error: 'No product with that id' });

讀出來:第一行建立一個有三個欄位的新物件,hashedPassword 就是這樣被刻意排除在外的。第二行:「把狀態設成 404,然後把這個物件當成 JSON 送出。」res.status(404) 回傳的就是 res 本身,所以 .json 可以接在它後面,寫在同一行。

刪掉型別後: 這幾行完全不變,它們本來就是純 JavaScript。

C. 帶 .js 的 import 與 export

TypeScript · 第 6 週 · src/app.ts:2
import { productsRouter } from './routes/products-router.js';

讀出來:「從我的檔案 routes/products-router,匯入它以 productsRouter 這個名字匯出的東西。」大括號裡的名字必須對應到那個檔案裡的 export const productsRouter。為什麼檔案是 .ts,卻要寫 .js?第 2 到 6 週使用 Node 的 ES 模組,設定是 "module": "nodenext"。Node 執行的是編譯後的 .js 檔,所以 import 寫的是編譯後才存在的檔名。漏掉它就會出現 錯誤 TS2835.

刪掉型別後: 這一行完全不變,它本來就是純 JavaScript。

D. describe、it 與 expect

TypeScript · 第 6 週 · src/__tests__/auth.e2e.test.ts:40–51
describe('GET /auth/me', () => {
  // #region valid-token
  it('200s with the username for a token we sign ourselves', async () => {
    const token = jwt.sign(payload, getJwtSecret());

    const res = await request(app)
      .get('/auth/me')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    expect(res.body.username).toBe('ada');
  });
  • describe('GET /auth/me', …) 是一組關於同一件事的測試。
  • it('200s with …', async () => {…}) 是一個測試。它的文字就是它要檢查的承諾。
  • expect(x).toBe(y) 的意思是「x 必須正好是 y,否則這個測試失敗」。.toEqual 則改為比較物件和清單的內容;依你的意思選擇比對器 有完整清單。

E. Supertest:request(app)

TypeScript · 第 6 週 · src/__tests__/auth.e2e.test.ts:45–47
    const res = await request(app)
      .get('/auth/me')
      .set('Authorization', `Bearer ${token}`);

讀出來:「對我的 app 的 /auth/me 送出一個模擬的 GET 請求,帶上這個 Authorization 標頭,然後等回應。」不需要真的伺服器或連接埠。res.status 與 res.body 就是回應,接下來幾行再用 expect 檢查它們。

重點整理

控制器是一個匯出的 async 函式,接收 req 與 res,回傳什麼都不裝的 Promise。res.status() 回傳 res,所以後面可以接 .json()。本地匯入要以 .js 結尾。測試用 describe 分組,每個 it 陳述一個承諾,並在 Supertest 請求後用 expect 檢查。

檢查 · 先預測再選擇

在第 6 週的 res.status(404).json({ error: 'No product with that id' }); 裡,為什麼 .json 可以直接接在 .status(404) 後面?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • status 回傳 res 本身,所以下一個呼叫還是作用在同一個回應上 — 鏈結裡的每個呼叫,都作用在前一個呼叫回傳的東西上,而 status 回傳的就是回應。
  • Express 會等函式結束,再執行所有呼叫 — Express 執行到哪個呼叫就跑哪個。鏈結就是普通的 JavaScript。
  • json 是數字 404 的方法 — 數字沒有 json 方法。這個呼叫作用在 status 回傳的東西上,也就是回應。

讀懂紅色的 TypeScript 錯誤

下面每個錯誤都是真的。錯誤寫法是打進課堂專案的副本裡,再執行 npx tsc --noEmit,輸出原封不動貼在這裡。之後副本就還原了。每個 tsc 錯誤都有相同的幾個部分:

終端機 · tsc 輸出
src/app.ts(3,28): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './routes/auth-router.js'?
  • src/app.ts 是檔案,(3,28) 是第 3 行第 28 欄。
  • TS2835 是錯誤代碼:一個你可以拿去搜尋的名字。
  • 其餘的是訊息。
  1. 先到那個檔案和那一行。還不要讀完整個訊息。
  2. 讀訊息。它通常在說「X 不是 Y」。在那一行找到 X。
  3. 先修第一個錯誤,再執行一次 tsc。一個錯誤常常引起底下好幾個錯誤。

有四個詞會一再出現。型別是指某個值屬於哪一種。assignable 意思是「放得進去」。參數是函式替它的輸入取的名字,例如 (id: string) => 裡的 id。引數是呼叫函式時實際傳進去的值。

1. TS2345 · 引數的型別無法指派給參數

TypeScript · 第 6 週 · src/controllers/users-controller.ts:6 · 錯誤寫法
  const user = await usersService.getUser(caller, req.params.id);
終端機 · tsc 輸出
src/controllers/users-controller.ts(6,51): error TS2345: Argument of type 'string | string[]' is not assignable to parameter of type 'string'.
  Type 'string[]' is not assignable to type 'string'.

意思是: Express 把 URL 參數的型別定為「可能是字串清單」,而 getUser 只接受一個字串。

修正: 先把它轉成一個字串,課堂程式碼每個地方都是這樣寫的:

TypeScript · 第 6 週 · src/controllers/users-controller.ts:6
  const user = await usersService.getUser(caller, String(req.params.id));

2. TS2322 · 型別無法指派給另一個型別

TypeScript · 第 6 週 · src/services/auth-service.ts:35–39 · 錯誤寫法
  const user = await usersRepository.insertUser({
    username,
    hashedPassword,
    role: 'admin'
  });
終端機 · tsc 輸出
src/services/auth-service.ts(38,5): error TS2322: Type '"admin"' is not assignable to type 'Role'.

意思是: Role 只允許兩個字,所以 admin 放不進標示為 Role 的盒子:

TypeScript · 第 6 週 · src/models/user.ts:1
export type Role = 'customer' | 'staff';

修正: 使用允許的字,就像真正的程式碼寫的 role: 'customer';如果你真的要一個新角色,就把 admin 加進 Role。

3. TS2339 · 型別上不存在這個屬性

TypeScript · 第 6 週 · src/services/auth-service.ts:55 · 錯誤寫法
  const matches = await bcrypt.compare(password, user.password);
終端機 · tsc 輸出
src/services/auth-service.ts(55,55): error TS2339: Property 'password' does not exist on type 'User'.

意思是: User 沒有叫 password 的欄位,它有的是 hashedPassword:

TypeScript · 第 6 週 · src/models/user.ts:4–9
export type User = {
  id: string;
  username: string;
  hashedPassword: string;
  role: Role;
};

修正: 使用真正的欄位名稱 user.hashedPassword。這就是 TypeScript 幫你抓到打錯字。

4. TS18048 · 可能是 undefined

TypeScript · 第 2 週 · src/books-router.ts:20 · 錯誤寫法
  if (authorization.split(' ')[1] === secretKey) {
終端機 · tsc 輸出
src/books-router.ts(20,7): error TS18048: 'authorization' is possibly 'undefined'.

意思是: 請求可能根本沒帶 Authorization 標頭,所以 req.headers.authorization 是字串或 undefined,而你不能對「什麼都沒有」呼叫 .split。

修正: 先檢查,就像第 2 週真正的程式碼那樣:

TypeScript · 第 2 週 · src/books-router.ts:20
  if (authorization && authorization.split(' ')[1] === secretKey) {

你也可能看到 TS2532「Object is possibly undefined」。意思相同,修法也相同。

5. TS2304 · 找不到這個名稱

TypeScript · 第 6 週 · src/middleware/error-handler.ts:2–6 · 錯誤寫法
import {
  AuthenticationError,
  AuthorizationError,
  ValidationError
} from '../errors.js';
終端機 · tsc 輸出
src/middleware/error-handler.ts(26,22): error TS2304: Cannot find name 'ConflictError'.

意思是: 第 26 行還寫著 err instanceof ConflictError,但這個檔案從沒建立過這個名稱,也不再匯入它。

修正: 把 ConflictError 加回匯入清單。

6. TS7006 · 參數隱含 any 型別

TypeScript · 第 6 週 · src/db/users-repository.ts:8 · 錯誤寫法
const toUser = (doc): User => {
終端機 · tsc 輸出
src/db/users-repository.ts(8,17): error TS7006: Parameter 'doc' implicitly has an 'any' type.

意思是: TypeScript 看不出 doc 是什麼,而課堂的 tsconfig 設了 "strict": true,不允許默默猜測。

修正: 寫出型別:

TypeScript · 第 6 週 · src/db/users-repository.ts:8
const toUser = (doc: Document): User => {

像 docs.map((doc) => toProduct(doc)) 這樣的回呼函式不需要替 doc 寫型別,因為 TypeScript 已經知道 docs 裡裝的是什麼。

7. TS2835 與 TS2307 · 匯入路徑問題

TypeScript · 第 6 週 · src/app.ts:3 · 錯誤寫法
import { authRouter } from './routes/auth-router';
終端機 · tsc 輸出
src/app.ts(3,28): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './routes/auth-router.js'?
TypeScript · 第 6 週 · src/app.ts:3 · 錯誤寫法
import { authRouter } from './routes/auth-routes.js';
終端機 · tsc 輸出
src/app.ts(3,28): error TS2307: Cannot find module './routes/auth-routes.js' or its corresponding type declarations.

意思是: 第一個漏了 .js;當專案使用 "type": "module" 搭配 nodenext 時,每個本地匯入都需要它。第二個寫了一個不存在的檔案:真正的檔案是 auth-router.ts。

修正: 以 .js 結尾,並對照 src/routes 裡的檔案檢查拼字:

TypeScript · 第 6 週 · src/app.ts:3
import { authRouter } from './routes/auth-router.js';

8. TS2741 · 型別裡缺少這個屬性

TypeScript · 第 6 週 · src/db/users-repository.ts:37–41 · 錯誤寫法
  return {
    id: result.insertedId.toString(),
    username: user.username,
    hashedPassword: user.hashedPassword
  };
終端機 · tsc 輸出
src/db/users-repository.ts(37,3): error TS2741: Property 'role' is missing in type '{ id: string; username: string; hashedPassword: string; }' but required in type 'User'.

意思是: 這個函式承諾回傳 User,而每個 User 都必須有 role。

修正: 補上缺少的欄位:

TypeScript · 第 6 週 · src/db/users-repository.ts:37–42
  return {
    id: result.insertedId.toString(),
    username: user.username,
    hashedPassword: user.hashedPassword,
    role: user.role
  };

9. TS1308 · 在 async 函式外使用 await

TypeScript · 第 6 週 · src/controllers/auth-controller.ts:18–22 · 錯誤寫法
export const postLogin = (
  req: Request,
  res: Response
): void => {
  const token = await authService.logIn(
終端機 · tsc 輸出
src/controllers/auth-controller.ts(22,17): error TS1308: 'await' expressions are only allowed within async functions and at the top levels of modules.

意思是: await 只能用在標了 async 的函式裡,而 async 被拿掉了。

修正: 把 async 加回去,函式就會回傳 Promise:

TypeScript · 第 6 週 · src/controllers/auth-controller.ts:18–21
export const postLogin = async (
  req: Request,
  res: Response
): Promise<void> => {

重點整理

tsc 錯誤的格式是「檔案(行,欄)」、代碼、訊息。先到那一行,找出「X 不是 Y」裡的 X,並先修第一個錯誤,再看其他的,因為一個錯誤常會引起好幾個錯誤。

檢查 · 先預測再選擇

tsc 印出 src/services/auth-service.ts(55,55): error TS2339: Property 'password' does not exist on type 'User'.。你先看哪裡?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • TypeScript 文件裡 TS2339 的頁面 — 代碼之後值得搜尋,但先告訴你錯在哪裡的是那一行。
  • src/services/auth-service.ts 的第 55 行,找 password 這個字 — 先看檔案和行號,而訊息裡的 X 就是 password。
  • 每個用到 User 型別的檔案 — 回報的只有第 55 行。User 型別本身沒有問題。

讀懂 Vitest 的失敗訊息

錯誤寫法:在第 2 週專案的副本裡,把 GET /books/:id 路由找不到書時的回應從 404 改成 400。接著執行 npx vitest run src/__tests__/books.test.ts。

TypeScript · 第 2 週 · src/books-router.ts:73–80 · 錯誤寫法
booksRouter.get('/:id', (req: Request, res: Response) => {
  const book = getBook(Number(req.params.id));
  if (!book) {
    res.sendStatus(400);
    return;
  }
  res.json(book);
});

抓到它的是這個測試:

TypeScript · 第 2 週 · src/__tests__/books.test.ts:62–72
describe('GET /books/:id', () => {
  it('returns the matching book and 404 for a missing id', async () => {
    const posted = await addBook();
    const found = await request(app).get(`/books/${posted.body.id}`);
    const missing = await request(app).get('/books/1');

    expect(found.status).toBe(200);
    expect(found.body.title).toBe('The Hobbit');
    expect(missing.status).toBe(404);
  });
});

真實輸出,有刪減:去掉了執行標頭與路由器的記錄行。

終端機 · Vitest 輸出
 ❯ src/__tests__/books.test.ts (9 tests | 1 failed) 64ms
   ❯ GET /books/:id (1)
     × returns the matching book and 404 for a missing id 7ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/__tests__/books.test.ts > GET /books/:id > returns the matching book and 404 for a missing id
AssertionError: expected 400 to be 404 // Object.is equality

- Expected
+ Received

- 404
+ 400

 ❯ src/__tests__/books.test.ts:70:28
     68|     expect(found.status).toBe(200);
     69|     expect(found.body.title).toBe('The Hobbit');
     70|     expect(missing.status).toBe(404);
       |                            ^
     71|   });
     72| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 8 passed (9)
  • (9 tests | 1 failed):八個測試仍然通過。只有一個測試和這個路由有關。
  • FAIL … > GET /books/:id > returns the matching book and 404 for a missing id:路徑依序是檔案、describe 名稱、it 名稱。它告訴你哪個承諾被打破了。
  • expected 400 to be 404:伺服器送了 400,測試要的是 404。把它讀成「預期(實際得到的)要是(我們想要的)」。
  • - Expected 與 + Received:減號那行是測試想要的,加號那行是實際發生的。
  • books.test.ts:70:28 和插入符號:失敗的那一個 expect。第 68、69 行通過了,所以找到的那本書沒問題。

把它說出來: 「測試要一本不存在的書。我的路由回了 400,但請求本身沒問題,是書不存在,這是 404。我會把 GET /:id 路由裡的 res.sendStatus(400) 改回 res.sendStatus(404)。」

重點整理

Vitest 的失敗訊息會列出檔案、describe 與 it,接著說「預期(實際得到的)要是(我們想要的)」。減號行是測試想要的,加號行是實際發生的,插入符號指向失敗的那個 expect。

檢查 · 先預測再選擇

Vitest 印出 AssertionError: expected 201 to be 200。伺服器實際送出的是什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 200 — 200 是測試想要的,它在句子的後半。
  • 什麼都沒送;測試在請求前就當掉了 — 當掉的話會印出被拋出的錯誤,而不是預期值與實際值的配對。
  • 201 — expected 後面先出現的是實際得到的值,所以伺服器送了 201。

空白檔案練習

讀懂一行只是一半。要確認你也寫得出來,就在課堂專案的副本裡,憑記憶重寫一個檔案:

  1. 複製整個專案,這樣你真正的專案永遠不會被動到。
  2. 把你要清空的檔案先存一份到專案外面。
  3. 清空它,但保留 import 那幾行。它們告訴你這個檔案用到什麼。
  4. 憑記憶寫回其餘部分,不要偷看。
  5. 執行 tsc,再執行測試。先修第一個錯誤。
  6. 把你的版本和原本的檔案做 diff,讀過每一處差異。
  7. 把原本的檔案複製回去,或直接刪掉副本。
終端機 · 練習指令
cp -R si-679-f-26-week06-nyt-yourname ~/drill-week06
cd ~/drill-week06
cp src/services/products-service.ts ../products-service.original.ts
# open src/services/products-service.ts, keep the import lines, delete the rest, rewrite it
npx tsc --noEmit
npm test
diff ../products-service.original.ts src/services/products-service.ts
cp ../products-service.original.ts src/services/products-service.ts

在第 6 週專案的副本裡,整套 45 個測試大約兩秒就跑完,所以檢查的成本很低。一輪要 30 分鐘:

  • 第 0 到 2 分鐘:複製專案並清空檔案。
  • 第 2 到 22 分鐘:重寫。
  • 第 22 到 25 分鐘:執行 tsc 與測試,直到兩者都通過。
  • 第 25 到 30 分鐘:做 diff,記下你漏掉的兩行,然後還原。

下一輪再清空同一個檔案,直到 diff 幾乎沒有差異,再換下一個。先從短的檔案開始,例如 src/errors.ts 與 src/config.ts,再換服務、控制器、路由器和測試。如果某一行和原本不同,但 tsc 和測試都通過,那是另一個正確答案,不是錯誤。

重點整理

複製一個課堂專案,清空一個檔案但保留它的 import,憑記憶重寫,執行 tsc 與測試,和原本的檔案做 diff,最後還原。一輪三十分鐘,同一個檔案重複練到 diff 幾乎沒有差異。

檢查 · 先預測再選擇

你在副本裡清空 src/services/products-service.ts。要保留什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 只保留 import 那幾行 — import 顯示這個檔案用到什麼,而後面的一切就是你要練習寫的。
  • 什麼都不留,連 import 也不留 — 沒有 import,你會花力氣回想套件路徑,而不是這個練習要練的程式碼。
  • 每個函式名稱,函式本體留空 — 這樣檔案的結構就被洩漏了,練習能檢查的東西少很多。

來源依據 · 第 2、3、4、6 週的課堂練習程式碼,引自這些專案已固定的快照。每個錯誤與那個測試失敗,都是把錯誤寫法打進課堂專案的副本,再在一台 Mac 上用 Node 24 執行 tsc 7.0 或 Vitest 5.0 得到的。

03

第 2 週 · 路由器、中介軟體、服務與第一批測試

執行順序也是行為的一部分。

整理路由,同時保留 API 行為

連回第 0 節:這章打開廚房的門。看看點餐單如何被分派、檢查,再交給真正完成工作的程式。

本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的是:中介軟體。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫本章
本章點亮的是中介軟體這一站:在路由比對成功之後、處理函式之前執行的程式。

標出地圖 · 先點名稱,再點空格

中介軟體在哪裡

把每個名稱放到地圖上它該在的位置。

名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。用戶端瀏覽器、Postman、curl1?回應伺服器2?3?4?5?資料庫

這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

TypeScript
// app.ts
app.use('/books', booksRouter);

// books-router.ts
const booksRouter = express.Router();
booksRouter.get('/:id', handler);
// A client sends GET /books/123, not GET /:id.

逐行解說

  1. app.use('/books', booksRouter);在 app.ts 裡,應用程式提供掛載前綴。路徑以 /books 開頭的請求,都會交給這個路由器。
  2. const booksRouter = express.Router();在 books-router.ts 裡。Router 把相關的處理函式收在一起。
  3. booksRouter.get('/:id', handler);路由器提供相對路徑。掛載在 /books 並定義 /:id,就得到 /books/:id。如果在路由器裡再定義一次 /books,前綴就會重複。
  4. // A client sends GET /books/123, not GET /:id.用戶端送出的永遠是帶有實際 id 的完整路徑。

Python

# app.py
app.register_blueprint(booksRouter, url_prefix='/books')

# books_router.py
booksRouter = Blueprint('books', __name__)
@booksRouter.get('/<id>')
def handler(id): return f'Book {id}'  # → 200 Book 123

TypeScript

// app.ts
app.use('/books', booksRouter);

// books-router.ts
const booksRouter = express.Router();
booksRouter.get('/:id', handler);

第 2 週將程式分為 types.ts (Book 的資料結構)、 books-service.ts (私有的記憶體內目錄與操作)、 books-router.ts (HTTP 處理函式)、 app.ts (組裝應用程式),以及 index.ts (於 6790 監聽)。服務匯出 addBook、getAllBooks、getBook、updateBook、removeBook 與 _resetCatalog。HTTP 程式碼向服務取得資料,而不是自行管理陣列。

重點整理

掛載 Router 時,前綴會加到相對路由。/books + /:id 成為 /books/:id。像大樓地址加房號;重複大樓地址會導向錯誤位置。

檢查 · 先預測再選擇

Router 掛載在 /books,內部定義 GET /books/:id。結果是哪個 URL?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • /books/:id — 需讓 Router 定義 /:id,不要再加一次 /books。
  • /:id — 掛載前綴不會被丟棄。
  • /books/books/:id — App 前綴與 Router 路徑相接,前綴因此重複。

中介軟體要嘛繼續,要嘛回應

JavaScript 風格概略 · 第 2 週順序
const logger = (req, res, next) => {
  console.log(req.method, req.path);
  next();
};

booksRouter.use(express.json());
booksRouter.use(logger);
booksRouter.post('/', checkAuth, validateBookParams, handler);

逐行解說

  1. const logger = (req, res, next) => {一般中介軟體接受 (req, res, next):請求、回應,以及一個把控制權往下交的函式。
  2. console.log(req.method, req.path);這個中介軟體自己的工作:印出方法與路徑。
  3. next();呼叫 next() 會把控制權交給下一個適用的處理函式。送出回應會結束請求。兩者都不做,請求就會一直等待。
  4. booksRouter.use(express.json());路由器層級的中介軟體,會套用到這個路由器所有相符的請求。這一個負責解析 JSON 主體,而且最先註冊,所以最先執行。
  5. booksRouter.use(logger);同樣是路由器層級;它第二個註冊,所以在解析器之後執行。
  6. booksRouter.post('/', checkAuth, validateBookParams, handler);路由專屬的中介軟體只套用到它自己的路由。這個 POST 會先執行 checkAuth,再執行 validateBookParams,最後才是處理函式。前一個呼叫了 next(),才會輪到下一個。

動手重建 · 把程式行排好

先註冊中介軟體,再註冊路由

重建第 2 週的概略:先是 logger 中介軟體,再來是在路由器上註冊解析器、logger 和 POST 路由的幾行。把這些程式行排好。

顯示排好的程式碼
const logger = (req, res, next) => {
  console.log(req.method, req.path);
  next();
};
booksRouter.use(express.json());
booksRouter.use(logger);
booksRouter.post('/', checkAuth, validateBookParams, handler);

中介軟體依註冊順序執行,所以解析器和 logger 排在路由之前。這兩者在這裡互不相依,誰先誰後都可以。

Python

@booksRouter.before_request
def logger():
    print(request.method, request.path)  # → GET /books/123

JavaScript

const logger = (req, res, next) => {
  console.log(req.method, req.path);
  next();
};

booksRouter.use(logger);

Flask 在 before_request 函式回傳 None 時就繼續往下;Express 只有一種方式繼續:呼叫 next().

中介軟體依註冊順序執行一個 POST /books 請求依註冊順序經過六個步驟。每個步驟要嘛呼叫 next() 繼續,要嘛送出回應。1,express.json() 解析 JSON 主體。2,logger 記錄請求。3,checkAuth 檢查 token。4,validateBookParams 檢查 title 與 author。5,handler 也就是路由,建立書籍並回應 201 與新書籍。步驟 3 有一條分支:token 錯誤時,checkAuth 自己回應 403,所以步驟 4 和 5 不會執行。步驟 1 有一條虛線:格式錯誤的 JSON 讓 express.json() 呼叫 next(err),跳過中間所有步驟,直接到步驟 6,也就是最後註冊的錯誤處理器。它對所有錯誤都回應 500。請求:POST /books1express.json()解析 JSON 主體next()2logger記錄請求next()3checkAuth檢查 tokennext()4validateBookParams檢查 title、authornext()5handler建立書籍403token 錯誤步驟 4 和 5不會執行201新書籍格式錯誤的 JSONnext(err)跳過中間所有步驟6錯誤處理器最後註冊500所有錯誤
請注意,token 錯誤時 checkAuth 自己回應 403,所以它下面的兩個步驟不會執行。

錯誤走的是另一條路:next(err) 會把控制權往後交給錯誤處理中介軟體。

第 2 週的記錄器會記錄方法、路徑與主機名稱。 checkAuth 將 Authorization 以空白分隔後的第二個值與 SI679 比較,失敗時回傳 403 。它比 HW1 要求的精確 Bearer 解析簡單。 validateBookParams 要求 title 與 author 都是非空白字串,否則回傳 400。有效 token 加上錯誤欄位會進入資料驗證;身分驗證失敗加上錯誤欄位則會停在身分驗證。

重點整理

Middleware 用 next() 繼續、送出回覆,或用 next(err) 轉交錯誤。順序很重要:解析 → 驗證身分 → 驗證資料 → 處理。JSON 格式錯誤可能先於身分檢查失敗。

檢查 · 先預測再選擇

第 2 週請求的 JSON 有效,但身分與欄位都錯。哪項檢查先停止請求?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 欄位驗證:400 — 身分檢查已回覆,後面的驗證不會執行。
  • 建立:201 — 身分遭拒必須阻止建立資料。
  • 身分檢查:403 — 保存的第 2 週流程先檢查身分,再驗證欄位。

第 2 週的確切 API 契約

路由存取保護與輸入本機程式的行為
POST /books身分驗證加上 title/author 驗證;主體包含 title、author、year201,回傳新書籍。id 使用 Date.now();預設狀態為 available。
GET /books公開;可選用 title、author、year 查詢參數200,回傳陣列;提供的篩選條件皆採精確比對,並同時套用。Year 由 URL 文字轉成數字。
GET /books/:id公開;將路徑 id 轉成數字200,回傳書籍;不存在時為 404。
PATCH /books需身分驗證;JSON 主體包含 id 與至少一個要修改的欄位id 無法使用或沒有變更時為 400,不存在時為 404,更新成功為 200。其他欄位保留。
DELETE /books需身分驗證;JSON 主體包含 id移除後回傳 200;此實作在沒有相符書籍時也回傳 200。
GET /books/badroute公開的教學路由拋出錯誤,用來練習最後的 500 處理函式。註冊順序在 /:id 之前。

請注意,這裡的 PATCH 與 DELETE 使用 /books 與主體中的 id。後面的週次使用 /products/:id。要學的是當下正在研讀的實際端點。 Date.now() 是課堂用的 id 產生方式;同一毫秒內的兩次呼叫可能撞號,因此無法提供一般性的唯一性保證。

目錄使用 let ,因為重設與移除會替換整個陣列。移除時使用 filter 來保留所有其他 id。更新時找到書籍,再使用 Object.assign 套用提供的屬性。編譯時期的 Omit 本身不會從不可信任的執行時期物件移除禁止的欄位。

重點整理

讀取第 2 週的確切約定:POST 建立,GET 列出或查詢,PATCH 更新提供的欄位,DELETE 刪除。此處 PATCH/DELETE 在 /books 使用主體 id,與後來的 /products/:id 不同。

檢查 · 先預測再選擇

保存的第 2 週 PATCH /books,id 應放在哪裡?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 在 JSON 主體中,與變更資料一起 — 需遵守此練習的主體 id 約定。
  • 只能放在 /books/:id 路徑 — 那是另一種 API 設計,不是保存的第 2 週端點。
  • 只放在回應中 — 處理函式執行更新前就需要目標身分。

分開啟動程式;錯誤處理放最後

簡化的教學概略
// app.ts: define and export the app
app.use('/books', booksRouter);
app.use((err, req, res, next) => {
  res.status(500).send('An unexpected error occurred');
});
export { app };

// index.ts: import app, then start the real listener
app.listen(6790);

逐行解說

  1. app.use('/books', booksRouter);先註冊路由。
  2. app.use((err, req, res, next) => {Express 透過四個參數辨識錯誤處理中介軟體: (err, req, res, next)。即使未使用,也要保留第四個參數。將它註冊在路由之後,Express 向後尋找時,錯誤才能到達它。
  3. res.status(500).send('An unexpected error occurred');對收到的每個錯誤,一律回應 500 與一則簡短訊息。
  4. export { app };把應用程式提供給其他檔案使用。這個檔案不會開始監聽。
  5. app.listen(6790);在 index.ts 裡。只有這個檔案會啟動真正的監聽,連接埠是 6790。

動手重建 · 把程式行排好

組裝 app:解析器、路由,錯誤處理放最後

這是第 4 週筆記裡的 app.ts,順序和上面的概略相同。把程式行排好,讓 app 先建立,再解析 JSON 主體、掛載商品路由,最後處理錯誤。

顯示排好的程式碼
import express from 'express';
import { productRouter } from './routes/product-routes.js';
import { errorHandler } from './middleware/error-handler.js';
const app = express();
app.use(express.json());
app.use('/products', productRouter);
app.use(errorHandler);
export { app };

Express 依註冊順序執行中介軟體:解析器排在讀取 req.body 的路由之前,錯誤處理器排在它要接住錯誤的那些路由之後。

Python

# app.py: define the app
app.register_blueprint(booksRouter, url_prefix='/books')
@app.errorhandler(500)
def handleError(err):
    return 'An unexpected error occurred', 500  # → 500 An unexpected error occurred

TypeScript

// app.ts: define and export the app
app.use('/books', booksRouter);
app.use((err, req, res, next) => {
  res.status(500).send('An unexpected error occurred');
});

Flask 依狀態碼找錯誤處理函式,所以註冊位置不影響結果。

404 處理函式是一般的中介軟體,接住沒有任何路由處理的請求;錯誤處理函式則接收沿著鏈傳下來的錯誤。

測試匯入 app.ts,因此不會啟動固定連接埠的監聽器。Supertest 會開一個暫時的監聽器,不必執行 npm run dev。測試從不執行 index.ts,所以要親手確認一次啟動流程。

重點整理

app.ts 組裝 Express App;index.ts 啟動它。404 後備處理未匹配請求。四個參數的錯誤 Middleware 放最後,處理轉交的例外。分離啟動程式方便測試匯入 App。

檢查 · 先預測再選擇

錯誤處理器放在會拋出例外的路由之前。問題是什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 必須用三個參數的處理器 — Express 依四個參數辨認錯誤 Middleware。
  • Express 向前走,後面的錯誤無法到達該處理器 — 應把錯誤 Middleware 放在需捕捉的路由後面。
  • 所有例外都會變成正常 404 — 未匹配請求與轉交錯誤使用不同處理器。

第 2 週測試清單

測試涵蓋空目錄、精確作者篩選、拒絕缺少或錯誤 token、空白書名驗證、201 建立、依 id GET/404、PATCH 的身分與資料驗證及未變動欄位,以及 DELETE 身分驗證與最終空清單。 beforeEach 會呼叫 _resetCatalog() ,避免前一個測試的書籍影響下一個測試。

使用 res.body 讀取解析後的 JSON,並使用 res.text 讀取純文字。狀態碼與回應內容都要檢查;光看到看似合理的書籍,不能證明端點回傳了正確狀態碼。

POST /books 沒有 token,而且 title 空白,會發生什麼事?

第 2 週回傳 403。只要提交文字是合法 JSON,身分驗證中介軟體會在 title/author 驗證器執行前回應。

重點整理

每次測試前重設目錄。依完成的約定測試權限、無效欄位、篩選、更新與刪除。單獨成功的測試,可能因前一測試改變共用狀態而失敗。

檢查 · 先預測再選擇

篩選測試單獨成功,但在建立測試之後失敗。應先檢查什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 移除篩選斷言 — 削弱斷言會掩蓋問題,無法修復隔離。
  • 永遠用剛好能成功的順序執行 — 測試應從受控狀態開始,不應依賴順序。
  • beforeEach 的目錄重設 — 殘留狀態改變了測試資料;每次測試都需重設。

TypeScript 設定、標頭與三個 NYT 練習區塊

JSONC · 設定示意
// Important tsconfig.json settings from the lecture:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"],
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

將 Express 安裝為執行時期相依套件;TypeScript、tsx、@types/node 與 @types/express 提供開發工具與型別宣告。Vitest、Supertest 與 @types/supertest 是測試相依套件。tsx 能在開發時執行 TypeScript,但不能取代 tsc --noEmit。透過 .gitignore,讓產生的 dist 與安裝的 node_modules 不進入 Git。

請求起始行包含方法/路徑/通訊協定,回應起始行包含通訊協定/狀態碼。兩者都有標頭、空白行,以及在適用時出現的主體。HTTP 標頭名稱不區分大小寫;Node 會將收到的鍵名轉成小寫。在 Postman 加入 My-Header-Field: Woot! ,並檢查 req.headers['my-header-field']。比較瀏覽器與 Postman 的標頭;不同用戶端會提供不同中繼資料。

講義中間階段的 POST 與測試先使用 200,之後 NYT 明確要求正確的建立狀態 201。你完成的第 2 週程式與測試使用 201。講義與本機錯誤處理函式為教學顯示堆疊追蹤;HW1 則送出一般性的 500,並在伺服器記錄詳細資訊。

課程任務表:第 2 週 Now You Try 練習區塊
第 2 週練習區塊所有要求的任務
NYT #1 · 篩選實作 title、author 與 year 的精確篩選;結合時使用 AND。建立對應的 Postman 請求。
NYT #2 · 路由行為將 POST 改為 201;新增 GET /books/:id,不存在時回傳 404;新增需身分驗證的 PATCH /books,要求 id 與至少一個變更欄位,成功為 200,資料無效為 400。
NYT #3 · 測試POST 缺少或錯誤的身分驗證 → 403;身分驗證有效但 title 空白 → 400,且文字包含 non-blank;先建立兩位作者的資料,再確認單一作者的精確結果;DELETE 缺少身分驗證 → 403,通過身分驗證並刪除後 → 空目錄。

加入後續中介軟體與測試時,保留先前的篩選功能。依完成後的 API 契約測試路由行為,不要依講義中間階段的回應。即使每個測試單獨執行都通過,BeforeEach 重設仍不可少。

重點整理

TypeScript 設定負責編譯;HTTP 設定選擇實際方法、連接埠與標頭。保留第 2 週三組活動:篩選、受保護路由行為與測試。課堂中途回應不是最終約定。

檢查 · 先預測再選擇

第 2 週課堂先用 POST 成功 200,完成練習要求建立狀態。測試應期待什麼?

先解釋,再比較推理

查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

  • 204,並附上新書主體 — 204 表示沒有回應主體,不是此處選擇的建立回應。
  • 201 — 完成後保存的實作遵守建立資源的約定。
  • 永遠用 200,因為最先出現 — 中途範例會隨練習完成而改變。

送出一個請求

組出一個 HTTP 請求,看第 2 週的 books API 一步一步處理它。沒有任何資料離開這個頁面:應用程式、它的書籍和每一個回應,都是在你的瀏覽器裡模擬的。

這個練習場需要 JavaScript。沒有 JavaScript 時,這裡有一個手動走過一遍的請求。

HTTP 請求 · 查詢字串只要一位作者
GET /books?author=Frank%20Herbert HTTP/1.1
Host: localhost:6790
  1. 路徑以 /books 開頭,所以請求進入 books 路由器。
  2. express.json() 沒有主體可以解析,繼續往下。
  3. logger 印出方法與路徑,然後繼續往下。
  4. GET / 路由只留下作者剛好是 Frank Herbert 的書,並回應 200。
HTTP 回應
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 83

[{"id":2,"title":"Dune","author":"Frank Herbert","year":1965,"status":"available"}]

引導任務

依序往下做。你送出的請求達成任務時,該任務就會打勾。這些勾與練習進度裡的檢查分開計算。

    標頭

    Host 與 Content-Length 會自動加上,就像真正的用戶端一樣。

    留空就不會送出主體。在 JSON 裡,鍵和文字都要用雙引號。

    按「送出」,就能看到請求、每一個步驟和回應。

    5 · 目前儲存的書籍

      書籍只存在這個頁面上。重新載入頁面或按「重設書籍」,就會回到一開始的三本。

      課程沒有說明的地方,這個練習場自己決定的事
      • 書籍目錄一開始有三本書,這樣才有東西可以讀取。課程裡的目錄一開始是空的。
      • 書籍的 status 顯示為文字 available,也就是 TypeScript 那一章給這個 enum 成員的值。
      • 403、找不到書時的 404、PATCH 的 400,以及 DELETE 的 200,都用 res.sendStatus() 送出,所以主體就是狀態名稱。
      • PATCH 成功時會回應更新後的書。PATCH 只接受數字作為 id。
      • express.json() 讓 req.body 保持 undefined 時,處理函式會把它當成沒有任何欄位的物件。
      • 同一個篩選條件在查詢字串裡出現兩次時,會變成陣列,而且不符合任何一本書。
      • 錯誤處理函式用的是本章的簡短版本:每個被轉交的錯誤都變成 500,文字相同,也沒有堆疊追蹤。主體解析失敗也包含在內。
      • 回應只顯示 Content-Type 與 Content-Length。真正的 Express 還會加上其他幾個標頭,例如 Date 與 ETag。
      • 路徑的比對方式與 Express 5 的預設相同:/Books 也會進入路由器,結尾多一個斜線也可以。

      來源依據 · 第 2 週課程頁面、books-router.ts、books-service.ts、app/index、測試與讀書計畫。

      04

      作業 1 · 菜單、角色檢查、訂單驗證與錯誤

      把各部分組合起來:SliceDrop。

      四個端點;兩種角色

      HW1 是使用記憶體保存菜單與訂單的 API,連接埠為 3000。它結合第 1 週輸入處理與第 2 週路由器、中介軟體和測試,沒有 MongoDB 持久儲存。菜單資料由作業提供;儲存的訂單會取得伺服器產生的 id、status 與 timestamp。

      本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的是:伺服器裡的四站全部。資料庫沒有標示,因為 SliceDrop 把訂單放在記憶體裡。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫本章
      本章點亮整條伺服器流程,因為 SliceDrop 把路由、中介軟體、控制器與服務組在一起,而且訂單放在記憶體裡,不放在資料庫。

      標出地圖 · 先點名稱,再點空格

      把 SliceDrop 放上地圖

      把每個名稱放到地圖上它該在的位置。

      名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。用戶端瀏覽器、Postman、curl1?回應伺服器2?3?4?5?資料庫

      這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

      端點誰可以呼叫預期回應
      GET /menu公開200;回傳全部六個品項,或依 category 精確篩選。
      GET /menu/:id公開200,回傳一個品項;未知 id 回傳 404 與錯誤。
      POST /orders顧客 token201,回傳建立的訂單;無效訂單回傳 400 與 errors。
      GET /orders員工 token200,回傳所有訂單,或依 status 精確篩選。

      菜單 id 為 hawaiian, meat-lovers, build-your-own, bread-nugz, dipping-sauce,以及 soda. ?category=pizza 會回傳三種披薩。未知 category/status 篩選仍是合法的集合查詢,只是沒有相符結果:200 與 []。未知的單一菜單 id 則為 404。

      SliceDrop 的 POST /ordersSliceDrop 的 POST /orders,一步一步來。app.ts 裡的 express.json() 解析主體。routers/orders.ts 裡的 ordersRouter 比對路徑。middleware/auth.ts 裡的 requireCustomerToken 在沒有權杖或權杖錯誤時回覆 401。POST 處理函式呼叫 validateOrder,有錯誤就回覆 400;否則 data/orders.ts 裡的 addOrder 把訂單存進記憶體,回覆 201。回覆 401 或 400POST /ordersexpress.json()app.ts解析 JSON 主體ordersRouterrouters/orders.ts比對 POST /ordersrequireCustomerTokenmiddleware/auth.ts沒有權杖或權杖錯誤:401POST 處理函式routers/orders.ts執行下面兩步validateOrdervalidation/validate-order.ts找到錯誤:處理函式回覆 400addOrderdata/orders.ts把訂單放進記憶體201,附上建立好的訂單
      請注意:權杖檢查在驗證之前執行,所以沒有權杖的請求即使主體無效,也會得到 401。
      HTTP · 課堂 token 取自提供的常數
      POST /orders
      Authorization: Bearer slicedrop-customer-secret
      Content-Type: application/json
      
      {
        "customerName": "Ada",
        "items": [{ "menuItemId": "soda", "quantity": 1 }]
      }

      逐行解說

      1. POST /orders方法與路徑:建立一筆訂單。
      2. Authorization: Bearer slicedrop-customer-secret帶著顧客 token 的標頭,token 寫在 Bearer 與一個空白之後。
      3. Content-Type: application/json說明下面的主體是 JSON。
      4. "customerName": "Ada",顧客名稱。主體裡沒有 id、狀態或時間戳記,那些由伺服器建立。
      5. "items": [{ "menuItemId": "soda", "quantity": 1 }]只有一個品項的陣列:一瓶汽水,以菜單 id 指定。

      員工清單使用 Bearer slicedrop-staff-secret。這些字串是作業示範用的憑證,不能作為正式環境的身分驗證設計。有效的顧客 token 不會授予員工存取權。

      重點整理

      HW1 把訂單存於記憶體。菜單公開;顧客建立訂單;員工列出訂單。篩選後空集合是 200/[];單一菜單品項不存在是 404。角色與回應結構都重要。

      檢查 · 先預測再選擇

      GET /menu?category=unicorn 找不到品項。哪個回應符合 HW1?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 200,主體為 [] — 集合查詢成功,只是符合項目為零。
      • 404,因為分類不熟悉 — HW1 把未知集合篩選視為有效但無符合項目的查詢。
      • 401,因為菜單查詢都需要員工 Token — 菜單公開;員工權限用於列出訂單。

      解析憑證與接受憑證是兩回事

      TypeScript
      export function parseBearerToken(header: string | undefined): string | null {
        if (header === undefined) return null;
        const match = header.match(/^Bearer ([^\s]+)$/);
        return match === null ? null : match[1];
      }

      逐行解說

      1. export function parseBearerToken(header: string | undefined): string | null {接收 Authorization 標頭(可能不存在),回傳 token 文字或 null。
      2. if (header === undefined) return null;沒有送出標頭,所以沒有 token 可讀。
      3. const match = header.match(/^Bearer ([^\s]+)$/);用正規表示式檢查標頭。開頭的 ^ 和結尾的 $ 讓正規表示式比對整個值。Bearer 後面緊接一個空白字元。其中 [^\s]+ 擷取一個以上的非空白字元。
      4. return match === null ? null : match[1];標頭不符合樣式時,match 是 null,結果也是 null。否則結果就是擷取到的部分,也就是 token。

      Python

      import re
      def parseBearerToken(header: str | None) -> str | None:
          if header is None: return None
          match = re.fullmatch(r'Bearer ([^\s]+)', header)  # Python's $ would also accept a final \n
          return None if match is None else match[1]
      
      print(parseBearerToken('Bearer nope'))  # → nope
      print(parseBearerToken('Bearer one two'))  # → None

      TypeScript

      export function parseBearerToken(header: string | undefined): string | null {
        if (header === undefined) return null;
        const match = header.match(/^Bearer ([^\s]+)$/);
        return match === null ? null : match[1];
      }
      標頭值解析結果接著會發生什麼事
      缺少null401
      Bearer slicedrop-customer-secret候選 token 字串顧客路由接受;員工路由拒絕。
      bearer token, Basic tokennull大小寫或驗證方案不符合此作業要求的精確格式。
      Bearer , Bearer one twonull空 token 或多餘空白會遭拒絕。
      Bearer nope"nope"解析成功;角色中介軟體仍以 401 拒絕。

      兩個中介軟體函式讀取標頭,把候選值與各自角色的常數比對,然後回應 401 或呼叫 next。

      應用程式的處理鏈為 JSON 解析器 → menu/orders 路由器 → JSON 404 備用處理 → 四參數錯誤處理函式。POST /orders 的路由器依序執行顧客身分驗證 → 處理函式 → validateOrder → addOrder。沒有 token 且 JSON 為合法的 {} 時,會在欄位驗證之前得到 401。

      重點整理

      解析取出候選憑證;權限檢查判斷該角色是否接受它。HW1 要求精確的 Bearer 格式及不同顧客/員工 Token。格式正確也可能是錯誤 Token。

      檢查 · 先預測再選擇

      Bearer nope 符合標頭格式。接著會怎樣?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 解析器本身必須送出 HTTP 回應 — 單純解析器回傳 Token 或 null;Middleware 決定回覆。
      • 解析成功,角色身分檢查仍拒絕 — 取出候選值不代表它符合接受的憑證。
      • 所有受保護路由都接受 — 權限檢查必須將候選值與該角色要求的 Token 比對。

      先驗證結構;收集所有能安全找出的錯誤

      欄位/規則合法條件防護的原因
      主體非 null、非陣列的物件不能把 null、陣列或基本型別值當成訂單物件。
      customerNametrim() 後不是空字串的字串拒絕缺少、型別錯誤或只有空白的名字。
      items非空陣列走訪之前先檢查 Array.isArray。
      每個項目含有已存在 menuItemId 的物件存取菜單選項之前先做防護。
      quantity至少為 1 的整數拒絕缺少、文字、零、負數或小數的數量。
      size / crust / sauce若有提供,必須是正確的字串,且該菜單品項提供此選項選填欄位一旦出現,仍必須驗證。
      toppings若有提供,必須是陣列,且每個元素都是有提供的配料字串字串不是配料清單;未知或型別錯誤的元素都算錯誤。

      處理函式將非空的 errors 陣列轉成 400 {"errors":[...]},若陣列為空,就建立訂單。讓驗證器保持為一般函式,可以重複使用,也更容易測試。

      若查不到菜單品項,先回報仍可檢查的 id 與 quantity 問題,再略過該品項的選項檢查。在 forEach 回呼函式中 return,只會停止目前項目的回呼,不會從外層驗證器返回。

      提供的輔助函式使用 some 檢查可選尺寸,使用 includes 檢查可選餅皮、醬料與配料。缺少選項清單代表不提供該選項。即使 NewOrder 有 TypeScript 欄位,驗證器仍必須檢查執行時期的值。

      JSON 形式示意 · 註解僅用於說明,不是合法 JSON
      // A valid JSON body, but an invalid order:
      {
        "customerName": "   ",
        "items": [{ "menuItemId": "not-on-menu", "quantity": 0 }]
      }
      // With the customer token: 400 and multiple validation errors.
      // Without it: 401 before order validation.

      逐行解說

      1. "customerName": " ",只有空白。trim() 之後什麼都不剩,所以驗證函式加入一個錯誤。
      2. "items": [{ "menuItemId": "not-on-menu", "quantity": 0 }]一個品項,有兩個問題。菜單上沒有這個 id,而數量必須是至少為 1 的整數。兩者各加入一個錯誤。因為查不到菜單品項,選項檢查會被略過。
      3. // With the customer token: 400 and multiple validation errors.處理函式把非空的 errors 陣列轉成一個 400 回應,列出所有錯誤。
      4. // Without it: 401 before order validation.身分驗證中介軟體先回應,所以驗證函式根本不會執行。
      TypeScript · 為本指南撰寫,省略品項檢查,以 Node 24 實際執行
      function validateOrder(body: any): string[] {
        const errors: string[] = [];
        if (typeof body?.customerName !== 'string' || body.customerName.trim() === '')
          errors.push('customerName is required');
        if (!Array.isArray(body?.items) || body.items.length === 0)
          return [...errors, 'items must be a nonempty array'];
        return errors;
      }

      Python

      def validateOrder(body) -> list[str]:
          errors, fields = [], body if isinstance(body, dict) else {}
          if not isinstance(fields.get('customerName'), str) or fields['customerName'].strip() == '':
              errors.append('customerName is required')
          if not isinstance(fields.get('items'), list) or len(fields['items']) == 0:
              return [*errors, 'items must be a nonempty array']
          return errors
      print(validateOrder(None))  # → ['customerName is required', 'items must be a nonempty array']

      TypeScript

      function validateOrder(body: any): string[] {
        const errors: string[] = [];
        if (typeof body?.customerName !== 'string' || body.customerName.trim() === '')
          errors.push('customerName is required');
        if (!Array.isArray(body?.items) || body.items.length === 0)
          return [...errors, 'items must be a nonempty array'];
        return errors;
      }

      Python 的 .get() 只能用在 dict 上,所以才要先做 isinstance 檢查;body?. 則對任何值都能用。

      重點整理

      存取欄位前先檢查結構:物件 → items 陣列 → 每個品項 → 可選項目。收集所有可安全發現的錯誤。純驗證函式回傳錯誤,由處理函式選擇 400 或建立。

      檢查 · 先預測再選擇

      items 是字串,customerName 是空白。驗證應怎樣做?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 接受,因為 NewOrder 宣告了欄位 — 執行時資料可能違反宣告型別,必須檢查。
      • 回報兩項可安全發現的錯誤,跳過品項迭代 — 迴圈前先用 Array.isArray;仍可收集姓名錯誤。
      • 先迴圈,讓當機變成 500 — 應先防護無效輸入結構,不應變成非預期故障。

      狀態、時間與三種不同失敗

      NewOrder 描述顧客提交的欄位。 Order extends NewOrder 再加上 id、status 與 createdAt。 addOrder 產生像 "1"、"2" 這樣的 id,將 status 設為 pending,使用 ISO 時間戳記,並加入記憶體內陣列。未篩選的清單保留插入順序;status 篩選只保留精確相符項目。重啟 Node 會清空訂單並重設計數器。

      情況失敗位置回應
      格式錯誤的 JSON 文字JSON 解析器 → 最後的處理函式400; {"error":"Request body must be valid JSON"}
      合法 JSON,但訂單欄位無效一般 validateOrder 函式 → 路由處理函式400; {"errors":[...]}
      缺少 token/角色錯誤/無效 token角色中介軟體401; {"error":"Unauthorized"}
      未知方法/路徑末端中介軟體404; {"error":"Not found"}
      非預期的應用程式例外最後的處理函式500; {"error":"Internal server error"},詳細資訊記錄在伺服器上。

      解析器錯誤會被辨識為帶有 body 屬性的 SyntaxError。非預期例外不會被改成用戶端資料驗證錯誤。測試時間戳記時,檢查其型別以及 Date.parse 是否接受,不要斷言某個精確時間。

      重點整理

      伺服器建立訂單 id、pending 狀態與時間。重啟 HW1 會清除記憶體訂單。需區分 JSON 格式錯誤、無效欄位、未授權、不存在路由及非預期例外。

      檢查 · 先預測再選擇

      重啟 HW1 後,之前的訂單消失。為什麼?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • ISO 時間戳會自動讓訂單到期 — 時間戳只記錄時間,不會實作到期功能。
      • 訂單存在 Node 程序內,不是持久 Mongo 儲存 — 重啟會失去程序內的陣列,並重設計數器。
      • 每個 HTTP API 重啟都會刪除記錄 — 是否保留取決於儲存方式,HTTP 不強制此行為。

      檔案導覽與作業測試範圍

      檔案對照表:HW1 原始碼與測試檔
      檔案/群組研讀重點
      app.ts / index.ts解析器、掛載點、備用處理與錯誤;分開的監聽器。
      middleware/auth.ts精確的 Bearer 解析,以及分開的顧客/員工存取檢查。
      routers/menu.ts / orders.ts將每個 HTTP 請求轉成查找、驗證與資料操作。
      validation/validate-order.ts累積欄位錯誤;安全地呼叫可選項目的輔助函式。
      data/menu.ts / data/orders.ts提供的菜單查找;程序內的訂單狀態與測試重設。
      types.ts / constants.ts共用的請求/儲存資料結構;練習用 token 與連接埠。
      auth.test.ts缺少、無效與角色錯誤的 token;授權成功情況;身分驗證早於資料驗證。
      menu.test.ts六個品項、category 篩選、客製欄位與不存在的 id。
      orders.test.ts201、唯一 id、pending 狀態、可解析時間、清單/篩選/插入順序。
      errors.test.ts404、全部資料驗證錯誤、格式錯誤的 JSON,以及模擬 addOrder 失敗 → 500。

      _resetOrders() 在每個訂單測試之前執行。讓 addOrder 拋出錯誤的 mock,可以測試錯誤流程,不需要真的製造應用程式故障。還原 mock 能避免影響之後的測試。來源資料庫中的完整作業教學指南包含逐檔說明與 18 題測驗。

      為什麼 /menu?category=unicorn 是 200,而 /menu/unicorn 是 404?

      前者要求篩選集合,沒有相符項目仍是正確結果。後者要求取得特定資源,但該資源不存在。

      重點整理

      依檔案界線閱讀:Router 轉譯 HTTP,Auth 控制存取,Validator 收集錯誤,Data 模組管理狀態。測試重設訂單並還原 Mock,避免互相污染。

      檢查 · 先預測再選擇

      測試把 addOrder 模擬成拋出例外。後續成功測試前必須做什麼?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 還原 Mock 並重設測試狀態 — 注入的故障與訂單狀態不可流入後續測試。
      • 把後續測試改為期待 500 — 這會保留測試造成的影響,無法驗證正常行為。
      • 只重啟瀏覽器 — Mock 位於測試程序內,不在瀏覽器畫面。

      來源依據 · 本機 HW1 實作、四個測試檔、README,以及完整的 TEACHING_GUIDE。

      05

      第 3 週 · MongoDB、BSON、CRUD、id 與 Express 整合

      將狀態移到資料庫。

      選擇資料模型;認識術語

      連回第 0 節:MongoDB 扮演保存訂單簿的角色。留意「索取資料的請求」和「保存資料的紀錄」有什麼不同。

      本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的是:資料庫。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫本章
      本章點亮的是資料庫:存進 MongoDB 的紀錄,在伺服器重新啟動後仍然存在。

      標出地圖 · 先點名稱,再點空格

      MongoDB 在哪裡

      把每個名稱放到地圖上它該在的位置。

      名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。1?瀏覽器、Postman、curl2?3?伺服器4?中介軟體控制器服務5?

      這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

      還沒安裝?先設定好 MongoDB、mongosh 與 Compass。

      MongoDB 是文件導向的 NoSQL 資料庫。SQL 資料庫將資料列組成資料表,透過鍵與聯結表達關聯,適合關係緊密的資料與關聯式查詢。Mongo 將文件存於集合,允許巢狀物件、陣列,以及欄位不同的文件。

      資料庫、集合、文件、欄位四個方塊一層包一層,每個方塊旁邊標出大致對應的 SQL 名詞。最外層是資料庫 week3db,SQL 也稱為資料庫。裡面是集合 products,SQL 稱為資料表。集合裡有兩份文件,SQL 稱為資料列。第一份文件有四個欄位:_id 是一個 ObjectId,name 是 Duct Tape,price 是 5.99,quantity 是 120。其中 name 欄位標示為「欄位」,SQL 稱為資料欄。第二份文件是另一項商品 Scotch Tape,有它自己的欄位。資料庫 week3dbSQL:資料庫集合 productsSQL:資料表文件SQL:資料列_id: new ObjectId('68bf9c…')欄位SQL:資料欄name: 'Duct Tape'price: 5.99quantity: 120文件SQL:資料列name: 'Scotch Tape',以及它自己的欄位
      請注意這個包含關係:資料庫裡有集合,集合裡有文件,文件裡有欄位,每一層都有大致對應的 SQL 名詞。

      講義提出使用 NoSQL 的三個動機: 可擴充性 (將資料分散到多台機器)、 彈性 (改變文件結構),以及 簡易性 (物件形式的資料與查詢)。這些都是設計取捨,不代表 NoSQL 一定較快,或 SQL 無法擴充。彈性的儲存仍需要應用程式契約、驗證,並留意舊文件可能具有不同結構。

      MongoDB大致對應的關聯式概念範例
      資料庫資料庫week3db, week3app, week4
      集合資料表products
      文件資料列一筆膠帶或咖啡機紀錄
      欄位資料欄name, price, quantity

      Mongo 儲存的是 BSON,這是一種二進位表示方式,支援 ObjectId、日期等額外型別。JSON 本身支援多種值的型別,並非每個值都是字串。欄位名稱是字串,巢狀物件與陣列則讓一份文件容納結構化資料。Node 驅動程式負責在 JavaScript 值與 BSON 之間轉換。

      本機 Community Edition 伺服器是 mongod; mongosh 是互動式用戶端,Compass 則是圖形介面用戶端。 mongodb npm 套件是驅動程式,並不是伺服器。MongoDB Atlas 是代管服務。Mongo 也支援複寫/容錯切換與分片;課堂練習只使用一台本機伺服器。

      重點整理

      資料庫 → 集合 → 文件 → 欄位。MongoDB 儲存 BSON 記錄,可包含巢狀值。mongod 是伺服器;mongosh 與 Compass 是客戶端;npm Driver 讓 Node 與它通訊。

      檢查 · 先預測再選擇

      已安裝 mongodb npm 套件。這會啟動持久的本機資料庫伺服器嗎?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 會;Driver 就是伺服器 — Driver 連到伺服器,不會取代 mongod。
      • 會;開啟 Compass 一定會建立伺服器 — Compass 是圖形客戶端,需要可連線的伺服器。
      • 不會;它是 Driver,mongod 需另行執行 — 客戶端函式庫與資料庫伺服器是不同程式。

      連線、選擇資料庫,再選擇集合

      mongosh
      // mongosh setup for the exploration database:
      use week3db
      db.createCollection("products")
      show databases
      TypeScript
      import { MongoClient, ObjectId } from 'mongodb';
      import type { Db, Document } from 'mongodb';
      
      const client = new MongoClient('mongodb://127.0.0.1:27017');
      await client.connect();
      const db = client.db('week3db');
      await db.command({ ping: 1 });
      const products = db.collection('products');
      // ...operations...
      await client.close();

      逐行解說

      1. import { MongoClient, ObjectId } from 'mongodb';從 mongodb 套件載入兩個工具。下一行載入兩個型別,只有 TypeScript 會用到。
      2. const client = new MongoClient('mongodb://127.0.0.1:27017');建立一個用戶端,對象是你自己電腦上、連接埠 27017 的 MongoDB 伺服器。
      3. await client.connect();三個獨立步驟的第一步:連線到伺服器。await 會在這裡暫停,直到連線完成。
      4. const db = client.db('week3db');第二步:依名稱選擇資料庫。
      5. await db.command({ ping: 1 });檢查連線:向伺服器送出 ping 並等待回答。
      6. const products = db.collection('products');第三步:選擇集合。接下來的操作都在它上面執行。
      7. await client.close();臨時練習或測試結束時,關閉用戶端。

      Python

      from pymongo import MongoClient
      
      client = MongoClient('mongodb://127.0.0.1:27017')
      # no connect() and no await: the client connects by itself
      db = client.get_database('week3db')
      db.command({'ping': 1})  # → {'ok': 1.0}
      products = db.get_collection('products')
      client.close()

      TypeScript

      import { MongoClient, ObjectId } from 'mongodb';
      
      const client = new MongoClient('mongodb://127.0.0.1:27017');
      await client.connect();
      const db = client.db('week3db');
      await db.command({ ping: 1 });
      const products = db.collection('products');
      await client.close();

      資料庫要等到存了資料或集合之後,才會出現在清單裡。長時間執行的應用程式會在啟動時連線,之後一直沿用這個連線。

      探索程式使用 week3db;第 3 週 Express 應用程式使用 week3app。匯入其中一個資料庫不會填入另一個。 npm run explore 以監看模式執行臨時探索檔。如果插入功能一直開著,每次儲存或重啟都會新增重複資料:先填入一次初始資料,然後關掉它。

      重點整理

      先連伺服器,再選資料庫與集合。資料庫名稱是目的地的一部分。匯入 week3db 不會填入 week3app。避免監看模式重複執行種子資料插入。

      檢查 · 先預測再選擇

      Compass 的 week3db 有記錄,但 App 讀 week3app 得到 []。應檢查什麼?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 假設 find() 無法讀取匯入資料 — 目的地不一致已能解釋情況,應先確認再懷疑查詢方法。
      • 每次存檔都插入,直到清單看起來正確 — 監看模式插入可能產生重複資料;應修正目的地並刻意載入。
      • App 選擇的資料庫與集合 — 兩個資料庫可以各自保存同名集合。

      CRUD 使用查詢物件與更新運算子

      TypeScript
      const products = db.collection('products');
      
      const inserted = await products.insertOne({ name: 'Duct Tape', price: 5.99, quantity: 120 });
      const all = await products.find().toArray();
      const one = await products.findOne({ name: 'Duct Tape' });
      const pricey = await products.find({ price: { $gte: 5 } }).toArray();
      
      const changed = await products.updateOne(
        { _id: inserted.insertedId },
        { $set: { quantity: 150 } }
      );
      const removed = await products.deleteOne({ _id: inserted.insertedId });

      逐行解說

      1. const inserted = await products.insertOne({ name: 'Duct Tape', price: 5.99, quantity: 120 });建立。存入一份文件。結果裡有 insertedId,也就是 MongoDB 產生的 _id。它不是讀回來的文件。
      2. const all = await products.find().toArray();讀取全部。find() 回傳的是游標,要靠 toArray() 才得到陣列。
      3. const one = await products.findOne({ name: 'Duct Tape' });讀取一份。這個物件是查詢:比對 name 等於這段文字的文件。結果是一份相符的文件,或 null。
      4. const pricey = await products.find({ price: { $gte: 5 } }).toArray();比較查詢。這裡的 $gte 表示大於或等於,所以會找出價格在 5 以上的所有商品。
      5. { _id: inserted.insertedId },更新需要兩個物件。第一個是查詢:具有這個 id 的文件。
      6. { $set: { quantity: 150 } }第二個說明要改什麼。這裡的 $set 修改 quantity,其他欄位保持不變。
      7. const removed = await products.deleteOne({ _id: inserted.insertedId });刪除。移除第一份符合查詢的文件。檢查 deletedCount,就知道是否真的移除了一份。

      動手重建 · 把程式行排好

      依 id 刪除一份文件

      出自第 3 週筆記:一個依 id 刪除一項商品的函式,以及執行它的 main 函式。把程式行排好。

      顯示排好的程式碼
      const deleteOneTest = async () => {
        const query = { _id: new ObjectId('68c06e746d1392af8b22b35f') };
        const result = await db.collection('products').deleteOne(query);
        return result;
      };
      const main = async () => {
        await connect();
        const result = await deleteOneTest();
        console.log(result);
        await disconnect();
      };
      main();

      先用 ObjectId 建立查詢,再執行操作、回傳它回報的結果,而且全部都在連線與中斷連線之間完成。印出結果和中斷連線互不相依,誰先誰後都可以。

      Python

      inserted = products.insert_one({'name': 'Duct Tape', 'price': 5.99, 'quantity': 120})
      all = list(products.find())
      pricey = list(products.find({'price': {'$gte': 5}}))
      changed = products.update_one(
          {'_id': inserted.inserted_id},
          {'$set': {'quantity': 150}}
      )
      print(changed.modified_count)  # → 1

      TypeScript

      const inserted = await products.insertOne({ name: 'Duct Tape', price: 5.99, quantity: 120 });
      const all = await products.find().toArray();
      const pricey = await products.find({ price: { $gte: 5 } }).toArray();
      const changed = await products.updateOne(
        { _id: inserted.insertedId },
        { $set: { quantity: 150 } }
      );

      pymongo 的方法名稱是以底線分隔的小寫,也不用 await;list() 的作用相當於 toArray(),對應 TypeScript 那一側。

      操作理解重點
      insertOne / insertMany建立一份或多份文件。結果回報 acknowledged 與 insertedId,或 insertedCount/insertedIds;不是完整讀回的文件。
      find(query)回傳 Cursor,而不是最後的陣列。使用 await cursor.hasNext()/next() 或 await cursor.toArray()。
      findOne(query)回傳一份相符文件或 null;沒有相符結果不會拋出例外。
      相等查詢{name: "Duct Tape"} 比對相等的欄位值。
      比較查詢$lt 小於, $gte 大於或等於;$gt 與 $lte 是另外兩個對應的邊界。
      updateOne / updateMany對第一筆或所有相符資料套用變更。 {$set:{price:4.29}} 保留其他欄位,也可以新增欄位。
      matchedCount / modifiedCount相符紀錄可能本來就具有指定值:matchedCount 為 1,modifiedCount 為 0。以相符數量判斷資料是否存在。
      deleteOne / deleteMany移除第一筆或所有相符資料;檢查 deletedCount。 deleteMany({}) 移除該集合中的所有文件。
      其他驅動程式選項replaceOne 替換一份文件; {upsert:true} 可以在不存在時插入; {$inc:{quantity:1}} 增加欄位數值。這些是講義延伸內容,並非路由必備功能。
      TypeScript
      const cursor = db.collection('products').find({ price: { $lt: 5 } });
      const results: Document[] = [];
      while (await cursor.hasNext()) {
        const doc = await cursor.next();
        if (doc) results.push(doc); // next() can be null
      }

      名稱可能重複。名稱篩選能示範查詢,但對單一資源 API 而言,使用已知 id 更精確。更新後應再讀取確認;結果物件並不包含修改後的文件。

      重點整理

      CRUD 表示建立、讀取、更新、刪除。find() 給出 Cursor,toArray() 讀取結果。$set 更改指定欄位。matchedCount 看存在,modifiedCount 看變更,deletedCount 看刪除。

      檢查 · 先預測再選擇

      價格更新成原值,matchedCount 是 1,modifiedCount 是 0。代表什麼?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 記錄不存在 — matchedCount 1 表示篩選符合了一筆記錄。
      • 記錄遭刪除 — 更新計數不代表刪除;需看正確操作與結果。
      • 記錄存在,這次更新沒改變內容 — 成功但無變更,與記錄不存在不同。

      id 的格式與是否存在是兩個問題

      TypeScript
      const id = '507f1f77bcf86cd799439011'; // illustrative shape
      const document = await db.collection('products').findOne({
        _id: new ObjectId(id)
      });

      逐行解說

      1. const id = '507f1f77bcf86cd799439011'; // illustrative shapeURL 帶來的 id:由 24 個十六進位字元組成的字串。
      2. const document = await db.collection('products').findOne({向 products 集合要一份文件。
      3. _id: new ObjectId(id)要查詢值為 ObjectId 的 _id,必須使用 new ObjectId(id),因為一般字串的 BSON 型別不同。

      Python

      from bson import ObjectId  # installed with pymongo
      id = '507f1f77bcf86cd799439011'  # illustrative shape
      document = db.get_collection('products').find_one({
          '_id': ObjectId(id)
      })
      print(document)  # → None

      TypeScript

      const id = '507f1f77bcf86cd799439011'; // illustrative shape
      const document = await db.collection('products').findOne({
        _id: new ObjectId(id)
      });

      標準的 Mongo 自動產生 ObjectId 是 12 位元組的值,通常顯示為 24 個十六進位字元。Mongo 可以儲存其他型別的 _id,但這些練習使用它產生的 ObjectId。

      建立 ObjectId 之前先拒絕格式錯誤的 id。 badID123 在你完成的中介軟體中應回傳 400。語法合法但找不到相符文件的 id 回傳 404。講義中的 id 只是示意:要成功查找,請先從自己的 POST、清單或 Compass 複製實際 id。

      TypeScript · 第 3 週延伸任務的教學示意,不是課程程式碼
      export const validateId = (req: Request, res: Response, next: NextFunction) => {
        if (!/^[0-9a-f]{24}$/.test(String(req.params.id))) {
          res.status(400).json({ error: 'Malformed id' });
          return;
        }
        next();
      };

      逐行解說

      1. export const validateId = (req: Request, res: Response, next: NextFunction) => {一個中介軟體函式:接收請求、回應與 next。
      2. if (!/^[0-9a-f]{24}$/.test(String(req.params.id))) {用一個樣式檢查路徑裡的 id:剛好 24 個字元,每個都是數字或 a 到 f 的字母。開頭的 ! 讓 id 不符合時才進入這個分支。
      3. res.status(400).json({ error: 'Malformed id' });在這裡回應 400。不會建立 ObjectId,也不會去問 Mongo。
      4. return;到此為止,送出回應後就不會再呼叫 next()。
      5. next();格式沒問題,所以交給路由執行。有沒有文件使用這個 id 是另一個問題,答案是 404。

      動手重建 · 把程式行排好

      不是回應就是繼續的中介軟體

      重建 validateId,也就是檢查 id 格式的中介軟體。把程式行排好,讓格式錯誤的 id 得到 400,格式正確的 id 繼續前往路由。

      顯示排好的程式碼
      export const validateId = (req: Request, res: Response, next: NextFunction) => {
        if (!/^[0-9a-f]{24}$/.test(String(req.params.id))) {
          res.status(400).json({ error: 'Malformed id' });
          return;
        }
        next();
      };

      中介軟體不是送出回應,就是呼叫 next(),不會兩者都做:400 和它的 return 排在前面,所以只有格式正確的 id 會執行到 next()。

      重點整理

      ObjectId 通常表示為 24 個十六進位字元。先查格式,再用 ObjectId 值查詢。看似有效的地址也可能沒有記錄:格式錯誤 → 400;有效但不存在 → 404。

      檢查 · 先預測再選擇

      24 個十六進位字元的 id 通過驗證,但 findOne 回傳 null。結果應是什麼?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 400:文件不存在就表示 id 格式錯 — 此 id 已通過格式檢查;存在與否是另一個問題。
      • 200,因為 id 看起來有效 — 有效的 id 表示法不等於已取得文件。
      • 404:沒有相符資源 — 格式驗證無法證明資源存在。

      用 HTTP 路由包裝 Mongo 操作

      db.ts 負責連線、斷線與商品操作。它接受 URI/資料庫引數,因此同一套程式可以使用練習資料庫或暫時測試資料庫。路由器負責請求欄位與 HTTP 狀態碼。 app.ts 解析 JSON 並掛載 /products。 index.ts 先等待連線成功,才在 6790 開始監聽。

      第 3 週路由資料庫函式本機成功/不存在的結果
      GET /productsgetAllProducts → find().toArray()200,回傳陣列;空集合回傳 []。
      GET /products/:idgetProduct → findOne(_id)200,回傳文件;若為 null,回傳 404。
      POST /productsaddProduct → insertOne()201,回傳 {id}。
      PATCH /products/:idupdateProduct → $set,回傳 matchedCount有相符資料就回傳 200,即使沒有實際變更;相符數量為零時回傳 404。
      DELETE /products/:iddeleteProduct → deletedCount成功刪除為 200;沒有相符資料為 404。
      TypeScript · 取自第 3 週講義,尚未加入 validateId
      productRouter.get('/:id', async (req: Request, res: Response) => {
        const product = await getProduct(String(req.params.id));
        if (!product) {
          res.sendStatus(404);
          return;
        }
        res.json(product);
      });

      逐行解說

      1. productRouter.get('/:id', async (req: Request, res: Response) => {註冊取得單一商品的 GET。處理函式是 async,因為它要等待資料庫。
      2. const product = await getProduct(String(req.params.id));從路徑讀取 id,請 db.ts 取得那項商品。路由器負責請求欄位,本身不碰 Mongo。
      3. if (!product) {沒有文件使用那個 id 時,getProduct 會給 null,而 null 會被視為 false。
      4. res.sendStatus(404);路由器也負責 HTTP 狀態碼:找不到就是 404。下一行的 return 讓處理函式停下來。
      5. res.json(product);找到了,就把文件以 JSON 送出。在第 3 週,這份原始文件仍然露出 _id。

      GET/PATCH/DELETE 的 id 路由先執行 validateId,因此格式錯誤的 id 會得到 400。第 3 週 Product 欄位為 name, price,以及 quantity。其原始文件回應會暴露 _id;第 4 週加入轉換,改用公開的 id 欄位。 ProductUpdate 讓 PATCH 的欄位變成選填。POST 建立已知的三欄位商品;PATCH 使用提供的變更。型別註記本身不會強制執行主體的執行時期驗證。

      _clearProducts() 使用 deleteMany({}),是測試輔助函式,不是路由。只有在刻意連到隔離的測試資料庫後才能呼叫。

      動手重建 · 把程式行排好

      先連線,再監聽

      這是第 3 週筆記裡的 index.ts,也就是啟動伺服器的檔案。把程式行排好,讓伺服器在資料庫連線之後才開始接受請求。

      顯示排好的程式碼
      import { app } from './app.js';
      import { connect } from './db.js';
      const port = 6790;
      await connect('mongodb://127.0.0.1:27017', 'week3app');
      app.listen(port, () => {
        console.log(`Server running on port ${port}`);
      });

      資料庫先連線,伺服器才開始監聽,所以不會有請求在連線建立之前就到達路由。設定連接埠的那一行和連線的那一行互不相依,誰先誰後都可以。

      重點整理

      第 3 週路由把 Mongo 結果轉為 HTTP。啟動時先連資料庫再監聽。PATCH 套用提供的欄位並檢查符合筆數;DELETE 檢查刪除筆數。第 3 週回傳原始 _id,第 4 週轉成公開 id。

      檢查 · 先預測再選擇

      第 3 週 PATCH 只改 price。name 與 quantity 應怎樣?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • 保持原值 — 部分更新使用 $set 修改提供的欄位,不是取代全部內容。
      • 自動移除 — 這違反部分更新的行為。
      • 變成空值,因為每次更新都是 POST — PATCH 與 POST 不同,需遵守路由約定。

      第 3 週所有 Now You Try 任務

      活動必須能寫出並驗證的內容
      NYT #1 · findPricierTest使用 $gte 找出所有 price ≥ 5 的商品。
      NYT #1 · updatePriceTest使用 updateOne 與 $set,將 Scotch Tape 的 price 設為 4.29。
      NYT #1 · deleteByNameTest使用 deleteOne,依名稱刪除 Masking Tape。
      NYT #1 延伸 · markOnSaleTest使用 updateMany 與 $lt:5,替相符商品加入 onSale:true;在 Compass 檢查不同文件的欄位差異。
      NYT #2 · PATCH 測試斷言格式正確但不存在的 id 回傳 404;成功更新只改變提供的欄位,並保留其他欄位。
      NYT #2 · DELETE 測試斷言成功為 200、不存在的 id 為 404;操作後再確認文件或清單。
      NYT #2 延伸 · id 中介軟體以 400 拒絕格式錯誤的 id,並測試每個受影響的 GET/PATCH/DELETE 路由。

      目前講義指出 MongoMemoryServer 內容移到第 4 週。本指南在第 7 章統整測試概念,並在這裡與練習實驗室保留兩週的活動。

      PATCH 將 price 設為目前相同的值。matchedCount 1/modifiedCount 0 應該變成 404 嗎?

      不應該。相符商品存在,這是成功但無實際變更的操作。只根據 modifiedCount 回傳 404,會將「未變更」與「不存在」混為一談。

      重點整理

      練習邊界與範圍:$gte:5 包含 5;$lt:5 不包含 5。updateOne 改一筆,updateMany 改全部符合項目。之後讀取確認;破壞性測試工具只用於隔離的測試資料庫。

      檢查 · 先預測再選擇

      要讓所有低於 5 的商品特價,並保留其他欄位。哪個操作適合?

      先解釋,再比較推理

      查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

      • updateOne({price:{$gte:5}}, {$set:{onSale:true}}) — 這只處理一筆,且價格範圍相反。
      • deleteMany({price:{$lt:5}}) — 刪除符合商品不等於把它們標為特價。
      • updateMany({price:{$lt:5}}, {$set:{onSale:true}}) — Many 套用全部符合項目;$lt 排除 5;$set 保留其他欄位。

      試著執行查詢

      在一個小小的 products 集合上執行第 3 週的驅動程式操作,看看回傳了什麼,以及集合有什麼變化。不需要安裝任何東西,也沒有任何資料離開這個頁面:集合和每一個結果,都是在你的瀏覽器裡模擬的。

      這個沙盒需要 JavaScript。沒有 JavaScript 時,這裡有一個手動走過一遍的查詢。

      TypeScript · 驅動程式呼叫
      await products.find({ price: { $gte: 5 } }).toArray()
      1. 集合裡有六項產品:Duct Tape 5.99、Scotch Tape 3.99、Masking Tape 2.01、Packing Tape 5、Glue Stick 1.5,以及 Stapler 12.5。
      2. 查詢物件只有一個條件:price 必須大於或等於 5。
      3. 有三項產品符合。Packing Tape 也算,因為 $gte 包含剛好等於 5 的價格。
      4. find() 給的是 cursor,toArray() 再把它讀成下面這個陣列。
      回傳值 · console.log 印出來的樣子
      [
        {
          _id: new ObjectId('68bf9cae04ffbba55ba70cbf'),
          name: 'Duct Tape',
          price: 5.99,
          quantity: 120
        },
        {
          _id: new ObjectId('68bf9f42d0eabc55610c1ab5'),
          name: 'Packing Tape',
          price: 5,
          quantity: 60
        },
        {
          _id: new ObjectId('68bf9f42d0eabc55610c1ab7'),
          name: 'Stapler',
          price: 12.5,
          quantity: 15
        }
      ]

      引導任務

      依序往下做。你執行的操作達成任務時,該任務就會打勾。這些勾與練習進度裡的檢查分開計算。

        每個引數都照講義的寫法來寫。鍵不用加引號。文字放在單引號或雙引號裡。

        使用 find 與 findOne 時,留空就會比對到每一份文件。想用 id 查某一份文件,可以從下面的集合複製它的 _id。

        不寫 _id 的話,會自動產生一個。insertMany 接受放在 [ ] 裡、用逗號分開的多份文件。

        $set 給欄位新的值。$inc 把欄位加上一個數字。

        一般的更新留空即可。加上 upsert: true 之後,沒有比對到任何文件的更新會新增一份文件。

        按「執行」,就能看到驅動程式呼叫、回傳的東西、原因,以及之後的集合。

        4 · products 集合

          文件只存在這個頁面上。重新載入頁面或按「重設產品」,就會回到一開始的六份。

          這個沙盒不做的事
          • 它只涵蓋第 3 週講義教的內容:find、findOne、insertOne、insertMany、updateOne、updateMany、deleteOne 與 deleteMany;查詢裡的相等比對、$lt、$lte、$gt 與 $gte;更新裡的 $set 與 $inc;以及 upsert 選項。
          • 其他運算子和選項在這裡都不能用:沒有 $or、$in、$ne 或 $unset,也沒有正規表示式、排序、筆數限制、投影、索引、聚合或 replaceOne。用了的話,會出現一則指出它名稱的訊息。
          • find() 直接顯示成 toArray() 給的陣列。這裡沒有可以一步一步讀取的 cursor。
          • 比較運算子只比較數字。查詢不能深入巢狀物件或陣列,也不能拿欄位和它們比對。
          • 每一個 _id 都是 ObjectId,寫成 new ObjectId('...'),裡面是 24 個十六進位字元。真正的 ObjectId 建構函式還接受其他幾種輸入,這裡的不接受。
          • findOne、updateOne 與 deleteOne 依照集合列出的順序取第一份相符的文件。講義只說第一份相符的文件由 Mongo 自己認定。
          • 操作失敗時,沙盒不會改變任何東西。真正的 insertMany 或 updateMany 如果中途失敗,可能已經存入一部分變更。
          • 新欄位依照你寫的順序加入。真正的伺服器可能用不同的順序儲存多個新欄位。
          • 回傳值的印法和講義裡 console.log 印出來的一樣。很長或巢狀很深的值,換行的位置可能和 Node 不同。
          • 其中三項產品和它們的 id、價格、數量來自第 3 週講義。另外三項是沙盒自己加的。新文件的 id 在你的瀏覽器裡產生。
          • 你輸入的內容由一個小型解析器當成資料讀取,絕對不會當成程式碼執行。它讀得懂物件、陣列、加引號的文字、數字、true、false、null 與 new ObjectId('...')。鍵可以不加引號,// 註解會被略過,手機鍵盤打出的彎引號也當成一般引號。變數、函式呼叫和算式都讀不懂。
          • 文件只存在這個頁面上,最多同時 50 份。不會保存任何東西,也不會送出任何東西。

          來源依據 · 完整的第 3 週提供筆記;db-explore.ts、db.ts、product-router.ts、validate-id.ts、測試與讀書計畫。

          06

          第 4 週 · REST、資源表述與控制器–服務分層

          設計公開的 API 契約。

          REST 是一種架構風格

          REST 代表 Representational State Transfer,由 Roy Fielding 於 2000 年提出。它的影響力來自以一致的用戶端/伺服器介面配合網路的 HTTP 模型。講義將它與 SOAP/CORBA 等較複雜的分散式呼叫方式比較。REST 並不只是「回傳 JSON 的端點」。

          本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的是:構成公開約定的請求與回應,以及背後的控制器與服務。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫本章
          本章點亮的是公開約定,也就是用戶端看得到的請求與回應,以及背後的控制器與服務兩層。

          標出地圖 · 先點名稱,再點空格

          契約背後的各層

          把每個名稱放到地圖上它該在的位置。

          名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。用戶端瀏覽器、Postman、curl請求回應伺服器1?2?3?4?5?

          這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

          第 4 週講義列出六項指導原則,也稱為限制條件。下表依講義的順序列出每一項、它的意思,以及它在本課程 API 中的樣子。

          限制條件意思在本課程的 API 中
          一致介面用戶端與伺服器用一致的方式溝通:依標準意義使用標準 HTTP 方法,端點的命名也保持一致。講義的設計讓每種資源都用同一個模式:複數名詞的路徑,以 GET 讀取、POST 建立、PATCH 修改、DELETE 移除。
          用戶端–伺服器用戶端與伺服器是各有職責的兩個程式,所以其中一方可以改動,另一方不必重寫。Express 伺服器保存商品並套用規則。用戶端只送出請求、讀取回應。
          無狀態每個請求都帶著伺服器完成它所需的一切。伺服器不會在請求之間保留對話脈絡。取得單一商品的請求在路徑裡寫明 id,建立商品的請求在主體裡送出欄位。沒有任何路由需要記得先前的請求。
          可快取回應會說明自己能不能存起來,之後直接重複使用,不必再請求一次。課程的程式碼自己沒有設定任何快取標頭,所以它的回應沒有說明。講義說這項限制條件很難做好,實作上也不一致。
          分層系統系統以分層建構,每個部分只看得到相鄰的那一層。用戶端只看得到 HTTP 介面。它無法分辨、也不需要知道路由與 MongoDB 之間還有控制器、服務與資料庫這幾層。
          按需提供程式碼 · 選用伺服器可以送出程式碼讓用戶端執行。這是唯一選用的限制條件。沒有使用。這個 API 只以 JSON 送出資料,從不送出程式碼。

          無狀態 不 代表「沒有資料庫」。商品可以持久儲存,而每個請求仍獨立說明自己的需求。用戶端收到的是資源狀態的 表述 ,不是直接控制儲存的文件。修改用戶端物件不會更新 Mongo,必須再送出請求,要求伺服器執行更新。

          重點整理

          REST 組織一致的資源介面。Representation 是客戶端收到的資料。Stateless 表示每次請求提供所需脈絡,不禁止儲存商品或訂單;其他限制也重要。

          檢查 · 先預測再選擇

          Stateless API 把商品存於 MongoDB。這有矛盾嗎?

          先解釋,再比較推理

          查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

          • 沒有;持久資源與獨立請求可並存 — Stateless 關乎請求脈絡,不表示沒有持久資料。
          • 有;Stateless 禁止所有資料庫 — 這混淆了對話狀態與資源儲存。
          • 任何 JSON API 都自動完全符合 REST — 只有 JSON 並不能滿足 REST 限制。

          設計實體 → 表述 → 端點

          先從實體與其關係開始,再選擇用戶端能看見的欄位,最後用複數資源名詞規劃路徑,以 HTTP 方法表達動作。資料庫的儲存安排不應決定每個公開路徑。

          講義情境中的表述欄位與關係
          顧客id、firstName、lastName、zipCode。zipCode 是文字,保留開頭的零。
          商品id, modelName, modelNumber, manufacturer, color, price, quantity。
          訂單id、customerId、status,以及以商品 id 為鍵並附有數量的 items。
          關係顧客有訂單;每筆訂單指向一位顧客,並包含各商品的數量。
          端點設計操作
          /customersGET 取得集合;POST 建立。
          /customers/:idGET 取得一筆;PATCH 更新提供的欄位;DELETE 刪除一筆。
          /productsGET 取得集合;POST 建立。
          /products/:idGET 取得一筆;PATCH 更新提供的欄位;DELETE 刪除一筆。
          /customers/:id/ordersGET 取得此顧客的訂單;POST 為此顧客建立新訂單。

          這是 設計情境。第 4 週練習實作商品 GET 清單、POST、依 id GET,以及 DELETE 延伸題;沒有實作整個顧客/訂單 API 或 PATCH。情境規則:提交訂單時檢查供貨情況並扣除庫存;將訂單存於頂層集合,以便跨顧客查詢。角色權限、搜尋、付款與完整驗證都屬於額外設計工作,不是這個練習已完成的功能。

          重點整理

          先設計事物與關係,再選公開欄位,最後配對方法與路徑。使用 /products 等資源名詞。課堂顧客/訂單設計比已實作的第 4 週商品練習更廣。

          檢查 · 先預測再選擇

          在課堂設計中,想取得某位顧客的訂單。哪個路徑表達該關係?

          先解釋,再比較推理

          查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

          • PATCH /products — 更改商品與讀取顧客訂單集合不同。
          • GET /customers/:id/orders — 巢狀集合說明要求的是哪位顧客的訂單。
          • GET /getEverythingAndPay — 這個動作導向路徑沒有指出要求的資源關係。

          每一層只負責一件事

          第 4 週的分層六個方塊由上到下排列:路由、控制器、服務、模型、資料庫與 MongoDB。標示「請求」的箭頭沿左側往下,經過路由、控制器、服務與資料庫,進入 MongoDB。第二條箭頭沿右側往上,從 MongoDB 經過資料庫、服務與控制器,最後成為「回應」離開。路由知道哪個方法與路徑交給哪個控制器。控制器懂 HTTP:讀取 req、選擇狀態碼、送出 res。服務知道 db 與模型函式,不會接觸 req 或 res。模型是服務呼叫的輔助函式,位在這條路線旁邊,不在路線上。資料庫懂 Mongo 操作,不懂 Express,回傳的文件包含 _id。MongoDB 是資料庫程式本身,儲存的每份文件都有 _id。往上的箭頭上有一個圓圈標出模型方塊:productFromDocument() 在這裡把 _id 轉成字串 id,之後服務才回傳結果。請求回應路由routes/知道哪個方法與路徑交給哪個控制器。控制器controllers/懂 HTTP:讀取 req、選擇狀態碼、送出 res。服務services/知道 db 與模型函式。不會接觸 req 或 res。資料庫db/懂 Mongo 操作,不懂 Express。回傳文件,包含 _id。MongoDB資料庫程式本身。儲存的每份文件都有 _id。模型models/服務呼叫的輔助函式。productFromDocument()把 _id 轉成字串 id。
          請注意,請求一次往下經過一層;往回走時,_id 在圓圈處變成 id,所以上面各層都看不到 _id。

          請求依序經過路由 → 控制器 → 服務 → 資料庫;服務在回傳途中使用模型函式轉換資料。這些是同一個伺服器內的資料夾,不是五個網路服務。

          由底層往上建立:先做資料庫操作,再做模型、服務、控制器、路由器,以及應用程式/啟動流程。資料庫程式碼不需要 Express。路由只負責將路徑與中介軟體連接到控制器。

          TypeScript · 取自第 4 週講義
          const getProducts = async (req: Request, res: Response): Promise<void> => {
            const allProducts = await productService.getAll();
            res.json(allProducts);
          };
          
          const getAll = async (): Promise<Product[]> => {
            const productDocs = await db.getAllInCollection(db.PRODUCTS);
            return productDocs.map((pDoc) => productFromDocument(pDoc));
          };

          Python

          def getProducts():
              allProducts = productService.getAll()
              return jsonify(allProducts)
          def getAll():
              productDocs = db.getAllInCollection(db.PRODUCTS)
              return [productFromDocument(pDoc) for pDoc in productDocs]
          print(getProducts().get_json())  # → [{'id': '66f1a2', 'modelName': 'Kettle'}]

          TypeScript

          const getProducts = async (req: Request, res: Response): Promise<void> => {
            const allProducts = await productService.getAll();
            res.json(allProducts);
          };
          const getAll = async (): Promise<Product[]> => {
            const productDocs = await db.getAllInCollection(db.PRODUCTS);
            return productDocs.map((pDoc) => productFromDocument(pDoc));
          };

          Flask 的處理函式直接回傳回應,不必呼叫 res.json.

          檔案對照表:第 4 週原始碼檔案
          第 4 週原始碼檔案職責
          db/db.tsinit、getAllInCollection、getInCollection、addToCollection、deleteFromCollection;PRODUCTS 常數;測試用 disconnect/_clearCollection。
          models/product.tsProduct、ProductFields、productFromDocument、productFromFields。
          services/product-service.tsgetAll、get、add、remove;回傳應用程式格式的資料或不存在的結果。
          controllers/product-controllers.tsgetProducts、getProduct、addProduct、deleteProduct;決定 200/201/204/404。
          routes/product-routes.ts對應 GET 清單、POST、GET/:id、DELETE/:id;註冊驗證中介軟體。
          middleware/*id/主體驗證與最後的錯誤處理。
          app.ts / index.ts組裝解析器/路由器/備用處理/錯誤處理鏈;開始監聽前先等待資料庫 init。

          重點整理

          跟著訂單走:Route 選處理器 → Controller 處理 HTTP → Service 協調工作 → Database 查詢。Model 轉換資料。這通常是同一程式內的分工;Service 不應依賴 req/res。

          檢查 · 先預測再選擇

          查不到商品時,哪層應把它轉為 HTTP 404?

          先解釋,再比較推理

          查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

          • Controller — Service 回傳不存在;了解 HTTP 的 Controller 選擇狀態。
          • 資料庫連線工具 — 資料庫程式應回報資料與結果,不依賴 HTTP。
          • Model 型別宣告 — 結構宣告不會送出 HTTP 回覆。

          完整追蹤 GET /products/:id

          摘錄/精簡自你的第 4 週程式碼
          // Route
          productRouter.get('/:id', validateId, productControllers.getProduct);
          
          // Controller: HTTP decisions
          const product = await productService.get(String(req.params.id));
          if (!product) {
            res.status(404).json({ error: 'No product with that id' });
            return;
          }
          res.json(product);
          
          // Service: return Product | null, without choosing a status
          const doc = await db.getInCollection(db.PRODUCTS, id);
          return doc ? productFromDocument(doc) : null;
          
          // Database: string -> ObjectId before the query
          return await theDb.collection(collectionName)
            .findOne({ _id: new ObjectId(id) });

          逐行解說

          1. productRouter.get('/:id', validateId, productControllers.getProduct);路由。id 驗證會在碰到 Mongo 之前擋下格式不對的 id,接著才執行控制器。
          2. const product = await productService.get(String(req.params.id));控制器。它讀取路徑中的 id,向服務要資料,從不直接查詢 Mongo。
          3. if (!product) {服務傳回 null,表示沒有商品使用這個 id。
          4. res.status(404).json({ error: 'No product with that id' });在這裡,也只有在這裡,「不存在」變成 404:這是 HTTP 層面的決定,所以由控制器決定。
          5. res.json(product);找到了,就送出商品。
          6. const doc = await db.getInCollection(db.PRODUCTS, id);服務。它向資料庫層要這份文件。
          7. return doc ? productFromDocument(doc) : null;服務把存在的文件轉成 Product,否則傳回 null。它不決定狀態碼。
          8. .findOne({ _id: new ObjectId(id) });資料庫。字串先轉成 ObjectId 再查詢。資料庫回傳一份文件或 null。

          動手重建 · 把程式行排好

          GET /products/:id 經過的各層

          重建第 4 週 GET /products/:id 的概略。四行註解固定不動。把每一行放到它所屬的那一層底下,控制器的幾行要照執行順序排。

          顯示排好的程式碼
          // Route
          productRouter.get('/:id', validateId, productControllers.getProduct);
          // Controller: HTTP decisions
          const product = await productService.get(String(req.params.id));
          if (!product) {
            res.status(404).json({ error: 'No product with that id' });
            return;
          }
          res.json(product);
          // Service: return Product | null, without choosing a status
          const doc = await db.getInCollection(db.PRODUCTS, id);
          return doc ? productFromDocument(doc) : null;
          // Database: string -> ObjectId before the query
          return await theDb.collection(collectionName)
            .findOne({ _id: new ObjectId(id) });

          每一層只做一件事:路由把請求交下去,控制器把不存在的商品變成 404,服務轉換文件,只有資料庫層會碰 Mongo。

          Python

          # Controller: HTTP decisions
          @productRouter.get('/<id>')
          def getProduct(id):
              product = productService.get(str(id))
              if not product:
                  return jsonify(error='No product with that id'), 404  # → 404 {"error":"No product with that id"}
              return jsonify(product)

          TypeScript

          // Controller: HTTP decisions
          const product = await productService.get(String(req.params.id));
          if (!product) {
            res.status(404).json({ error: 'No product with that id' });
            return;
          }
          res.json(product);

          Flask 從處理函式回傳的第二個值讀取狀態碼。

          TypeScript · 取自第 4 週講義
          export const productFromDocument = (productDocument: Document): Product => {
            return {
              id: productDocument._id.toString(),
              modelName: productDocument.modelName,
              modelNumber: productDocument.modelNumber,
              manufacturer: productDocument.manufacturer,
              color: productDocument.color,
              price: productDocument.price,
              quantity: productDocument.quantity
            };
          };

          逐行解說

          1. export const productFromDocument = (productDocument: Document): Product => {一個一般函式:輸入 Mongo 文件,輸出 Product。它不呼叫資料庫,也沒有 req 或 res,所以可以單獨測試。
          2. id: productDocument._id.toString(),最關鍵的一行。_id 是 ObjectId,在這裡變成字串 id。用戶端拿到的是字串 id,看不到 _id。
          3. modelName: productDocument.modelName,這一行和下面五行,明確地逐一選出公開欄位。

          清單回應來自同一個轉換函式:用 .map() 轉換每一份文件就得到它。

          追蹤請求

          選擇一個情境,查看請求在哪裡結束。這是教學模擬,不會送出網路請求。

            重點整理

            依 id 取得資料:驗證格式 → 查詢 → 轉換文件 → 回覆。Service 把 _id 轉為公開 id 字串,不存在則回傳 null。只有 Controller 選 HTTP 狀態。

            檢查 · 先預測再選擇

            第 4 週文件包含 _id 與公開欄位。客戶端應收到什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 只回傳 HTTP 狀態,絕不回傳商品資料 — 成功 GET 在主體回傳資源表示。
            • 包含字串 id 與指定公開欄位的 Product — productFromDocument 轉換儲存身分,並選出公開結構。
            • 帶有所有儲存欄位的原始文件 — 第 4 週引入明確公開表示,避免洩露儲存結構。

            追蹤建立與刪除流程

            POST 也經過相同分層。express.json 解析 req.body,驗證器檢查提供的值,服務準備商品欄位,Mongo 插入文件並產生 _id,服務再將 insertedId 轉成 id 字串。用該 id 讀回商品,確認實際儲存狀態。

            TypeScript · 取自第 4 週講義
            const addProduct = async (req: Request, res: Response): Promise<void> => {
              const postData = req.body;
              const { id } = await productService.add(postData);
              res.status(201).json({ id });
            };

            ProductFields = Partial<Product> 讓每個欄位都變成選填。 productFromFields 使用以下方式提供空字串與零的預設值: ??,因此提供的零仍會保留為零。備用 id 使用 Date.now。這個 id 只是記憶體中物件的佔位值,不可以存進資料庫;真正的 id 由 Mongo 產生。講義將 InsertOneResult 傳給服務,刻意讓少量 Mongo 型別資訊進入服務,並非完全封裝資料庫。

            本機第 4 週服務會選取已知欄位,並在插入前移除候選 id;提供的 id/_id 不會決定儲存資料的身分。它也會在儲存前填入預設值。課堂上輸入的版本先插入呼叫端提供的欄位,之後才套用預設值;老師的課後備忘錄把講義程式碼修正成同樣的順序。主體中介軟體拒絕陣列、非物件,以及提供欄位的錯誤型別;price/quantity 必須是有限的非負數,quantity 還必須是整數。仍允許省略欄位。

            下一個主題會一步一步說明這個錯誤。

            DELETE 的資料庫回傳 deletedCount,服務再回報布林值。第二次 DELETE 回傳 404。講義允許成功時使用帶主體的 200 或無主體的 204;本機第 4 週使用 204,第 3 週則使用 200。

            TypeScript · 為本指南撰寫,以 Node 24 實際執行
            const deleteProduct = async (req: Request, res: Response): Promise<void> => {
              res.sendStatus((await productService.remove(String(req.params.id))) ? 204 : 404);
            };

            Python

            @app.post('/products')
            def addProduct():
                created = productService.add(request.get_json())
                return jsonify(id=created['id']), 201  # → 201 {"id":"1"}
            @app.delete('/products/<id>')
            def deleteProduct(id):
                return ('', 204) if productService.remove(id) else ('', 404)  # → 204, then 404 on a second DELETE

            TypeScript

            const addProduct = async (req: Request, res: Response): Promise<void> => {
              const postData = req.body;
              const { id } = await productService.add(postData);
              res.status(201).json({ id });
            };
            const deleteProduct = async (req: Request, res: Response): Promise<void> => {
              res.sendStatus((await productService.remove(String(req.params.id))) ? 204 : 404);
            };

            重點整理

            POST 建立並回傳 201/{id},再讀取確認儲存。第 4 週 DELETE 回傳 204、無主體;重複刪除是 404。本機程式驗證提供的欄位,讓 Mongo 建立儲存身分。

            檢查 · 先預測再選擇

            第 4 週商品已成功刪除。同一 id 再 DELETE,應回傳什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 201,附上替代 id — DELETE 不會建立替代資源。
            • 204,附上 JSON 成功主體 — 204 沒有主體,且這次重複刪除的目標已不存在。
            • 404 — 資源現在不存在,保存的第 4 週約定如此回報。

            一個錯誤的故事:只存了一半欄位的商品

            第 4 週下課後,老師發了一份備忘錄,說明全班一起輸入的 POST /products 程式碼裡有一個錯誤。每個函式都做了名稱所說的事,商品還是可能存錯。

            這個故事有兩個問題。修正第一個問題會帶出第二個,所以請依序閱讀。

            程式碼依序做了什麼

            add() 是建立商品的服務函式。這是課堂上寫的版本。

            TypeScript · 課堂上寫的 services/product-service.ts,取自備忘錄
            const add = async (productInfo: ProductFields): Promise<Product> => {
              const { insertedId } = await db.addToCollection(db.PRODUCTS, productInfo);
              return productFromFields({ ...productInfo, id: insertedId.toString() });
            };
            1. 函式裡的第一行把 productInfo 直接交給資料庫。這個值就是呼叫端送來的原樣,所以呼叫端沒給的欄位仍然不存在。
            2. 第二行呼叫 productFromFields(),這個模型函式會替每個缺少的欄位填入預設值:文字填空字串,數字填零。

            錯就錯在順序。預設值填進了交還給呼叫端的值,從來沒有進到儲存的文件。

            實際存了什麼

            備忘錄用一個只有四個欄位的商品來測試,省略了 modelNumber 與 quantity。

            TypeScript · 備忘錄測試中的請求主體
            const partialProduct = {
              modelName: 'My Model',
              manufacturer: 'My Manufacturer',
              color: 'green',
              price: 5.5
            };
            // no modelNumber, no quantity

            使用課堂版本的 add() 時,資料庫裡最後存的是這份文件。_id 欄位由 Mongo 產生。

            儲存的文件,課堂版本 · 改編自備忘錄的表格,未顯示 _id 的值
            {
              _id: ObjectId('...'),
              modelName: 'My Model',
              manufacturer: 'My Manufacturer',
              color: 'green',
              price: 5.5
            }

            儲存的文件沒有 modelNumber,也沒有 quantity。資料庫留下的是只填了一半的商品。

            為什麼回應看起來沒問題

            備忘錄列出課堂上沒人發現這個錯誤的三個原因。

            • 控制器只回傳 id,寫法是 res.status(201).json({ id })。productFromFields() 填入的所有內容,在下一行就被丟掉了。只有 id 這一部分是對的。
            • 課堂上送出的每個商品都是完整的。沒有缺少的欄位,就不需要預設值,錯誤也就不會出現。
            • 用 Postman 也抓不到。你得先送出一個不完整的商品,再到 Compass 開啟那份文件,並注意到少了欄位。

            第一個修正:先填入預設值,再插入

            TypeScript · 修正後的順序,取自備忘錄
            const product = productFromFields(productInfo);
            const { insertedId } = await db.addToCollection(db.PRODUCTS, product);

            現在 productFromFields() 先執行,插入的是填好的 product。呼叫端省略的欄位會以預設值儲存。

            佔位用的 id

            第一個修正帶來一個比較不明顯的問題。看看 productFromFields() 在沒有收到 id 時做了什麼。

            TypeScript · productFromFields() 的其中一行,取自備忘錄
            id: fields.id ?? String(Date.now()),

            它用目前時間編出一個 id。對記憶體中的物件來說沒有問題。但第一個修正把整個物件交給 Mongo,所以這個編出來的 id 也被存了進去。

            第一個修正後儲存的文件 · 改編自備忘錄的表格,未顯示 _id 的值
            {
              _id: ObjectId('...'),
              id: '1790716131668',
              modelName: 'My Model',
              modelNumber: '',
              manufacturer: 'My Manufacturer',
              color: 'green',
              price: 5.5,
              quantity: 0
            }

            兩個 id,只有一個是真的。_id 是 Mongo 產生的。id 欄位只是佔位值,根本不該被儲存。

            透過 API 永遠看不到它。資料送出時,productFromDocument() 用 _id 產生回應的 id,不會讀取儲存的 id 欄位。只有在 Compass 開啟文件才看得到。

            第二個修正:儲存欄位,不儲存 id

            備忘錄示範了兩種寫法。第一種列出要儲存的六個欄位,其中沒有 id。

            TypeScript · 修正後的 add(),取自備忘錄
            const add = async (productInfo: ProductFields): Promise<string> => {
              const product = productFromFields(productInfo);
            
              const { insertedId } = await db.addToCollection(db.PRODUCTS, {
                modelName: product.modelName,
                modelNumber: product.modelNumber,
                manufacturer: product.manufacturer,
                color: product.color,
                price: product.price,
                quantity: product.quantity
              });
            
              return insertedId.toString();
            };

            第二種比較短,儲存的是同樣六個欄位。

            TypeScript · 精簡版本,取自備忘錄
            const add = async (productInfo: ProductFields): Promise<string> => {
              const product = productFromFields(productInfo);
              const { id, ...productWithoutId } = product;
              const { insertedId } = await db.addToCollection(
                db.PRODUCTS,
                productWithoutId
              );
              return insertedId.toString();
            };

            把這一行拆開來,一段一段讀:const { id, ...productWithoutId } = product;

            • const 宣告不能重新指定的新名稱。
            • { } 出現在等號左邊時是解構:把物件拆開,放進各個變數。
            • id 把名為 id 的欄位取出,放進它自己的變數。
            • ... 是其餘元素。它收集前面沒有點名的所有欄位。
            • productWithoutId 是新物件的名稱,用來接收收集到的欄位。
            • = product 指出被拆開的是哪個物件。

            這一行執行後,productWithoutId 含有六個商品欄位。變數 id 存著佔位值,之後不再使用。第 2 週的 PATCH 路由用過同樣的寫法,把 id 和其他欄位分開。

            備忘錄說兩種版本都可以。長的版本寫明儲存什麼,精簡版本寫明排除什麼。

            只回傳 id

            兩個修正版本的最後一行都是 return insertedId.toString()。宣告的回傳型別現在是 Promise<string>,課堂版本則是 Promise<Product>。控制器只需要 id,所以 add() 只回傳文字形式的 id。控制器那一行也就不需要大括號了。

            TypeScript · controllers/product-controllers.ts,取自備忘錄
            const id = await productService.add(postData);   // was: const { id } = ...

            動手重建 · 把程式行排好

            修正後的 add()

            重建備忘錄裡修正後的 add(),採用精簡版本。把程式行排好,讓預設值在儲存之前就填好,而且佔位用的 id 不會被存進去。

            顯示排好的程式碼
            const add = async (productInfo: ProductFields): Promise<string> => {
              const product = productFromFields(productInfo);
              const { id, ...productWithoutId } = product;
              const { insertedId } = await db.addToCollection(
                db.PRODUCTS,
                productWithoutId
              );
              return insertedId.toString();
            };

            先填預設值,再去掉佔位用的 id,接著插入,最後回傳 id:Mongo 存下的是完整的商品,而且只有一個 id,也就是它自己的 _id。

            同一個商品的三種儲存結果

            add() 的版本Mongo 儲存的內容
            課堂上寫的版本_id 與提供的四個欄位。沒有 modelNumber 與 quantity。
            第一個修正後所有欄位,另外在 _id 旁邊多了一個佔位用的 id。
            兩個修正都完成後所有欄位,而且只有一個 id:_id。

            會把資料讀回來的測試能抓到什麼

            備忘錄的測試先送出不完整的商品,再透過 GET /products 讀回清單,用 id 找出新商品。

            Vitest + Supertest · 備忘錄中的測試
            const created = await request(app).post('/products').send(partialProduct);
            const all = await request(app).get('/products');
            const stored = all.body.find((p) => p.id === created.body.id);
            
            expect(stored.modelName).toBe(partialProduct.modelName); // ← passed
            expect(stored.price).toBe(partialProduct.price);         // ← passed
            expect(stored.modelNumber).toBe('');   // ← failed: got undefined
            expect(stored.quantity).toBe(0);       // ← would fail but never got here

            前兩個 expect 通過。第三個失敗:modelNumber 讀回來是 undefined,不是空字串。第一個錯誤就是這樣被發現的。

            只檢查 POST 回應的測試會通過,因為狀態碼和 id 都是對的。把資料讀回來,才問到了呼叫端省略的欄位。

            這次讀回是透過 API,所以看不到佔位用的 id。它只出現在儲存的文件裡,因此備忘錄要你到 Compass 查看。

            任務在課堂上改了

            備忘錄也記錄了最後一個 Now You Try 在課堂剩下約十五分鐘時改了內容。修改後的任務就是上面的測試:驗證送到 POST /products 的不完整商品有正確插入,提供的欄位都在,其餘欄位則是 productFromFields() 的預設值。第 4 週筆記描述的仍是原本的任務,也就是 GET /products/:id 的兩個測試。

            重點整理

            add() 先存了呼叫端送來的內容,再把預設值填進控制器丟棄的值。應該先填預設值,並且不要把佔位用的 id 存進文件。回應只有 id,所以只有把資料讀回來才看得出錯誤。

            檢查 · 先預測再選擇

            課堂版本的 add() 先插入 productInfo,再呼叫 productFromFields()。用戶端送出沒有 quantity 的商品,得到 201 與 id。儲存的文件裡有什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 完全沒有 quantity 欄位 — 插入先執行,存的就是呼叫端送來的原樣。預設值是之後才套用的,而且套用在沒有被儲存的值上。
            • quantity: 0,因為 productFromFields() 會補上預設值 — productFromFields() 確實會補上 0,但它在插入之後才執行。結果回到呼叫端,沒有進資料庫。
            • 所有欄位,外加一個用時間產生的第二個 id — 那是第一個修正後的文件:填好的商品連同佔位用的 id 一起被插入。課堂版本在插入前什麼都沒填。

            第 4 週全部任務,包含撰寫測試的核心練習

            任務必須理解的內容/證據
            講義 · GET /products將 products.json 匯入 week4/products;建立所有分層;取得兩台咖啡機,包含公開的 id 欄位。
            講義 · POST /products在 Postman 送出 raw JSON;取得 201/{id};清單增加為三筆;在 Compass 確認儲存的文件。
            NYT #1 · GET /products/:id新增資料庫/服務/控制器/路由操作。模型轉換器已存在。找到 id → 200 與單一物件;合法但不存在的 id → 404;id 為字串,不包含 _id。
            NYT #1 延伸 · DELETE將刪除流程貫穿各層;選擇成功回應;清單縮短,Compass 的資料也一致。
            講義 · Supertest 設定讓 init 接受 URI/資料庫名稱;暫時 Mongo 只啟動一次,每個測試前清空並填入初始資料,結束後斷線並停止。
            講義 · 清單/建立斷言恰好兩筆初始資料、預期的製造商與順序、id 格式、不含 _id;POST 後清單有三筆,並包含新的製造商。
            筆記中的 NYT #2 · 撰寫測試describe GET /products/:id:200 與指定物件;格式正確但不存在的 id 為 404。從初始清單取得現有 id;使用 toEqual 比較物件。
            課堂上修改後的 NYT #2 · 不完整商品測試送出省略部分欄位的商品,用回傳的 id 在 GET /products 找到它,並斷言提供的值與預設值。課後備忘錄記錄了這項變更。
            NYT #2 延伸 · DELETE 測試刪除一筆;斷言成功,接著清單長度為一。不存在的 id → 404。操作與驗證分成不同請求。
            哪一層應將找不到文件轉成 HTTP 404?

            控制器。資料庫與服務回傳不存在的結果,控制器將它轉成 HTTP 契約要求的回應。

            重點整理

            商品操作需串接所有層。測試存在/不存在 id、公開欄位、建立後讀取及刪除後讀取。從種子資料取得存在 id;使用受控的不存在 id,不猜課堂示範 id。

            檢查 · 先預測再選擇

            GET-by-id 測試如何可靠取得存在的 id?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 複製投影片上任意 id — 示範 id 不一定存在於此測試資料庫。
            • 把所有 24 字元十六進位字串當成存在記錄 — 格式與存在是不同檢查。
            • 讀取已知種子清單,使用回傳的 id — 測試資料確立記錄,回應提供實際產生的身分。

            來源依據 · 完整的第 4 週提供筆記(含「Write the tests」)、第 4 週課後備忘錄、本機原始碼與測試修訂,以及第 4 週讀書計畫。

            07

            第 2–4 週與 HW1 · Vitest、Supertest、資料庫隔離與比對器

            讓驗證證據可以重現。

            三個工具,三項工作

            本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章在伺服器下方加上測試方塊。測試像用戶端一樣,把請求送到路由,並檢查回應。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫測試送出請求,讀取回覆本章
            本章加上測試方塊:測試像用戶端一樣,把請求送到伺服器,再檢查回應。

            標出地圖 · 先點名稱,再點空格

            測試站在哪裡

            把每個名稱放到地圖上它該在的位置。

            名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。用戶端瀏覽器、Postman、curl1?2?伺服器3?中介軟體控制器服務4?送出請求,讀取回覆5?

            這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

            工具工作它無法證明什麼
            Vitest執行 describe/it 測試、生命週期掛鉤、expect 比對器與 mock。測試套件通過,只能涵蓋其中實際寫出的斷言。
            Supertest對匯出的 Express 應用程式建立 HTTP 請求;檢查 status/body/text。匯入 app 不會執行 index.ts 的啟動流程,也不會測試真正的固定連接埠伺服器。
            MongoMemoryServer在獨立 URI 啟動真正的暫時 mongod,用於資料庫測試。它不是 Compass/手動操作所需的持久本機 Mongo 伺服器。

            測試專用 URI/資料庫將測試資料與一般 week3app/week4 紀錄分開。MongoMemoryServer 是真正的資料庫程序,不是記憶體中的 JavaScript mock。暫時儲存行為取決於 Mongo/儲存設定;隔離來自測試程式連到暫時實例。隨機連接埠不是存取控制的安全界線。

            第一次執行可能下載 Mongo 執行檔;之後使用快取。執行這些測試套件不必啟動一般 mongod 或 npm run dev,但設定或下載錯誤仍可能讓測試無法執行。使用一般持久資料庫的測試,可能在多次執行間累積重複插入。暫時實例解決不同執行間的殘留;清空/重設掛鉤則解決同一次執行中測試之間留下的狀態。

            重點整理

            Vitest 執行測試與斷言;Supertest 向匯出的 App 送請求;MongoMemoryServer 提供真正的暫時 Mongo 程序。像裁判、練習客戶端與隔離場地,不能取代所有正式環境檢查。

            檢查 · 先預測再選擇

            Supertest 對匯入 App 的測試通過。哪個主張仍未證明?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 受測請求得到斷言指定的狀態 — 狀態斷言通過,能證明該特定受測請求。
            • 測試工具執行了該通過的斷言 — 這是觀察到的測試結果之一,與真正啟動不同。
            • index.ts 能搭配平常資料庫成功啟動 — 測試可提供另一連線,而不執行真正啟動流程。

            啟動一次;每次重設;停止一次

            第 4 週生命週期 · 整合教學概略
            let mongo: MongoMemoryServer;
            
            beforeAll(async () => {
              mongo = await MongoMemoryServer.create();
              await db.init(mongo.getUri(), 'week4-tests');
            });
            
            beforeEach(async () => {
              await db._clearCollection(db.PRODUCTS);
              for (const product of twoCoffeeMakers) {
                await db.addToCollection(db.PRODUCTS, product);
              }
            });
            
            afterEach(() => {
              vi.restoreAllMocks(); // when a test temporarily replaced behavior
            });
            
            afterAll(async () => {
              await db.disconnect();
              await mongo.stop();
            });

            逐行解說

            1. let mongo: MongoMemoryServer;宣告在掛鉤外面,讓 beforeAll 可以存入、afterAll 可以使用。
            2. beforeAll(async () => {只執行一次,在第一個測試之前。
            3. mongo = await MongoMemoryServer.create();啟動一個暫時的 MongoDB。這一步很花時間,所以只做一次。
            4. await db.init(mongo.getUri(), 'week4-tests');把應用程式的資料庫程式碼連到那個暫時伺服器,使用只有測試會用的資料庫。
            5. beforeEach(async () => {每個測試之前都會執行。
            6. await db._clearCollection(db.PRODUCTS);清空 products 集合,前一個測試留下的東西就不見了。
            7. for (const product of twoCoffeeMakers) {直接透過資料庫,把兩台已知的咖啡機逐一存入。每個測試都從同樣的兩項商品開始。
            8. vi.restoreAllMocks(); // when a test temporarily replaced behavior每個測試之後,還原測試用 mock 替換掉的行為。
            9. afterAll(async () => {只執行一次,在最後一個測試之後:先中斷連線,再停止暫時的 MongoDB。

            動手重建 · 把程式行排好

            每個測試前後的掛鉤

            重建第 4 週的測試設定。掛鉤本身的幾行固定不動。把每一行放進該執行它的掛鉤裡,並照該有的執行順序排。

            顯示排好的程式碼
            let mongo: MongoMemoryServer;
            beforeAll(async () => {
              mongo = await MongoMemoryServer.create();
              await db.init(mongo.getUri(), 'week4-tests');
            });
            beforeEach(async () => {
              await db._clearCollection(db.PRODUCTS);
              for (const product of twoCoffeeMakers) {
                await db.addToCollection(db.PRODUCTS, product);
              }
            });
            afterAll(async () => {
              await db.disconnect();
              await mongo.stop();
            });

            啟動和連線只做一次,每個測試之前先清空再放入初始資料,最後照相反的順序關閉:每個測試都從同樣的兩項商品開始,結束後也不會留下還在執行的東西。

            Python

            @pytest.fixture(scope='module', autouse=True)
            def database():  # beforeAll, then afterAll
                db.init(mongo.getUri(), 'week4-tests')
                yield
                db.disconnect()
            @pytest.fixture(autouse=True)
            def clean():  # beforeEach
                db._clearCollection(db.PRODUCTS)

            TypeScript

            beforeAll(async () => {
              await db.init(mongo.getUri(), 'week4-tests');
            });
            
            beforeEach(async () => {
              await db._clearCollection(db.PRODUCTS);
            });

            pytest 的測試夾具把設定與清理放在同一個函式裡:yield 之後的程式碼,執行時機就是 afterAll 的時機。

            測試的生命週期一條由上到下的時間軸,分成兩欄:Vitest 執行的內容,以及暫時資料庫的狀態。首先,beforeAll 在開始時執行一次:暫時資料庫只啟動一次,內容是空的。接著是測試 1:beforeEach 執行,資料庫先清空,再加入 2 項商品;測試 1 執行,可能改動資料庫;afterEach 執行並還原 mock。然後是測試 2,同樣的三個步驟:beforeEach 清空資料庫並再次加入 2 項商品,測試 2 執行,afterEach 執行。每個測試都重複同樣的三個步驟。最後,afterAll 在結束時執行一次,資料庫停止。Vitest 執行暫時資料庫beforeAll開始時執行一次只啟動一次,內容是空的beforeEach每個測試之前先清空,再加入 2 項商品測試 1執行一個測試可能被改動afterEach還原 mockbeforeEach每個測試之前先清空,再加入 2 項商品測試 2執行一個測試可能被改動afterEach還原 mock每個測試都重複同樣的三個步驟。afterAll結束時執行一次停止
            請注意,資料庫只啟動一次、停止一次,但每個測試之前都會清空並重新填入資料,所以測試 2 看不到測試 1 改動的內容。
            掛鉤時機原因
            beforeAll測試套件/區塊開始前執行一次啟動資料庫與連線成本較高。
            beforeEach每個測試之前已知狀態:重設,或清空後填入初始資料。
            afterEach每個測試之後還原 mock 或只為單一測試進行的變更。
            afterAll測試套件/區塊結束後執行一次關閉用戶端並停止程序。

            第 2 週重設目錄陣列;HW1 重設訂單;第 3 週從空的 products 集合開始;第 4 週清空後插入兩台已知咖啡機。第 4 週應直接向資料庫填入初始資料,避免有問題的 POST 被誤認為有問題的 GET。

            每個測試前都啟動 Mongo 成本很高。只填一次初始資料則會讓之後測試繼承變更。非同步設定與請求都要 await,讓斷言在工作完成後執行。

            重點整理

            昂貴資源只啟動一次。每次測試前清除並載入已知資料;最後斷線並停止自己啟動的資源。等待設定與請求完成。隔離避免前一測試變更影響下一次。

            檢查 · 先預測再選擇

            哪個 Hook 應在每次第 4 週測試前還原兩筆商品資料?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 只有 afterAll — 最後清理無法替每次測試準備資料。
            • beforeEach — 每次測試都需要自己的已知起始狀態。
            • 只有 beforeAll — 只在開始載入一次,後續測試會繼承前面的變更。

            選擇能問對問題的比對器

            比對器問題用途/陷阱
            toBe(200)這是相同的值嗎?適合基本型別的狀態碼/字串/布林值;物件身分相同與內容相等是兩回事。
            toEqual({...})巢狀內容相等嗎?適用於物件/陣列;陣列順序會影響結果。
            toStrictEqual({...})把值為 undefined 的鍵與物件的種類都算進去,內容相等嗎?比 toEqual 嚴格:值為 undefined 的鍵也算數,陣列裡的空位與 undefined 不同,類別的實例也不等於欄位相同的一般物件。本課程的測試直接呼叫服務函式,而且殘留一個值為 undefined 的鍵(例如 _id)就該失敗時,才需要它;從 JSON 解析出來的主體不會有這種鍵。
            toHaveLength(2)有多少元素/字元?檢查陣列或字串長度。
            toContain("Braun")這個值存在嗎?檢查基本型別的成員或子字串;新建立的物件使用 toContainEqual。
            .not.toContain("REVOTRA")這個值不存在嗎?要搭配正向斷言;錯誤的空清單也能通過只檢查不存在的測試。
            toMatch(/^[0-9a-f]{24}$/)字串符合要求的格式嗎?自動產生的 id 應測試格式,不要比對無法預測的精確值。
            toBeUndefined()屬性/值是 undefined 嗎?第 4 週公開商品不應暴露 _id。
            toBeNull()這是 null 嗎?服務/資料庫沒有相符結果時使用。
            toBeDefined() / toBeTruthy()存在/為真值嗎?這些檢查比確認精確內容或身分更寬鬆。
            toThrow()函式會拋出錯誤嗎?傳入函式。
            TypeScript · 為本指南撰寫,以 Vitest 5.0 實際執行
            import { expect, it } from 'vitest';
            const load = async () => { throw new Error('No product with that id'); };
            
            it('asks the right question', async () => {
              const res = { status: 200, body: [{ a: 1 }, { a: 2 }] };
              expect(res.status).toBe(200);
              expect(res.body[0]).toEqual({ a: 1 });
              expect(res.body).toHaveLength(2);
              await expect(load()).rejects.toThrow('No product');
            });

            Python

            def test_asks_the_right_question():
                res = {'status': 200, 'body': [{'a': 1}, {'a': 2}]}
                assert res['status'] == 200
                assert res['body'][0] == {'a': 1}
                assert len(res['body']) == 2
                with pytest.raises(Exception, match='No product'):
                    load()  # → 1 passed

            TypeScript

            it('asks the right question', async () => {
              const res = { status: 200, body: [{ a: 1 }, { a: 2 }] };
              expect(res.status).toBe(200);
              expect(res.body[0]).toEqual({ a: 1 });
              expect(res.body).toHaveLength(2);
              await expect(load()).rejects.toThrow('No product');
            });

            expect({a:1}).toBe({a:1}) 會失敗:這是兩個分別建立的物件。Python 的 is 問的是 toBe 的問題;== 問的是 toEqual 的問題。透過 HTTP 測試時,拋出錯誤的路由會回應 500;rejects.toThrow 適合直接呼叫,例如 load().

            重點整理

            提出精確問題:toBe 比原始值或身分;toEqual 比物件內容;toHaveLength 比數量;toMatch 比格式。正反斷言一起用,因為錯誤空清單也能通過只檢查缺項的測試。

            檢查 · 先預測再選擇

            兩個分別建立的物件都含 {a:1}。哪個 Matcher 比較內容?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • toHaveLength — 它檢查長度,不比較巢狀物件內容。
            • toEqual — 深層內容相等,不要求相同物件身分。
            • toBe — 對物件而言,這檢查是否為同一參照。

            第 4 週「撰寫測試」任務的示範測試

            Vitest + Supertest · 假設已完成第 7 章設定與兩筆初始資料
            describe('GET /products/:id', () => {
              it('returns the requested product', async () => {
                const list = await request(app).get('/products');
                expect(list.status).toBe(200);
                expect(list.body).toHaveLength(2);
                const expected = list.body[0];
            
                const res = await request(app).get(`/products/${expected.id}`);
                expect(res.status).toBe(200);
                expect(res.body).toEqual(expected);
                expect(res.body._id).toBeUndefined();
              });
            
              it('returns 404 for an absent, well-formed id', async () => {
                const res = await request(app).get(`/products/${'a'.repeat(24)}`);
                expect(res.status).toBe(404);
              });
            });

            逐行解說

            1. describe('GET /products/:id', () => {把同一個端點的測試歸在一個名稱底下。
            2. it('returns the requested product', async () => {一個測試。文字說明應該成立的事,函式負責檢查。
            3. const list = await request(app).get('/products');Supertest 對應用程式送出 GET /products,並等待回應。不需要手動啟動伺服器。
            4. expect(list.status).toBe(200);一個斷言:狀態碼必須是 200。
            5. expect(list.body).toHaveLength(2);清單裡只有那兩筆初始資料。
            6. const expected = list.body[0];留下第一項商品,讓測試使用這次執行中真正存在的 id。
            7. const res = await request(app).get(`/products/${expected.id}`);執行:用它的 id 要求取得那一項商品。
            8. expect(res.body).toEqual(expected);單獨取回的商品,內容必須和清單裡的那一項相同。
            9. expect(res.body._id).toBeUndefined();公開的商品不可以露出 _id。
            10. const res = await request(app).get(`/products/${'a'.repeat(24)}`);第二個測試要求一個格式正確、但沒有任何初始資料使用的 id。下一行預期得到 404。

            動手重建 · 把程式行排好

            一個 Supertest 測試:先操作,再斷言

            重建第一個示範測試。把程式行排好:先準備測試需要的東西,再用一個請求操作,然後先斷言狀態碼,再斷言主體。

            顯示排好的程式碼
              it('returns the requested product', async () => {
                const list = await request(app).get('/products');
                expect(list.status).toBe(200);
                expect(list.body).toHaveLength(2);
                const expected = list.body[0];
                const res = await request(app).get(`/products/${expected.id}`);
                expect(res.status).toBe(200);
                expect(res.body).toEqual(expected);
                expect(res.body._id).toBeUndefined();
              });

            準備、操作、斷言:每一行都用到上面某一行的值,而且先檢查狀態碼,再檢查主體。兩個主體檢查互不相依,誰先誰後都可以。

            DELETE 延伸 · 操作加上獨立讀回驗證
            it('deletes one product and proves it is gone', async () => {
              const list = await request(app).get('/products');
              const id = list.body[0].id;
            
              const deleted = await request(app).delete(`/products/${id}`);
              expect(deleted.status).toBe(204); // Week 4 local contract
              expect(deleted.text).toBe('');
            
              const remaining = await request(app).get('/products');
              expect(remaining.body).toHaveLength(1);
              expect(remaining.body.map((p: Product) => p.id)).not.toContain(id);
            
              const missing = await request(app).get(`/products/${id}`);
              expect(missing.status).toBe(404);
            });

            準備 → 操作 → 斷言。 掛鉤準備已知初始資料;請求執行操作;expect 斷言結果。PATCH 要比較修改的欄位與未修改的欄位。POST 要確認 201/id,再讀回資料。DELETE 要檢查回應與之後的不存在結果。只測狀態碼,可能漏掉「回報成功但實際沒改資料」的伺服器。

            第 4 週有個真實案例:POST 回傳 201 與正確的 id,儲存的商品卻少了預設值。要斷言實際儲存的內容,不要只斷言回應。

            第 4 週的基本清單測試檢查恰好兩筆商品、預期的製造商順序、不含 REVOTRA、id 為 24 個十六進位字元,以及不含 _id。POST 加入 REVOTRA 後,清單增加為三筆。閱讀測試資料,理解這些確切數量與名稱的來源。

            重點整理

            準備已知資料 → 用請求執行 → 斷言回應與儲存效果。只有成功狀態可能掩蓋未發生的寫入。POST、PATCH 與 DELETE 都需獨立讀取確認。

            檢查 · 先預測再選擇

            DELETE 回傳預期成功狀態。再做什麼能抓出假刪除?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 之後讀取,確認商品已不存在 — 獨立讀取驗證狀態變更,而非只驗證回覆。
            • 不再請求,只斷言同一狀態兩次 — 重複同一觀察無法增加儲存狀態證據。
            • 檢查回應很快 — 速度無法證明目標記錄已刪除。

            讀懂失敗;理解通過的意義

            先看失敗測試名稱,以及預期值與實際值。斷言失敗代表程式至少已執行到能比較行為的階段。連線/下載/匯入錯誤是設定問題,可能讓任何請求都無法執行。逾時可能來自未 await 的操作、等待不存在的 Mongo 伺服器,或既不回應也不繼續的請求處理函式。

            測試通過不代表 TypeScript 能編譯、真正的啟動流程會等待 Mongo、匯入的範例資料存在,或老師已經評分。檢查完整流程時,另外執行型別檢查/建置指令,並手動啟動應用程式。Classroom 的佔位檢查不能當成 API 迴歸測試套件。

            第 5 週從這裡接著講:模擬依賴的部分,測試規則本身。

            為什麼所有 Supertest 測試都通過,npm run dev 卻失敗?

            測試匯入 app,並提供自己的資料庫連線,從不執行 index.ts。因此啟動設定錯誤、一般資料庫無法連線,或啟動時缺少 await,都可能在測試中看不出來。

            重點整理

            先讀預期與實際值。設定錯誤可能讓請求尚未執行就停止。通過只證明覆蓋的斷言;需另外檢查編譯、啟動、實際資料與手動流程。教師評分是另一界線。

            檢查 · 先預測再選擇

            所有 API 測試都通過。能斷定教師給了滿分嗎?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 不能;本機測試證據與教師評分不同 — 測試通過不代表已提交,也不證明外部評分結果。
            • 能;通過保證所有評分項目 — 測試只覆蓋自己的斷言,可能漏掉評分要求。
            • 能;TypeScript 型別就是正式成績 — 編譯與課程評分是不同證據來源。

            來源依據 · 第 2 週/HW1 測試、第 3/4 週完整測試章節,以及兩組 Mongo 測試設定。

            7+

            第 5 週 · 分層、一個 PATCH 請求、測試種類、Vitest 與模擬函式

            模擬依賴的部分,測試規則本身。

            四層,四項工作

            第 5 週從完成的第 4 週 products API 出發,整理過,也換了幾個名字。有一項工作搬了家:把 MongoDB 文件轉成 Product,現在由資料存取層負責,不再是模型。這樣服務手上只剩 Product,所以它可以單獨測試。

            本章在哪裡用戶端送出請求給伺服器。請求經過路由、中介軟體、控制器與服務;服務使用資料庫,回應再送回用戶端。本章標示的部分:服務,它會被真正測試;以及一個標著「模擬」的虛線框,圍住服務下方的資料存取層與資料庫,單元測試會把它們替換掉。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫模擬資料存取層與資料庫本章
            本章真正測試服務本身,並用模擬函式替換掉服務下方的資料存取層與資料庫。

            標出地圖 · 先點名稱,再點空格

            單元測試替換掉什麼

            把每個名稱放到地圖上它該在的位置。

            名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。用戶端瀏覽器、Postman、curl請求回應伺服器1?2?3?4?5?

            這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

            筆記列出的範例程式 src 資料夾:

            src/ · 取自第 5 週筆記
            src/
            ├── db/            db.ts, products-repository.ts
            ├── models/        product.ts
            ├── services/      products-service.ts
            ├── controllers/   products-controller.ts
            ├── routes/        products-router.ts
            ├── middleware/    error-handler.ts
            ├── __tests__/     products.e2e.test.ts, test-products.ts
            ├── errors.ts
            ├── mocks-and-spies.unit.test.ts
            ├── app.ts
            └── index.ts
            部分它的工作它知道什麼
            routes/products-router.ts把每個方法與 URL 交給一個控制器函式。HTTP 方法與路徑
            controllers/products-controller.ts讀取請求、呼叫服務、決定狀態碼、送出回應。HTTP:req 與 res
            services/products-service.ts套用商業規則,例如「數量永遠不能是負數」。進來是 Product,出去也是 Product。只知道 Product
            db/products-repository.ts讀寫 MongoDB,並在文件與 Product 之間互相轉換。db.ts 負責連線。MongoDB 與 Product
            models/product.ts宣告共用的型別:Product、NewProduct、ProductFields。只有型別
            errors.ts, middleware/error-handler.ts定義 ValidationError,並把拋出的錯誤轉成 400 或 500 回應。錯誤與 HTTP
            app.ts建立 Express 應用程式並匯出。Express
            index.ts先連上 MongoDB,再開始監聽。啟動流程
            __tests__/端對端測試與它們的初始商品。整個應用程式

            筆記修正了第 4 週的一個說法。當時課堂說服務負責在資料庫與應用程式之間轉換。更常見的做法是由資料存取層轉換,所以課程現在也這樣做:資料存取層的 toProduct 把 _id 轉成 id,資料存取層以上的程式從來看不到 ObjectId 或文件。

            你看到它應該放在
            req, res, res.status(404)控制器,或錯誤處理中介軟體。控制器以下的程式都不知道 HTTP。
            「價格不能是負數」服務。這是商業規則;HTTP、TypeScript 與 MongoDB 都不知道它。
            ObjectId, Document, $set資料存取層。MongoDB 的細節只留在這一層。
            Product 的欄位與型別模型。每一層都依照同一份定義。

            app.ts 與 index.ts 分成兩個檔案,是為了測試。app.ts 建立應用程式並匯出;index.ts 匯入它、連上資料庫,再呼叫 listen。Supertest 從 app.ts 匯入應用程式,所以測試能用到每一個路由,卻不必啟動伺服器,也不會碰到你平常的資料庫。測試會先連上它自己的暫時 MongoDB。

            重點整理

            路由挑出控制器,控制器處理 HTTP,服務掌握商業規則,資料存取層處理 MongoDB 並把文件轉成 Product。模型只有型別。app.ts 為測試建立應用程式;index.ts 負責連線與監聽。

            檢查 · 先預測再選擇

            有個新的輔助函式,要用改動的欄位組出 MongoDB 的 $set 更新。它應該放在哪一層?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 服務,因為它知道哪些欄位改了 — 服務決定什麼可以改,但 $set 是 MongoDB 的細節,服務不該看到。
            • 資料存取層,因為 $set 是 MongoDB 的細節 — MongoDB 運算子只留在跟 MongoDB 溝通的那一層,上面的各層就只需要處理 Product。
            • 控制器,因為欄位來自請求主體 — 控制器處理 HTTP;它把欄位交給服務,從不自己組資料庫更新。

            跟著一個 PATCH 請求走

            筆記跟著一個請求一路往下到 MongoDB,再回到上面:對 /products/:id 送出 PATCH,主體像是 {"price": 12.5}。每到一層,就問這一層負責什麼。

            一個 PATCH 請求,下去再回來一個 PATCH /products/:id 請求,主體是 {"price": 12.5},從用戶端往下經過路由器(productsRouter.patch)、控制器(patchProduct)、服務(updateProduct)與資料存取層,到達 MongoDB。儲存的文件再往上送回:資料存取層把它轉成 Product,交回給服務。三種結果在服務這裡分開。拿到 Product:控制器送出 200 與這項商品。拿到 null,也就是沒有商品有這個 id:控制器送出 404。拋出 ValidationError,也就是價格或數量會變成負數:錯誤處理器送出 400,什麼都不寫入。這個答覆就是送回用戶端的回應。用戶端PATCH /products/:id{"price": 12.5}請求路由器productsRouter.patch控制器patchProduct服務updateProduct資料存取層先找,再寫入MongoDBproducts 集合文件一個 Product一個 Product200 + 這項商品控制器null404 · 找不到控制器ValidationError400 · 沒有儲存錯誤處理器回應
            請注意,三種答覆都從服務開始:它回傳或拋出什麼,決定用戶端拿到 200、404 還是 400。

            路由器

            routes/products-router.ts · 取自第 5 週筆記
            productsRouter.patch('/:id', productsController.patchProduct);

            路由器掛在 /products,所以這一行把 PATCH /products/:id 交給 patchProduct,不交給任何其他函式。

            控制器

            controllers/products-controller.ts · 取自第 5 週筆記
            export const patchProduct = async (
              req: Request,
              res: Response
            ): Promise<void> => {
              const product = await productsService.updateProduct(
                String(req.params.id),
                req.body
              );
              if (!product) {
                res.status(404).json({ error: 'No product with that id' });
                return;
              }
              res.json(product);
            };

            控制器是唯一知道 HTTP 的一層。它把服務的答覆轉成狀態碼:null 變成 404 加上錯誤訊息,Product 變成 200 加上這項商品,因為 res.json 沒有另外指定時就送 200。如果服務拋出錯誤,函式剩下的部分不會執行,Express 會把錯誤交給錯誤處理器。

            服務

            services/products-service.ts · 取自第 5 週筆記
            export const updateProduct = async (
              id: string,
              changes: ProductFields
            ): Promise<Product | null> => {
              const existing = await productsRepository.findProduct(id);
              if (!existing) {
                return null;
              }
            
              const quantity = changes.quantity ?? existing.quantity;
              const price = changes.price ?? existing.price;
            
              if (quantity < 0) {
                throw new ValidationError('quantity cannot be negative');
              }
              if (price < 0) {
                throw new ValidationError('price cannot be negative');
              }
            
              return productsRepository.updateProduct(id, changes);
            };

            把它讀成順序固定的四個步驟:

            1. 找:向資料存取層要儲存的商品。找不到就回傳 null,然後停止。
            2. 算:算出商品最後會是什麼數量與價格。
            3. 驗證:結果是負數就拋出 ValidationError。
            4. 寫入:直到這時才請資料存取層儲存改動。

            因為寫入排在最後,被拒絕的改動永遠不會進到資料庫。

            changes.quantity ?? existing.quantity 的意思是:請求有送新數量就用新的,沒有就用儲存的數量。?? 只有在左邊是 null 或 undefined時才改用右邊。這對 0 很重要:

            JavaScript · 用 Node 20 執行
            console.log(0 ?? 10);          // → 0
            console.log(undefined ?? 10);  // → 10
            console.log(null ?? 10);       // → 10
            console.log(0 || 10);          // → 10

            如果用 ||,把數量設成 0 的請求,會被當成保留舊數量來檢查。0 本身算不算合法,是商業上的決定。這個服務允許 0,因為兩個檢查只拒絕小於 0 的值。

            資料存取層

            db/products-repository.ts · 取自第 5 週筆記
            export const updateProduct = async (
              id: string,
              changes: ProductFields
            ): Promise<Product | null> => {
              const doc = await getDb()
                .collection(PRODUCTS)
                .findOneAndUpdate(
                  { _id: new ObjectId(id) },
                  { $set: changes },
                  { returnDocument: 'after' }
                );
              return doc ? toProduct(doc) : null;
            };
            部分它做什麼
            { _id: new ObjectId(id) }要哪一份文件:有這個 id 的那一份。id 字串在這裡,也就是資料存取層,變成 ObjectId。
            { $set: changes }只改有送來的欄位,其他欄位保持原樣。
            { returnDocument: 'after' }回傳更新之後的文件,而不是更新之前的。
            doc ? toProduct(doc) : null沒有文件就是 null。否則 toProduct 把它轉成 Product,_id 變成字串 id。

            筆記裡每一層的承諾:

            層承諾
            路由器PATCH /products/:id 一定會到達控制器的 patchProduct 函式。
            控制器id 不存在時回 404,否則回 200 與更新後的商品。
            服務數量與價格永遠不會變成負數;如果會,就什麼都不寫入。
            資料存取層只有送來的欄位會改變,而且改動會被儲存。
            模型每個 Product 形狀都一樣:有 id,沒有 _id。

            重點整理

            PATCH 依序經過路由器、控制器、服務、資料存取層到 MongoDB,Product 再沿原路回來。服務先找到商品,用 ?? 算出新值,驗證它們,通過才寫入。控制器把答覆轉成 200 或 404。

            檢查 · 先預測再選擇

            用戶端對一項儲存數量為 10 的商品送出 PATCH,數量是 0。服務驗證的是哪個數量?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 0,因為 ?? 只取代 null 或 undefined — 0 是真正的值,所以 ?? 保留它;而且 0 會通過,因為只有小於 0 的值會被拒絕。
            • 10,因為 0 會被當成沒送 — 那是 || 的行為。?? 把 0 當成用戶端送來的值。
            • 都不驗證,因為 0 會跳過驗證 — 每個算出來的值都會經過兩個檢查;0 只是剛好通過。

            找不到、不合法、壞掉:三種結果

            一個 PATCH 請求有三種結局,程式刻意把它們分開。

            情況服務誰回應回應
            沒有商品有這個 id回傳 null控制器404 { error: 'No product with that id' }
            價格或數量會變成負數拋出 ValidationError錯誤處理器400 { error: 'price cannot be negative' }
            任何意外,例如資料庫停擺讓錯誤直接往上傳錯誤處理器500 { error: 'Something went wrong' }

            ValidationError 是應用程式自己的錯誤型別,在 errors.ts 裡只有一行:

            errors.ts · 取自第 5 週筆記
            export class ValidationError extends Error {}

            它沒有在 Error 上加任何東西。它唯一的工作是讓人認得出來:錯誤處理器會問 err instanceof ValidationError,只有用 new ValidationError 建立的錯誤才成立,其他錯誤都不成立。

            middleware/error-handler.ts · 取自第 5 週筆記
            import type { NextFunction, Request, Response } from 'express';
            import { ValidationError } from '../errors.js';
            
            export const errorHandler = (
              err: Error,
              req: Request,
              res: Response,
              next: NextFunction
            ): void => {
              if (err instanceof ValidationError) {
                res.status(400).json({ error: err.message });
                return;
              }
              res.status(500).json({ error: 'Something went wrong' });
            };

            前幾週對每個錯誤都回 500。這裡有一種錯誤代表用戶端送來的資料不對,所以回 400,附上服務寫的訊息。其他錯誤仍然回 500,附上一則固定的訊息。另一種做法是在每個控制器裡寫 try/catch,每個都重複同樣的處理。筆記說 HW2 用的是另一種做法,所以先讀它的錯誤處理,不要直接套用這一套。

            兩種有問題的 id,不是同一個問題。'a'.repeat(24) 是 24 個十六進位字元:一個合法、但沒有任何初始商品使用的 ObjectId。它一路到達 MongoDB,什麼都沒對到,回來時是 null,所以答覆是 404。格式錯誤的 id,例如 abc,會更早失敗:資料存取層裡的 new ObjectId(id) 會拋出錯誤。除非有東西先檢查 id,這個錯誤不是 ValidationError,所以錯誤處理器回 500。用 24 個字元 id 的測試,只檢查「找不到商品」這種情況。第 3 週也把 id 的格式和它存不存在分開來看。

            重點整理

            找不到:服務回傳 null,控制器送出 404。不合法:服務拋出 ValidationError,錯誤處理器送出 400。其他情況:錯誤處理器送出 500。24 個字元、什麼都沒對到的 id 是 404,不是壞掉的 id。

            檢查 · 先預測再選擇

            一個 PATCH 要把現有商品的價格設成 -5。由誰送出回應,狀態碼是多少?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 控制器,404 — 404 是給什麼都沒對到的 id;這項商品存在。
            • 服務,用 res.status(400) — 服務從不碰 res;它拋出錯誤,交給錯誤處理器回應。
            • 錯誤處理器,400 — 服務拋出 ValidationError,錯誤處理器把這種型別對應到 400。

            單元、整合、端對端、回歸

            第 5 週在第 4 週的端對端測試旁邊,加上單元測試。每個名稱都說明了應用程式有多少部分是真的在跑。

            種類真的在跑被替換在這個 API 裡
            單元一個函式它呼叫的東西,換成測試替身真正的 updateProduct 服務函式,資料存取層的函式被替換
            整合幾個部分一起這組以外的東西真正的服務與控制器,資料存取層用模擬函式;或是真正的資料存取層,接上測試資料庫
            端對端整個應用程式,透過 HTTP只有資料庫,換成暫時的Supertest 送出 PATCH,再用 GET 把商品讀回來
            回歸上面任何一種看是哪一種測試每次改動之後,重跑所有以前通過的測試

            每一種回答不同的問題。端對端:接起來的 API 有沒有做到它的承諾?單元:在它呼叫的東西給出這些答覆時,這個函式的判斷對不對?端對端測試失敗,只說明請求到資料庫之間某處壞了,不說是哪裡;筆記的例子是一個低庫存測試,它可能壞在控制器、服務、資料存取層,或是初始資料。單元測試通過,並不代表各層有正確接在一起。兩種都需要。

            回歸不是測試的大小,而是跑測試的理由:每次有任何改動,就重跑所有以前通過的測試,讓一處的改動不會悄悄弄壞另一處。單元測試和端對端測試都屬於回歸測試。

            單元測試把函式呼叫的東西換成測試替身,也就是由測試控制的替代品。筆記用了三個詞。假函式用 vi.fn() 建立,代替一個函式,並記住每一次呼叫。模擬函式是會用你指定的值來回答的假函式。監視函式用 vi.spyOn 建立,包住一個真正的函式,記錄它怎麼被呼叫;單獨使用時,它仍然會執行真正的程式碼。對服務來說,測試替身代替的是資料存取層。

            重點整理

            單元測試只跑一個函式,並把它呼叫的東西換成測試替身。整合測試讓幾個部分一起跑。端對端測試送出真正的 HTTP,經過整個應用程式。回歸是指每次改動後把它們全部重跑:它是目的,不是大小。

            檢查 · 先預測再選擇

            服務的單元測試全部通過。它們無法回答哪個問題?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 服務會拒絕負數價格嗎? — 這正是服務單元測試會檢查的那種判斷。
            • PATCH 請求會到達服務並儲存改動嗎? — 單元測試替換了服務周圍的路由器、控制器與資料存取層,所以只有端對端測試能顯示各部分有接起來。
            • 服務只把送來的欄位交給資料存取層嗎? — 監看資料存取層函式的監視函式會記錄它收到的引數,所以單元測試能檢查這件事。

            Vitest、Supertest、MongoMemoryServer

            三個工具分工合作。這週沒有新工具;新的是把兩組測試分開跑。

            工具它在這裡的工作注意
            Vitest找出並執行測試,並提供 expect、各種掛鉤、vi.fn 與 vi.spyOn。兩組測試都由它執行。
            Supertest對 app.ts 匯出的應用程式送出請求,並交回回應。不需要連接埠,也不需要 npm run dev。
            MongoMemoryServer為端對端測試啟動一個真的、暫時的 MongoDB,結束後再停掉。第一次執行會下載 MongoDB,所以會慢一次。

            複製 NYT 儲存庫之後,筆記從這兩行開始:

            終端機 · 取自第 5 週筆記
            npm install
            npm test

            筆記裡 package.json 的測試指令:

            package.json · 取自第 5 週筆記
            "test": "vitest run",
            "test:unit": "vitest run unit.test --passWithNoTests",
            "test:e2e": "vitest run e2e.test"

            npm test 會跑全部的測試。vitest run 後面的那個字是檔名篩選條件,所以兩個指令能運作,靠的是命名慣例:單元測試放在它測試的程式碼旁邊,檔名以 .unit.test.ts 結尾;端對端測試放在 __tests__/ 裡,檔名以 .e2e.test.ts 結尾。

            --passWithNoTests:沒有任何測試檔符合時,Vitest 通常會失敗。這個旗標讓 npm run test:unit 在零個測試時也算通過,所以綠色的結果可能代表什麼都沒跑。要看輸出裡的測試數量,而不只是顏色。

            it.todo 標記一個還沒寫的測試:Vitest 把它列為 todo,不算失敗。範例程式會顯示四個。Now You Try 的任務會把它們換成真正的測試,等到沒有 todo、也沒有失敗,就完成了。

            重點整理

            Vitest 執行測試,Supertest 對匯入的應用程式送出請求,MongoMemoryServer 給端對端測試一個暫時的真 MongoDB。test:unit 與 test:e2e 依檔名挑檔案。--passWithNoTests 讓空的執行也算通過,所以要看數量。it.todo 標記還沒寫的測試。

            檢查 · 先預測再選擇

            你把 products-service.unit.test.ts 改名成 products-service.test.ts,npm run test:unit 仍然是綠色。發生了什麼事?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • Vitest 用新檔名找到這個檔案並執行了 — unit 指令只跑檔名含有 unit.test 的檔案。
            • 改名後檔案裡的測試全部通過 — 它們在 test:unit 底下根本沒跑,所以這次執行完全沒說明它們的狀況。
            • 篩選條件略過了這個檔案,所以它的測試沒有跑 — 指令依檔名裡的 unit.test 篩選;另一個單元測試檔仍然會跑而且通過,所以結果還是綠色。

            準備、執行、斷言

            這是筆記裡一切順利時的 PATCH 測試,已經放在端對端測試檔裡:

            __tests__/products.e2e.test.ts · 取自第 5 週筆記
            it('updates the field that was sent', async () => {
              const all = await request(app).get('/products');
              const { id } = all.body[0];
            
              const res = await request(app)
                .patch(`/products/${id}`)
                .send({ price: 12.5 });
            
              expect(res.status).toBe(200);
              expect(res.body.price).toBe(12.5);
            });
            1. 準備:建立條件。測試讀取清單,拿第一項商品的 id。這裡它信任 GET;GET 由其他測試檢查。
            2. 執行:做那一件要測試的事。送出 PATCH,留住回應。
            3. 斷言:檢查現在應該成立的事。狀態碼是 200,主體裡是新價格。

            每個請求都有 await,所以每個 expect 讀到的都是已經完成的回應。

            選一個沒有任何初始商品已經使用的新值。筆記要你確認 test-products.ts 裡沒有商品一開始就是 12.5。如果儲存的價格本來就是 12.5,一個什麼都沒改的 PATCH 也會通過。

            同一個測試用 pytest 寫,並標出三個部分:

            Python

            def test_updates_the_field_that_was_sent(client):
                # Arrange
                all_products = client.get('/products')
                product_id = all_products.json()[0]['id']
                # Act
                res = client.patch(f'/products/{product_id}', json={'price': 12.5})
                # Assert
                assert res.status_code == 200
                assert res.json()['price'] == 12.5

            TypeScript

            it('updates the field that was sent', async () => {
              // Arrange
              const all = await request(app).get('/products');
              const { id } = all.body[0];
              // Act
              const res = await request(app)
                .patch(`/products/${id}`)
                .send({ price: 12.5 });
              // Assert
              expect(res.status).toBe(200);
              expect(res.body.price).toBe(12.5);
            });

            pytest 會執行名稱以 test_ 開頭的每個函式,失敗的 assert 就讓測試失敗。Vitest 執行每個 it(),遇到失敗的 expect 就失敗。Python 那一邊假設有一個名為 client 的測試用戶端,用法和 FastAPI 的 TestClient 一樣;用這個用戶端在 pytest 9.1 下實際執行,測試通過。

            比對器問什麼範例
            toBe是同一個基本型別的值嗎?expect(res.status).toBe(200)
            toEqual物件或陣列的內容一樣嗎?expect(names).toEqual(['Few', 'None'])
            toHaveLength有幾個項目?expect(low).toHaveLength(0)
            toBeNull是 null 嗎?什麼都沒找到的查找
            toBeUndefined是 undefined 嗎?expect(res.body._id).toBeUndefined()
            toThrow呼叫這個函式會拋出錯誤嗎?expect(() => fn()).toThrow():傳入函式,而不是它的結果
            rejects.toThrow這個 Promise 會被拒絕嗎?await expect(promise).rejects.toThrow(ValidationError)

            expect({ price: 12.5 }).toBe({ price: 12.5 }) 會失敗:分開建立的兩個物件不是同一個物件。toEqual 會通過。

            完整的比對器表格 在測試章節裡。

            重點整理

            準備資料,執行一次,對改變的結果做斷言。用一個初始資料裡沒有的值,否則什麼都沒改的 PATCH 也會通過。toBe 比較基本型別的值,toEqual 比較內容,被拒絕的 Promise 用 await expect(...).rejects.toThrow() 檢查。

            檢查 · 先預測再選擇

            一個 PATCH 測試送出價格 20,而初始商品本來就是 20。服務壞了:它沒有寫入,直接回傳儲存的商品。測試會回報什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 通過,因為主體仍然顯示 20 — 壞掉的服務交回儲存的商品,價格本來就是 20,所以斷言分辨不出來。
            • 失敗,因為根本沒有寫入 — 測試裡沒有任何東西檢查寫入;它只比較主體裡的價格。
            • 失敗,因為 toBe 不能比較數字 — toBe 比較數字沒有問題;20 和 20 是同一個值。

            啟動一次,每個測試前重設,最後停止一次

            端對端測試檔需要真正的資料庫,所以它只啟動一次暫時的 MongoDB、連上它,並在最後停止一次:

            __tests__/products.e2e.test.ts · 取自第 5 週筆記
            let mongo: MongoMemoryServer;
            
            beforeAll(async () => {
              mongo = await MongoMemoryServer.create();
              await connect(mongo.getUri(), 'test');
            });
            
            afterAll(async () => {
              await disconnect();
              await mongo.stop();
            });

            每個測試之前,它會清空商品,再重新加入初始商品:

            __tests__/products.e2e.test.ts · 取自第 5 週筆記
            beforeEach(async () => {
              await productsRepository._clearProducts();
              for (const product of testProducts) {
                await productsRepository.insertProduct(product);
              }
            });

            啟動 MongoDB 很慢,所以只做一次。重設很快,所以每次都做。沒有重設的話,一個改了價格的測試會把改動留給下一個測試,結果就取決於哪個測試先跑:單獨跑會通過,和其他測試一起跑就失敗。beforeAll 裡的 connect 會在任何清空動作之前,先把應用程式指向暫時資料庫,所以 _clearProducts 永遠碰不到你平常用的資料。

            第 7 章逐行讀過同樣這三個掛鉤。

            重點整理

            beforeAll 啟動暫時的 MongoDB 並連線一次,beforeEach 在每個測試之前清空並重新放入商品,afterAll 則中斷連線並停止它。沒有重設,測試就會依賴執行順序。

            檢查 · 先預測再選擇

            有人把清空並重新放入資料的程式碼從 beforeEach 移到 beforeAll。會有什麼改變?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 沒有改變,因為資料無論如何都會放入 — 資料只放入一次,之後每個測試都會看到前面的測試改過的內容。
            • 測試套件變慢,因為放入資料的次數變多 — 它跑得更少,只跑一次,而不是每個測試之前都跑。
            • 一個修改商品的測試可能讓後面的測試失敗 — 測試之間沒有重設,改過的價格或刪掉的商品會留下來,結果就取決於順序。

            六個 PATCH 測試,以及各自證明了什麼

            每個測試只為一個承諾提供證據,對其他事情一概不說。相信綠燈之前,先讀最後一欄。

            測試送出證明沒有證明
            更新送出的欄位{ price: 12.5 }200,且主體裡是新價格其他欄位沒變,或任何東西有被儲存
            拒絕負數的數量{ quantity: -1 }400 與數量的錯誤訊息什麼都沒寫入
            不存在的 id 回應 404'a'.repeat(24)格式正確但找不到的 id 得到 404格式錯誤的 id 會怎樣
            A · 拒絕負數的價格{ price: -1 }400 與價格的錯誤訊息什麼都沒寫入
            C · 其他欄位維持不變原價加 1每個沒送出的欄位都保留,而且修改有被儲存任何關於被拒絕的修改的事
            D · 修改被拒絕後商品不變一個錯誤的數量和一個正確的價格什麼都沒儲存,連正確的價格也沒有是哪一層拒絕的

            第一個測試在準備、執行、斷言裡。接下來兩個在筆記裡:

            __tests__/products.e2e.test.ts · 取自第 5 週筆記
            it('rejects a change that would make quantity negative', async () => {
              const all = await request(app).get('/products');
              const { id } = all.body[0];
            
              const res = await request(app)
                .patch(`/products/${id}`)
                .send({ quantity: -1 });
            
              expect(res.status).toBe(400);
              expect(res.body.error).toBe('quantity cannot be negative');
            });
            __tests__/products.e2e.test.ts · 取自第 5 週筆記
            it('404s when the id is not in the database', async () => {
              const res = await request(app)
                .patch(`/products/${'a'.repeat(24)}`)
                .send({ price: 1.5 });
            
              expect(res.status).toBe(404);
            });

            404 測試不需要準備。'a'.repeat(24) 是一個有效的 ObjectId,但沒有任何初始商品使用它,所以它會到達 MongoDB,然後空手而回。你也可以對主體做斷言:expect(res.body.error).toBe('No product with that id');

            Now You Try #1 要你再寫三個。筆記沒有印出解答,所以這裡的版本是自己寫的。A 複製負數數量的測試,改掉兩個值:

            TypeScript · 為本指南撰寫,以 Vitest 5.0 在第 5 週起始專案上實際執行
            it('rejects a change that would make the price negative', async () => {
              const all = await request(app).get('/products');
              const { id } = all.body[0];
            
              const res = await request(app)
                .patch(`/products/${id}`)
                .send({ price: -1 });
            
              expect(res.status).toBe(400);
              expect(res.body.error).toBe('price cannot be negative');
            });

            C 一次檢查所有欄位。展開原本的商品、只替換一個欄位,就得到你預期的完整商品,所以只要任何沒送出的欄位有變動,toEqual 就會失敗。第二個請求把商品讀回來,證明修改真的被儲存,而不只是回報了。

            TypeScript · 為本指南撰寫,以 Vitest 5.0 在第 5 週起始專案上實際執行
            it('leaves the other fields alone', async () => {
              const all = await request(app).get('/products');
              const original = all.body[0];
              const newPrice = original.price + 1;
              const expected = { ...original, price: newPrice };
            
              const res = await request(app)
                .patch(`/products/${original.id}`)
                .send({ price: newPrice });
              expect(res.status).toBe(200);
              expect(res.body).toEqual(expected);
            
              const after = await request(app).get(`/products/${original.id}`);
              expect(after.body).toEqual(expected);
            });

            D 問的是另一個問題:被拒絕之後,儲存的商品是否完好無缺?它在不合法的數量旁邊附上一個合法的價格。如果服務在檢查數量之前就寫入了價格,回應仍然會是 400,只有讀回來才抓得到。

            TypeScript · 為本指南撰寫,以 Vitest 5.0 在第 5 週起始專案上實際執行
            it('leaves the product unchanged after a rejected change', async () => {
              const all = await request(app).get('/products');
              const original = all.body[0];
            
              const res = await request(app)
                .patch(`/products/${original.id}`)
                .send({ quantity: -1, price: original.price + 1 });
              expect(res.status).toBe(400);
            
              const after = await request(app).get(`/products/${original.id}`);
              expect(after.body).toEqual(original);
            });

            PATCH 的回應回答的是「API 有沒有回報拒絕?」GET 回答的是「它有沒有保護好商品?」只有第二個問題和資料有關。

            重點整理

            每個 PATCH 測試證明一個承諾。404 測試用的是格式正確但不存在的 id。要證明沒送出的欄位沒變,就比較整個商品並把它讀回來。要證明被拒絕的修改什麼都沒寫入,就在 400 之後再讀一次商品。

            檢查 · 先預測再選擇

            測試 D 在不合法的數量旁邊附上一個合法的價格。為什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 讓請求看起來像真的用戶端送的 — 重點不是逼真;多出來的欄位是為了抓一個特定的錯誤。
            • 為了證明服務沒有儲存修改的一部分 — 如果價格在檢查數量之前就寫入了,讀回來就會看到新價格。
            • 因為 PATCH 至少需要兩個欄位 — PATCH 接受單一欄位;正常流程的測試就只送了價格。

            假函式、模擬函式、監視函式:控制與觀察

            在測試裡,依賴指的是受測函式會呼叫的任何東西。對服務來說,就是資料存取層。單元測試用測試替身取代它:一個由測試控制的替代品,讓結果只取決於受測的那個函式。

            模擬函式代替資料存取層測試呼叫真正的服務。服務呼叫資料存取層,而測試已經用模擬函式取代它,所以後面的 MongoDB 永遠不會被碰到。兩個箭頭:控制,是模擬函式交給服務的答案;觀察,是模擬函式被呼叫的紀錄,由測試讀取。觀察:它怎麼被呼叫測試it(...)呼叫服務真正的程式碼呼叫模擬函式資料存取層控制:它回傳什麼MongoDB沒有碰到真正的資料存取層會一路走到這裡
            注意這兩種能力:測試決定模擬函式回答什麼,之後再問模擬函式它是怎麼被呼叫的。
            詞它是什麼在 Vitest 裡
            假函式只會記住每次呼叫、其他什麼都不做的替代函式vi.fn()
            模擬函式會用測試選好的東西回答的假函式vi.fn().mockReturnValue(4)
            監視函式包住真正函式、記錄它被呼叫情形的外層。除非你給它答案,否則它仍會執行真正的程式碼。vi.spyOn(inventory, 'countOnHand')
            測試替身以上三者的總稱

            Vitest 的文件把 vi.fn() 回傳的東西一律稱為模擬函式。筆記則依用途區分假函式和模擬函式:同一個物件只在旁聽時是假函式,一開始回答就成了模擬函式。

            測試替身給測試兩種能力:

            • 控制:它回傳什麼、解析成什麼,或拋出什麼。服務看到的正是測試選好的商品。
            • 觀察:它怎麼被呼叫、被呼叫幾次、帶了什麼。測試事後再問。

            本章的測試會用其中一種能力,或另一種,或兩種都用。低庫存測試只控制資料存取層回傳什麼。從不寫入的測試只觀察寫入沒有被呼叫。更新測試兩種都用。

            重點整理

            依賴是受測函式呼叫的東西;測試替身代替它。假函式記住呼叫,模擬函式還會回答,監視函式包住真正的函式。測試替身提供兩種能力:控制答案,觀察呼叫。

            檢查 · 先預測再選擇

            一個測試用 vi.spyOn 包住 productsRepository.updateProduct,沒有給它答案。接著服務呼叫它。實際執行的是什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 什麼都沒有;給答案之前,監視函式都回傳 undefined — 那是 vi.fn()。監視函式底下保留的是真正的函式。
            • Vitest 拋出錯誤,因為監視函式不准被呼叫 — 監視函式負責記錄呼叫;被呼叫正是它要注意的事。
            • 真正的 updateProduct,而且監視函式記下這次呼叫 — 單獨的 vi.spyOn 只監看並轉呼叫原函式,所以真正的資料存取層會執行。

            vi.fn:會記住的函式

            vi.fn() 建立一個沒有任何行為、但會記住每次呼叫的函式。取自筆記:

            src/mocks-and-spies.unit.test.ts · 取自第 5 週筆記
            it('a fake function remembers how it was called', () => {
              const notify = vi.fn();
            
              notify('order shipped', 3);
            
              expect(notify).toHaveBeenCalledWith('order shipped', 3);
              expect(notify).toHaveBeenCalledTimes(1);
            });

            同樣的概念,這次也檢查結果:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const notify = vi.fn();
            const result = notify('restock', 4);
            
            expect(result).toBeUndefined();
            expect(notify).toHaveBeenCalledTimes(1);
            expect(notify).toHaveBeenCalledWith('restock', 4);

            空的假函式回傳 undefined。注意一個陷阱:expect(notify()).toBeUndefined() 會呼叫 notify 第二次,所以呼叫次數變成 2。像上面那樣把結果存進變數,再對變數做斷言。

            取自筆記,一個被告知要回答什麼的假函式:

            src/mocks-and-spies.unit.test.ts · 取自第 5 週筆記
            it('a fake function can be told what to answer', () => {
              const rollDie = vi.fn().mockReturnValue(4);
            
              expect(rollDie()).toBe(4);
              expect(rollDie()).toBe(4);
            });

            指定答案的三種方式:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const readStock = vi.fn().mockReturnValue(8);
            expect(readStock()).toBe(8);
            expect(readStock('any id')).toBe(8);
            
            const next = vi.fn()
              .mockReturnValue(8)
              .mockReturnValueOnce(0)
              .mockReturnValueOnce(3);
            // next() gives 0, then 3, then 8 every time after
            
            const total = vi.fn().mockImplementation(
              (price: number, quantity: number) => price * quantity
            );
            expect(total(2.5, 4)).toBe(10);
            方法回答
            mockReturnValue(v)每一次呼叫,不論引數是什麼
            mockReturnValueOnce(v)只有下一次呼叫。多個會依寫下的順序排隊;佇列用完之後,由預設值回答。
            mockImplementation(fn)用這次呼叫的引數執行你的函式。vi.fn(fn) 效果相同。

            在 Python 裡,unittest.mock 做同樣的事。有一點不同:呼叫一個空的 Mock() 會回傳另一個 Mock,而不是 None。

            Python

            from unittest.mock import Mock
            notify = Mock()
            notify('restock', 4)
            notify.assert_called_once_with('restock', 4)
            print(notify.call_count)  # → 1
            read_stock = Mock(return_value=8)
            print(read_stock())  # → 8

            TypeScript

            const notify = vi.fn();
            const result = notify('restock', 4);
            expect(notify).toHaveBeenCalledTimes(1);
            expect(notify).toHaveBeenCalledWith('restock', 4);
            const readStock = vi.fn().mockReturnValue(8);
            expect(readStock()).toBe(8);

            重點整理

            vi.fn() 建立一個回傳 undefined、並記錄每次呼叫的假函式;用 toHaveBeenCalledTimes 與 toHaveBeenCalledWith 檢查它。在 expect 裡呼叫它也算一次呼叫。mockReturnValue 永遠回答同一個值,mockReturnValueOnce 排入一次性的答案,mockImplementation 執行你的函式。

            檢查 · 先預測再選擇

            notify 已經被呼叫過一次。接著測試執行 expect(notify()).toBeUndefined() 與 expect(notify).toHaveBeenCalledTimes(1)。會發生什麼事?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 兩個都通過 — 第一個 expect 又呼叫了 notify,所以次數不再是 1。
            • 次數的斷言失敗,因為第一個 expect 又呼叫了一次 — expect 裡的 notify() 是真的呼叫;假函式會像記錄其他呼叫一樣記下它。
            • 第一個斷言失敗,因為假函式回傳 null — 空的 vi.fn() 回傳 undefined,所以那個斷言會通過。

            解析、拒絕,以及一次性答案的順序

            資料存取層的函式是非同步的:它們回傳 Promise。替它們做的假函式也必須交回 Promise。取自筆記:

            src/mocks-and-spies.unit.test.ts · 取自第 5 週筆記
            it('an async fake answers with a resolved promise', async () => {
              const loadSettings = vi.fn().mockResolvedValue({ theme: 'dark' });
            
              expect(await loadSettings()).toEqual({ theme: 'dark' });
            });

            成功與失敗:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const loadProduct = vi.fn().mockResolvedValue({ price: 20 });
            expect(await loadProduct()).toEqual({ price: 20 });
            
            const failing = vi.fn().mockRejectedValue(new Error('Database unavailable'));
            await expect(failing()).rejects.toThrow('Database unavailable');
            要做的事同步函式非同步函式
            答案mockReturnValue(v)mockResolvedValue(v)
            失敗mockImplementation(() => { throw err; })mockRejectedValue(err)
            只回答一次mockReturnValueOnce(v)mockResolvedValueOnce(v), mockRejectedValueOnce(err)
            檢查失敗expect(() => fn()).toThrow()await expect(fn()).rejects.toThrow()

            同步的檢查接收一個函式,讓 expect 可以呼叫它並接住拋出的錯誤。非同步的檢查接收 Promise,而且必須 await。Vitest 3 會對忘記的 await 發出警告並延後檢查;不要依賴這一點。

            一次性答案永遠先用,不管你用什麼順序寫:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const connect = vi.fn()
              .mockResolvedValue('ready')
              .mockRejectedValueOnce(new Error('Temporary failure'));
            
            await expect(connect()).rejects.toThrow('Temporary failure');
            expect(await connect()).toBe('ready');

            第一次呼叫被拒絕;之後每一次呼叫都解析成 ready。順序只決定每次呼叫拿到什麼。它不會讓你的程式重試;重試要由受測的程式自己做。

            Python

            import asyncio
            from unittest.mock import AsyncMock
            load_product = AsyncMock(return_value={'price': 20})
            print(asyncio.run(load_product()))  # → {'price': 20}
            failing = AsyncMock(side_effect=Exception('Database unavailable'))
            try: asyncio.run(failing())
            except Exception as e: print(e)  # → Database unavailable

            TypeScript

            const loadProduct = vi.fn().mockResolvedValue({ price: 20 });
            expect(await loadProduct()).toEqual({ price: 20 });
            const failing = vi.fn().mockRejectedValue(new Error('Database unavailable'));
            await expect(failing()).rejects.toThrow('Database unavailable');

            重點整理

            非同步假函式用 mockResolvedValue 解析,用 mockRejectedValue 拒絕。用 await expect(promise).rejects.toThrow() 檢查拒絕;同步的拋出則用 expect(() => fn()).toThrow()。先用一次性答案,再用預設值。

            檢查 · 先預測再選擇

            connect 是 vi.fn().mockResolvedValue('ready').mockRejectedValueOnce(new Error('x'))。它被呼叫三次。每次分別得到什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 拒絕,然後 ready,然後 ready — 一次性的拒絕先被用掉;之後每次呼叫都由預設值回答。
            • ready,然後拒絕,然後 ready — 寫下的順序不重要;一次性答案永遠先用。
            • 三次都拒絕 — 一次就是一次呼叫;第一次之後佇列就空了。

            讀呼叫紀錄

            每個假函式和監視函式都會保留呼叫紀錄。比對器幫你讀它:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const notify = vi.fn();
            notify('low-stock', 2);
            notify('restocked', 12);
            
            expect(notify).toHaveBeenCalledTimes(2);
            expect(notify).toHaveBeenCalledWith('low-stock', 2);
            expect(notify).toHaveBeenNthCalledWith(2, 'restocked', 12);
            expect(notify).toHaveBeenLastCalledWith('restocked', 12);
            expect(notify.mock.calls).toEqual([['low-stock', 2], ['restocked', 12]]);
            expect(notify.mock.calls[0][1]).toBe(2);
            比對器通過的條件
            toHaveBeenCalled()至少一次呼叫
            toHaveBeenCalledTimes(n)剛好 n 次呼叫
            toHaveBeenCalledWith(...)至少有一次呼叫帶了這些引數
            toHaveBeenNthCalledWith(n, ...)第 n 次呼叫(從 1 開始數)帶了這些引數
            toHaveBeenLastCalledWith(...)最近一次呼叫帶了這些引數

            toHaveBeenCalledWith 單獨使用並不代表只有一次:十次呼叫中有一次相符,仍然會通過。數量重要時,搭配 toHaveBeenCalledTimes(1) 一起用。

            mock.calls 是原始紀錄:每次呼叫一個引數陣列。mock.results 記錄每次呼叫交回了什麼:是回傳還是拋出,以及那個值:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const addTax = vi.fn((price: number) => price * 1.2);
            addTax(10);
            expect(addTax.mock.results[0]).toEqual({ type: 'return', value: 12 });

            拋出錯誤的呼叫是 type: 'throw'。對非同步假函式來說,值就是 Promise 本身;mock.settledResults 則保存它最後的結果。

            一個陷阱:紀錄保留的是每個引數的參照,不是副本。呼叫之後改了物件,紀錄也會跟著變:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const save = vi.fn();
            const changes = { price: 12.5 };
            save(changes);
            changes.price = 20;
            expect(save.mock.calls[0][0].price).toBe(20); // not 12.5

            如果受測的程式在傳出物件之後又修改它,就在假函式裡用 structuredClone 拍一份快照,再對副本做斷言:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const captured: object[] = [];
            const save = vi.fn((changes: object) => {
              captured.push(structuredClone(changes));
            });

            重點整理

            toHaveBeenCalledWith 代表至少有一次相符的呼叫;要精確的次數就加上 toHaveBeenCalledTimes。toHaveBeenNthCalledWith 從 1 開始數。mock.calls 保存引數,mock.results 保存回傳的東西。紀錄保留的是參照,所以之後會變的物件要先拍快照。

            檢查 · 先預測再選擇

            notify 被呼叫兩次,兩次都帶 ('low-stock', 2)。哪個斷言會失敗?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • expect(notify).toHaveBeenCalledWith('low-stock', 2) — 至少有一次呼叫相符,所以會通過。
            • expect(notify).toHaveBeenLastCalledWith('low-stock', 2) — 最近一次呼叫剛好帶著這些引數,所以會通過。
            • expect(notify).toHaveBeenCalledTimes(1) — 一共有兩次呼叫。只有這個斷言注意得到。

            vi.spyOn 包住一個真正的方法

            vi.fn() 建立一個新函式。vi.spyOn(object, name) 則包住一個已經存在、掛在某個物件上、有自己名稱的函式。單獨使用時,監視函式只會監看;真正的函式照樣執行。取自筆記:

            src/mocks-and-spies.unit.test.ts · 取自第 5 週筆記
            const inventory = {
              countOnHand: () => 42
            };
            
            it('vi.spyOn alone watches, but does not replace', () => {
              const count = vi.spyOn(inventory, 'countOnHand');
            
              expect(inventory.countOnHand()).toBe(42);
              expect(count).toHaveBeenCalledTimes(1);
            });

            給監視函式一個答案,真正的函式就不再執行。呼叫它的程式分辨不出來:

            src/mocks-and-spies.unit.test.ts · 取自第 5 週筆記
            it('vi.spyOn plus an answer replaces the real function', () => {
              const count = vi.spyOn(inventory, 'countOnHand').mockReturnValue(0);
            
              expect(inventory.countOnHand()).toBe(0);
              expect(count).toHaveBeenCalledTimes(1);
            });

            同樣的做法用在資料存取層上。服務照真的匯入,資料存取層則用命名空間匯入,import * as productsRepository,讓 vi.spyOn 有一個可以伸手進去的物件:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([]);
            const low = await productsService.getLowStockProducts(5);
            expect(low).toEqual([]);
            角色函式在測試裡
            受測對象productsService.getLowStockProducts真的。直接呼叫。
            依賴productsRepository.findAllProducts被監視並給了答案,所以永遠碰不到 MongoDB。

            模擬依賴,永遠不要模擬受測的函式。對 getLowStockProducts 設監視函式並給它答案,測試就只是在檢查模擬函式回傳你告訴它的東西。

            Python 裡最接近的工具是 patch.object。它預設會取代原函式;傳入 wraps= 才會像單獨的監視函式那樣轉呼叫原函式。離開 with 區塊時原函式就會放回去,這正是 vi.restoreAllMocks() 在 Vitest 測試之間做的事。

            Python

            from unittest.mock import patch
            class Repo:
                def find_all_products(self): return ['real']
            repo = Repo()
            with patch.object(repo, 'find_all_products', return_value=[]):
                print(repo.find_all_products())  # → []
            print(repo.find_all_products())  # → ['real']

            TypeScript

            vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([]);
            const low = await productsService.getLowStockProducts(5);
            expect(low).toEqual([]);

            重點整理

            vi.spyOn(object, name) 包住一個現有的函式,除非給了答案(例如 mockResolvedValue),否則仍會呼叫它。監視的是依賴,這裡就是資料存取層,受測的函式則照真的呼叫。

            檢查 · 先預測再選擇

            一個測試監視 productsService.getLowStockProducts,讓它回答 [product],再斷言結果是 [product]。這證明了什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 關於服務什麼都沒證明;真正的函式根本沒執行 — 測試只是拿模擬函式和你給它的答案比對。篩選從來沒有執行。
            • 低庫存的篩選是對的 — 篩選就在被取代的那個函式裡,所以它沒有執行。
            • 資料存取層回傳了 [product] — 資料存取層從來沒被呼叫;是被模擬的服務直接回答的。

            服務的單元測試檔

            筆記從零開始寫 src/services/products-service.unit.test.ts。它以匯入和一個掛鉤開頭:

            services/products-service.unit.test.ts · 取自第 5 週筆記
            import { beforeEach, describe, expect, it, vi } from 'vitest';
            import * as productsService from './products-service.js';
            import * as productsRepository from '../db/products-repository.js';
            import { ValidationError } from '../errors.js';
            import type { Product, ProductFields } from '../models/product.js';
            
            beforeEach(() => {
              vi.restoreAllMocks();
            });
            匯入原因
            productsService受測的函式。檔名就說明了這一點:它就放在 products-service.ts 旁邊。
            productsRepository測試要用監視函式取代的函式。
            ValidationError用來檢查拒絕時拋出的是正確種類的錯誤。
            Product, ProductFields只有型別,給下面的輔助函式用。

            vi.restoreAllMocks() 在每個測試之前把所有被監視的函式放回去,所以沒有測試會繼承別的測試設定的假答案。

            接著是一個測試資料輔助函式,只要給測試在意的欄位,就能組出完整的 Product:

            services/products-service.unit.test.ts · 取自第 5 週筆記
            const makeProduct = (fields: ProductFields): Product => {
              return {
                id: fields.id ?? 'aaaaaaaaaaaaaaaaaaaaaaaa',
                modelName: fields.modelName ?? 'Test Product',
                modelNumber: fields.modelNumber ?? 'TP-1',
                manufacturer: fields.manufacturer ?? 'Test',
                color: fields.color ?? 'Black',
                price: fields.price ?? 10,
                quantity: fields.quantity ?? 10
              };
            };

            這些預設值是測試資料,和真正的服務建立商品時填入的預設值是兩回事。

            受測的函式:

            services/products-service.ts · 取自第 5 週筆記
            export const getLowStockProducts = async (
              threshold: number = LOW_STOCK_THRESHOLD
            ): Promise<Product[]> => {
              const products = await productsRepository.findAllProducts();
              return products.filter((product) => product.quantity < threshold);
            };

            第一個真正的單元測試餵給它三個商品,再檢查哪些被傳回來:

            services/products-service.unit.test.ts · 取自第 5 週筆記
            it('returns only the products below the threshold', async () => {
              vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
                makeProduct({ modelName: 'Plenty', quantity: 100 }),
                makeProduct({ modelName: 'Few', quantity: 2 }),
                makeProduct({ modelName: 'None', quantity: 0 })
              ]);
            
              const low = await productsService.getLowStockProducts(5);
            
              const names = low.map((product) => product.modelName);
            
              expect(names).toEqual(['Few', 'None']);
            });

            準備:監視函式用三個商品回答,永遠碰不到 MongoDB。執行:用門檻 5 呼叫真正的服務。斷言:檢查名稱,不只是數量。端對端的低庫存測試檢查的是同一條規則,但那裡失敗時,問題可能出在控制器、服務、資料存取層或初始資料。這裡只可能是篩選。

            還有一個問題是初始資料回答不了的:剛好有 5 件的商品算不算低庫存?

            services/products-service.unit.test.ts · 取自第 5 週筆記
            it('does not count a product sitting on the threshold', async () => {
              vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
                makeProduct({ modelName: 'Exactly five', quantity: 5 })
              ]);
            
              const low = await productsService.getLowStockProducts(5);
            
              expect(low).toHaveLength(0);
            });

            這個測試釘住了邊界。用 < 時這個商品不算低庫存;改成 <= 就會讓這個測試失敗,其他測試都不會。

            重點整理

            匯入服務來測試它,匯入資料存取層來監視它。beforeEach 裡的 vi.restoreAllMocks() 讓每個測試從乾淨的狀態開始。makeProduct 填入測試預設值。低庫存測試控制資料存取層回傳什麼;邊界測試檢查數量剛好等於門檻的情況。

            檢查 · 先預測再選擇

            有人把篩選改成 product.quantity <= threshold。兩個低庫存測試中哪一個會失敗?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 三個商品的那個測試 — 它的數量是 100、2 和 0。沒有一個剛好是 5,所以 < 與 <= 得到的答案一樣。
            • 兩個都會 — 只有一個測試有剛好等於門檻的商品。
            • 只有邊界測試 — 剛好 5 件的商品現在算低庫存,所以原本預期的空清單多了一項。

            證明它從未寫入

            更新服務最後只做一次寫入。關於它有兩個問題:修改合法時,它送出什麼?不合法時,它到底有沒有送出任何東西?先看筆記:

            services/products-service.unit.test.ts · 取自第 5 週筆記
            it('asks the repository to change only what it was given', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
                makeProduct({ price: 10 })
              );
              const update = vi
                .spyOn(productsRepository, 'updateProduct')
                .mockResolvedValue(makeProduct({ price: 12.5 }));
            
              await productsService.updateProduct('abc', { price: 12.5 });
            
              expect(update).toHaveBeenCalledWith('abc', { price: 12.5 });
            });

            動手重建 · 把程式行排好

            問監視函式它收到了什麼

            重建筆記裡的這個測試:檢查服務只把收到的欄位傳下去。先準備兩個監視函式,執行一次,再去問負責寫入的監視函式。

            顯示排好的程式碼
            it('asks the repository to change only what it was given', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
                makeProduct({ price: 10 })
              );
              const update = vi
                .spyOn(productsRepository, 'updateProduct')
                .mockResolvedValue(makeProduct({ price: 12.5 }));
              await productsService.updateProduct('abc', { price: 12.5 });
              expect(update).toHaveBeenCalledWith('abc', { price: 12.5 });
            });

            兩個監視函式都必須在服務執行前就位,而問寫入的監視函式收到什麼,只有在呼叫之後才有意義。兩個監視函式互不相依,誰先誰後都可以。

            兩個資料存取層函式都被監視並給了答案,理由各不相同。findProduct 只是為了它的答案:服務需要一個已儲存的商品來比較。updateProduct 才是測試要問的對象,所以把監視函式存進變數。id 'abc' 在這裡沒問題,因為資料存取層已被取代;端對端測試會把它送進 new ObjectId,得到 500。

            接著是被拒絕的修改:

            services/products-service.unit.test.ts · 取自第 5 週筆記
            it('writes nothing when quantity would go negative', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
                makeProduct({ quantity: 10 })
              );
              const update = vi.spyOn(productsRepository, 'updateProduct');
            
              await expect(
                productsService.updateProduct('abc', { quantity: -1 })
              ).rejects.toThrow(ValidationError);
            
              expect(update).not.toHaveBeenCalled();
            });

            動手重建 · 把程式行排好

            證明寫入從未發生

            重建筆記裡這個測試:一個會讓數量變成負數的修改。它必須被拒絕,而且寫入函式一次都不能被呼叫。

            顯示排好的程式碼
            it('writes nothing when quantity would go negative', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
                makeProduct({ quantity: 10 })
              );
              const update = vi.spyOn(productsRepository, 'updateProduct');
              await expect(
                productsService.updateProduct('abc', { quantity: -1 })
              ).rejects.toThrow(ValidationError);
              expect(update).not.toHaveBeenCalled();
            });

            監視函式要在呼叫之前就位,呼叫要等到它被拒絕,之後 not.toHaveBeenCalled 才能證明寫入從未發生。兩個監視函式誰先誰後都可以。

            寫入的監視函式刻意不給答案:它本來就不該被呼叫。如果有錯誤跳過了檢查,呼叫就會進到真正的資料存取層,而這裡沒有資料庫,不論哪種情況測試都會變紅。對照測試 D,它需要第二個請求才看得到損害;這裡 not.toHaveBeenCalled() 直接問那個唯一可能造成損害的函式。

            Now You Try #2 要你寫價格的版本和商品不存在的情況。以下是自己寫的版本,已對照筆記中服務的副本實際執行:

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            it('writes nothing when the price would go negative', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
                makeProduct({ price: 10 })
              );
              const update = vi.spyOn(productsRepository, 'updateProduct');
            
              await expect(
                productsService.updateProduct('abc', { price: -1 })
              ).rejects.toThrow(ValidationError);
            
              expect(update).not.toHaveBeenCalled();
            });
            
            it('returns null when there is no product with that id', async () => {
              vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(null);
              const update = vi.spyOn(productsRepository, 'updateProduct');
            
              const result = await productsService.updateProduct('abc', { price: 12.5 });
            
              expect(result).toBeNull();
              expect(update).not.toHaveBeenCalled();
            });

            商品不存在的測試沒有 .rejects:服務不會拋出錯誤,它回傳 null。它斷言兩件事:結果,以及沒有發生的寫入。

            證據問什麼比對器
            結果函式交回或拋出了什麼?rejects.toThrow(ValidationError), toBeNull()
            副作用它有沒有試圖改變任何東西?expect(update).not.toHaveBeenCalled()

            重點整理

            存進變數的監視函式能回答關於寫入的兩個問題:toHaveBeenCalledWith 顯示送出了什麼,not.toHaveBeenCalled 顯示什麼都沒送。永遠不該被呼叫的寫入監視函式不需要答案。結果和副作用要分開檢查。

            檢查 · 先預測再選擇

            為什麼負數數量測試裡的寫入監視函式沒有 mockResolvedValue?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 測試預期它永遠不會被呼叫,所以不需要答案 — 只有服務真的呼叫到它,答案才有意義,而那正是測試要抓的錯誤。
            • 監視函式不能被給予答案 — 更新測試就用 mockResolvedValue 給同一個監視函式答案。
            • 讓真正的資料庫記錄這次嘗試 — 單元測試裡沒有資料庫;任何呼叫都由監視函式本身記錄。

            建立時補上空白

            Now You Try #2 的最後一題:只用一個型號名稱呼叫 createProduct,證明到達資料存取層的東西六個欄位都齊全。筆記沒有印出服務的預設值;這個版本假設沿用第 4 週的預設值:文字用空字串,數字用 0。

            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            describe('createProduct', () => {
              it('fills in defaults before handing it to the repository', async () => {
                const created = makeProduct({});
                const insert = vi
                  .spyOn(productsRepository, 'insertProduct')
                  .mockResolvedValue(created);
            
                const result = await productsService.createProduct({ modelName: 'Desk Lamp' });
            
                expect(insert).toHaveBeenCalledWith({
                  modelName: 'Desk Lamp',
                  modelNumber: '',
                  manufacturer: '',
                  color: '',
                  price: 0,
                  quantity: 0
                });
                expect(insert).toHaveBeenCalledTimes(1);
                expect(result).toEqual(created);
              });
            });

            insertProduct 同時被監看並給了答案:服務會回傳它交回的任何東西,所以測試給它 makeProduct({}),並檢查服務把它原封不動傳出去。toHaveBeenCalledWith 比較的是整個物件,所以少一個欄位或多一個欄位都會失敗。物件裡沒有 id;id 由資料存取層產生。

            問題單元測試端對端:先 POST,再 GET
            服務有沒有組出全部六個欄位?有,直接證明只能從傳回來的東西間接看出
            它們有被儲存並傳回嗎?不知道,資料存取層是監視函式有
            如果失敗,錯誤在哪裡?在 createProduct 裡從路由到 MongoDB 之間的任何地方

            端對端的版本正是抓到第 4 週預設值錯誤的那個測試:預設值進了回應,卻從來沒進資料庫。只靠單元測試,那次會通過,因為錯誤在於儲存了什麼。每種測試把不同的部分隔離出來。

            重點整理

            createProduct 的單元測試用 makeProduct({}) 回答 insertProduct,並檢查它剛好收到一次、包含預設值在內的全部六個欄位。它證明服務組出了什麼;端對端的先 POST 再 GET 證明儲存了什麼。

            檢查 · 先預測再選擇

            createProduct 的單元測試通過了,但端對端的先 POST 再 GET 測試發現儲存的商品少了 modelNumber。你先查哪裡?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 服務裡的 createProduct — 單元測試剛剛才證明服務送出了全部六個欄位。
            • 資料存取層的寫入路徑 — 服務交出的資料是對的,所以遺失發生在資料存取層或之後。
            • 單元測試裡的 makeProduct — makeProduct 只負責組出假答案;它改變不了端對端測試儲存的東西。

            清除、重設、還原

            假函式會在測試之間帶著兩樣東西:呼叫紀錄和答案;監視函式還藏著原本的函式。三個方法各自清掉的多寡不同:

            每種清理會清掉什麼三列,每列一個方法。mockClear 清除呼叫紀錄,保留答案與原函式。mockReset 清除呼叫紀錄與答案。mockRestore 兩者都清除,而且對監視函式來說,會把原本的方法放回去。呼叫答案原函式mockClear()清除保留保留mockReset()清除清除保留mockRestore()清除清除放回
            注意:每一列都做了上一列做的所有事,再多做一件。
            方法呼叫紀錄你設定的答案原本的方法
            mockClear()清除保留仍被取代
            mockReset()清除清除,回到假函式剛建立時的樣子仍被取代
            mockRestore()清除清除放回去(限監視函式)
            TypeScript · 為本指南撰寫,以 Vitest 3.2 實際執行
            const count = vi.fn(() => 4);
            count.mockReturnValue(9);
            expect(count()).toBe(9);
            
            count.mockClear();
            expect(count).not.toHaveBeenCalled();
            expect(count()).toBe(9);
            
            count.mockReset();
            expect(count()).toBe(4);

            mockReset 會回到傳給 vi.fn 的函式,所以是 4,不是 undefined。在單純的 vi.fn() 上沒有原函式可以放回,所以 mockRestore 的作用就和 reset 一樣。

            課程把 vi.restoreAllMocks() 放在 beforeEach 裡。它的工作是把所有被 vi.spyOn 取代的函式放回去,這正是服務測試需要的。它對單獨的 vi.fn() 會做什麼,在不同 Vitest 版本之間改變過,所以那種情況不要依賴它。它從不清空資料庫,而用 vi.mock 取代的模組會一直維持被取代。

            Vitest 指南也提供同樣的清理作為設定,這樣就沒有任何檔案會忘記:test: { restoreMocks: true } 寫在 vitest.config.ts 裡。

            重點整理

            mockClear 清除呼叫紀錄、保留答案。mockReset 還會移除設定好的答案,回到假函式剛建立時的樣子。mockRestore 還會把被監視的原函式放回去。vi.restoreAllMocks 還原監視函式;它不會重設資料庫,也不會撤銷 vi.mock。

            檢查 · 先預測再選擇

            count 設了 mockReturnValue(9),也記錄了一些呼叫。哪個呼叫會忘掉呼叫紀錄,但仍然回答 9?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • count.mockReset() — reset 也會移除設定好的答案,所以 9 沒了。
            • count.mockRestore() — restore 做了 reset 做的所有事,還做更多。
            • count.mockClear() — clear 只清掉呼叫紀錄;設定好的答案留著。

            模擬整個模組

            vi.spyOn 需要一個可以伸手進去的物件。程式碼用名稱匯入單一函式時,就沒有這樣的物件,所以 Vitest 改成取代整個模組。以下是仿照 Vitest 指南寫的例子:

            stock.ts · 為本指南撰寫
            export async function readQuantity(id: string): Promise<number> {
              return 10;
            }
            stock-label.ts · 為本指南撰寫
            import { readQuantity } from './stock.js';
            
            export async function stockLabel(id: string): Promise<string> {
              const quantity = await readQuantity(id);
              return quantity === 0 ? 'Unavailable' : 'Available';
            }
            stock-label.unit.test.ts · 為本指南撰寫,以 Vitest 3.2 實際執行
            import { expect, it, vi } from 'vitest';
            import { readQuantity } from './stock.js';
            import { stockLabel } from './stock-label.js';
            
            vi.mock(import('./stock.js'), () => ({ readQuantity: vi.fn() }));
            
            it('labels a product with no stock', async () => {
              vi.mocked(readQuantity).mockResolvedValue(0);
            
              expect(await stockLabel('lamp-1')).toBe('Unavailable');
              expect(readQuantity).toHaveBeenCalledWith('lamp-1');
            });
            部分它的工作
            vi.mock(import(...), factory)把模組的匯出換成工廠函式回傳的東西,作用於這個測試檔裡對它的每一次匯入。import() 的寫法讓 TypeScript 能檢查路徑。
            vi.fn()在工廠函式裡建立替代用的函式。
            vi.mocked(readQuantity)執行時什麼都不改變。它告訴 TypeScript 這個匯入已經是模擬函式,讓 mockResolvedValue 能通過型別檢查。

            vi.mock 會被提升:Vitest 把它移到匯入之上,所以模組在任何東西載入它之前就已被取代。因此工廠函式不能使用檔案後面才宣告的變數。

            在這份作業裡,監視資料存取層就夠了。服務用命名空間匯入資料存取層,所以 vi.spyOn 碰得到裡面的每一個函式。

            重點整理

            vi.mock(import(path), factory) 把模組的匯出換掉,作用於整個測試檔,而且會被提升到匯入之上。vi.fn 建立替代品;vi.mocked 只補上型別。沒有物件可以監視時才用它;這份作業不需要。

            檢查 · 先預測再選擇

            vi.mocked(readQuantity) 在執行時做了什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 什麼都沒做;它只告訴 TypeScript 這個匯入已經是模擬函式 — 取代的是 vi.mock。vi.mocked 是型別輔助工具。
            • 它把 readQuantity 換成模擬函式 — 取代發生在 vi.mock 的工廠函式裡,在任何匯入執行之前。
            • 它還原真正的 readQuantity — 還原是 mockRestore 的工作,而且 vi.mock 取代的模組會一直維持被取代。

            測試說謊的時候

            測試可能什麼都沒測就通過,也可能因為和程式碼無關的原因失敗。常見情況:

            症狀可能的問題要檢查什麼
            通過了,但邏輯從來沒執行受測的函式被模擬了只監視它呼叫的東西
            單元測試碰到了 MongoDB它呼叫的某個資料存取層函式仍然是真的它碰到的每個函式都有答案,或者本來就該失敗
            拒絕從來沒被檢查其中的 expect(...).rejects 沒有 await每個 Promise 斷言都要 await
            單獨跑通過,和其他一起跑就失敗別的測試留下的監視函式或資料vi.restoreAllMocks() 以及資料庫重設,都放在 beforeEach
            更新測試在沒有更新的情況下通過新值和初始資料一樣一個沒有任何初始商品用過的值
            400,但資料變了只檢查了回應把商品讀回來,或監視寫入
            門檻的錯誤溜過去了沒有值剛好落在邊界上一個數量剛好等於門檻的商品
            呼叫紀錄的斷言顯示的是後來的值引數在呼叫之後被修改了structuredClone 在呼叫當下複製一份
            不存在 id 的測試得到 500,而不是 404id 格式錯誤,而不是不存在'a'.repeat(24)
            每個測試都跑了兩次測試的編譯後副本也被找到了Vitest 從哪裡找測試檔
            單元測試指令是綠燈,卻什麼都沒跑--passWithNoTests輸出裡的測試數量

            判斷任何測試的七個問題:

            1. 它檢查什麼行為?
            2. 哪個實際會發生的錯誤會讓它失敗?
            3. 它有包含邊界值嗎?
            4. 它有檢查是哪些項目,而不只是幾個嗎?
            5. 它有檢查副作用嗎,例如寫入?
            6. 別的測試會不會改變它的起始狀態?
            7. 有什麼在它的證據範圍之外?

            通過的測試套件,是它所跑的那些情境的證據,不是沒有錯誤的證明。

            重點整理

            測試可能說謊:模擬了受測函式、漏了 await、繼承了狀態、重用了初始值、只檢查回應,或漏了邊界。問自己:哪個實際會發生的錯誤會讓每個測試失敗?

            檢查 · 先預測再選擇

            一個單元測試單獨跑會通過,整個檔案一起跑卻失敗,而且檔案裡沒有 beforeEach。最可能的原因是什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 前一個測試的監視函式還在回答 — 沒有東西還原它,所以下一個測試繼承了一個不是它設定的假函式。
            • 整個檔案一起跑時,Vitest 會把每個測試跑兩次 — Vitest 每個測試只跑一次;重複執行來自重複的檔案。
            • 低庫存的門檻錯了 — 門檻錯了,單獨跑也會失敗;這個只在一起跑時失敗。

            先說出來,再動手試

            每一題先大聲回答,再打開答案。

            1. 服務呼叫了 res.status(400)。哪裡出錯了?

            它越界到了 HTTP。服務應該拋出 ValidationError;狀態碼由控制器或錯誤處理器決定。

            2. 為什麼是資料存取層、而不是服務,把文件轉成 Product?

            這樣 ObjectId 和 _id 這類 MongoDB 細節就只留在一層,服務只處理 Product。

            3. 一個 PATCH 把數量設成 0。用 ?? 的話,服務檢查的是哪個數量?

            0。?? 只在 null 或 undefined 時才改用後備值,而 0 兩者都不是。

            4. 為什麼單靠 400 回應,不足以證明拒絕是安全的?

            服務可能先寫入,之後才回報錯誤。把商品讀回來,或監視寫入。

            5. 一個低庫存測試用數量 1 和 8,門檻 5。它可能漏掉什麼?

            分不出 < 與 <= 的差別。加一個剛好 5 件的商品。

            6. 空的 vi.fn() 回傳什麼?

            undefined。

            7. 單獨的 vi.spyOn 能阻止寫入到達資料庫嗎?

            不能。沒有答案時,它會呼叫真正的函式。

            8. mockReturnValue 和 mockResolvedValue 有什麼不同?

            前者交回值本身。後者交回一個會解析成那個值的 Promise,用在非同步函式。

            9. toHaveBeenCalledWith 能證明只呼叫了一次嗎?

            不能,只能證明至少有一次呼叫相符。加上 toHaveBeenCalledTimes(1)。

            10. 一個物件在傳給假函式之後被修改了。mock.calls 會顯示什麼?

            修改後的物件。紀錄保留的是參照,不是副本。

            11. 哪一種清理會忘掉呼叫紀錄,但保留設定好的答案?

            mockClear。

            12. vi.mocked 會取代函式嗎?

            不會。它只告訴 TypeScript 這個值已經是模擬函式。

            13. 為什麼單元測試可以用 id 'abc',端對端測試卻不行?

            在單元測試裡資料存取層已被取代,所以 new ObjectId 永遠不會執行。端對端時它會拋出錯誤,回應是 500。

            14. 商品不存在的單元測試應該斷言什麼?

            結果是 null,而且寫入從來沒被呼叫。

            15. 回歸測試只適用於單元測試嗎?

            不是。它的意思是重跑每一個之前通過的測試,單元測試和端對端測試都一樣。

            接著是五個任務。打開答案之前,先把測試寫出來。

            A · 零的邊界。把一個已儲存商品的數量更新成 0。測試應該斷言什麼?

            Promise 會解析,寫入的監視函式收到 ('abc', { quantity: 0 }),而且只被呼叫一次。0 是允許的;只有小於 0 的值會被拒絕。

            B · 不小心的整筆更新。請求只送了價格。怎麼抓到把每個欄位都傳下去的服務?

            斷言確切的引數,toHaveBeenCalledWith('abc', { price: 12.5 })。多一個欄位就會失敗;expect.objectContaining 則會讓它過關。

            C · 資料庫故障。findProduct 被拒絕。測試應該檢查什麼?

            讓 findProduct 用 mockRejectedValue(new Error('Database unavailable')) 回答,再 await rejects.toThrow 並比對那則訊息,最後檢查寫入的監視函式從未被呼叫。

            D · 預測。next 是 vi.fn().mockReturnValue('normal').mockReturnValueOnce('first')。測試先呼叫 next(),再呼叫 next.mockClear(),然後再呼叫一次 next()。兩次的結果和最後的呼叫次數分別是什麼?

            先是 'first',再來是 'normal',次數是 1。mockClear 把第一次呼叫從紀錄裡清掉了;一次性答案則早已用掉。

            E · 每個單元測試都通過,但端對端的 PATCH 回傳的形狀不對。問題可能在哪裡?

            在單元測試取代掉的範圍之外:資料存取層轉成 Product 的過程、控制器的回應、路由或中介軟體的設定,或初始資料。

            做完之後,兩個測試套件都要跑:先跑 npm run test:unit,接著是 npm test,並且除了顏色,也要看測試數量。

            第 6 週從這裡接續:弄清楚是誰在問。決定他能做什麼。

            重點整理

            說明各層的分工、PATCH 的三種結果,以及結果和副作用的差別。接著寫出零的邊界、確切引數的檢查、資料庫故障,並讀兩個測試套件的數量。

            檢查 · 先預測再選擇

            哪一個斷言單獨就能證明被拒絕的更新沒有寫入?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • expect(res.status).toBe(400) — 狀態碼回報了拒絕;有錯誤的服務仍可能先寫入了。
            • expect(update).toHaveBeenCalledWith('abc', { price: -1 }) — 那是斷言寫入有發生,和你要的正好相反。
            • expect(update).not.toHaveBeenCalled() — 它直接問那個唯一能寫入的函式,到底有沒有被呼叫過。

            來源依據 · 第 5 週筆記(Testing,2026 年 10 月 6 日擷取),以及 Vitest 的模擬函式指南。Python 範例與自行撰寫的測試都實際執行過。

            HW2

            HW2 · 使用 MongoDB 的 SliceDrop:每項任務要求什麼、課程在哪裡教過、怎麼自我檢查

            作業 2:先規劃,再動手。

            從 HW1 改變的三件事

            作業 2 沿用 作業 1 (四個端點、兩種角色)裡的 SliceDrop API,並改變三件事。公開的 HW2 作業說明 ↗ 就是合約。本章把其中每個部分對應到教過它的課程內容,並只用課堂練習的程式碼示範每個模式。本章不放任何作業程式碼,因為同學們正在寫這份作業,而這個頁面是公開的。

            在 HW1在 HW2課程在哪裡教過
            訂單存在記憶體中的陣列裡,重新啟動後就消失。訂單是 MongoDB 裡的文件。菜單的初始資料已經替你放好;下訂單會寫入一份新文件。第 3 週:將狀態移到資料庫
            大部分邏輯都寫在路由器裡。每一層一個資料夾:routes、controllers、services、db。每種邏輯都只有一個歸屬。第 4 週:每一層一項工作 與 第 5 週:四層,四項工作
            驗證是同步的:菜單是一個可以立刻搜尋的陣列。尋找菜單品項現在會回傳 Promise,所以訂單驗證程式要變成 async,所有呼叫它的地方也一樣。async 與 await 與 第 5 週:三種結果

            餐廳也變大了。菜單加入更大的尺寸、有價格的餅皮、高級配料與甜點。員工可以完整地新增、讀取、修改、刪除菜單,也能推進訂單的狀態,而每筆訂單在下單時就計算好價格。

            剛複製下來的專案無法編譯,這是故意的。 作業說明指出 npm run typecheck 剛好在兩個地方失敗。菜單型別還無法描述新菜單,而 HW1 的訂單驗證程式壞掉,因為尋找菜單品項現在會回傳 Promise。讓型別描述資料(資料是正確的,所以不要改它),把驗證程式改成 async,編譯器就安靜了。從那之後,測試就是你的待辦清單。

            要記住的 Promise 規則:會 await 的函式必須是 async,而呼叫它的地方也必須 await 它。最底層的一個 Promise 會一路往上傳。忘記 await 的呼叫端拿到的是 Promise,不是答案,而 Promise 永遠是 truthy。

            重點整理

            HW2 保留 HW1 的合約,改變三件事:資料放在哪裡(MongoDB)、每種邏輯放在哪裡(每一層一個資料夾),以及答案什麼時候準備好(驗證現在是非同步的)。剛複製的專案上那兩個型別檢查錯誤,就是第一項任務。

            檢查 · 先預測再選擇

            在剛複製的專案上,npm run typecheck 在 HW1 的訂單驗證程式中失敗。作業說明指出的原因是什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • HW2 的驗證規則改變了 — 作業說明說規則不變。改變的只有時機。
            • 尋找菜單品項現在會回傳 Promise,所以驗證程式必須變成 async — 在資料庫中查詢是非同步的,而驗證程式會呼叫它,所以驗證程式必須 await,也必須是 async。
            • MongoDB 沒有在執行 — 型別檢查不會連線到任何東西。它只讀程式碼裡的型別。

            每項任務都有一層和一堂課

            儲存庫中的 TODO 註解寫著細節;作業說明寫著合約。這張表把每項任務放進它所屬的那一層,指出教過這個模式的課程內容,並列出作業說明給它的狀態碼。

            任務層課程在哪裡教過狀態碼
            描述新菜單:甜點類別,以及有價格的餅皮models(型別)聯集型別 與 型別描述你的假設無。npm run typecheck 就是測試。
            把訂單驗證改成 async;規則不變validation(驗證)HW1 的驗證 與 await不合法的訂單回 400
            驗證菜單主體:建立時要有每個必填欄位,修改時只檢查有送來的欄位validation(驗證)收集所有錯誤 與 PATCH 只改送來的部分不合法的主體回 400
            接好每個路由:token 中介軟體、id 檢查,然後是控制器routes(路由)路由器, 中介軟體的順序 與 控制器之前的防護缺少或錯誤的 token 回 401;格式錯誤的訂單 id 回 400
            建立、修改與刪除菜單品項controllers,然後是 db建立與刪除 與 名稱已被使用時回 409201、200、204(沒有主體);400、404、409
            下訂單、列出訂單(可依狀態篩選)、讀取一筆、修改它的狀態controllers、services、db依 id 的 GET 與 格式與是否存在201 附上總價、200;400、404
            計算訂單價格services(服務)每一層一項工作沒有自己的狀態碼。總價放在 201 裡一起回傳。
            儲存與讀取菜單品項和訂單db(資料存取層)CRUD 與 放在 HTTP 路由後面的 Mongo無。它回傳一個值、null 或布林值,由控制器選擇狀態碼。

            表中有兩件事很容易漏看。菜單沒有服務層:它的控制器直接呼叫資料層,因為菜單沒有業務規則,而只負責轉交呼叫的一層沒有任何作用。另外,端點表裡沒有 403。使用 HW1 的共用密碼時,缺少或錯誤的 token 是 401;403 要到有角色的 第 6 週.

            每個錯誤回應仍然是 JSON,帶有 error 或 errors 欄位,絕不是 HTML,和 HW1 一樣。

            重點整理

            每項 HW2 任務都只在一層:型別在 models,規則在 validation,接線在 routes,狀態碼在 controllers,計價在 services,Mongo 呼叫在 db。先找出是哪一層,再打開教過它的課程內容。

            檢查 · 先預測再選擇

            一位員工建立了一個 id 已經存在的菜單品項。回 409 的判斷應該放在哪裡?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 放在菜單主體驗證程式裡,和其他 400 檢查放在一起 — 作業說明說驗證只檢查格式。id 是否已被使用需要查詢,而它的答案是 409,不是 400。
            • 放在路由器裡,在控制器之前 — 路由器只負責把中介軟體和控制器接在一起。它不查詢任何東西。
            • 放在控制器裡,在驗證通過之後 — 作業說明把它定為控制器的問題:主體格式正確,只有查詢才能知道 id 是否還沒被使用。

            每個模式,都用課堂程式碼示範

            HW2 需要的每個模式,都先出現在課堂練習裡。下面每段摘錄都複製自課堂儲存庫,並標明檔案與行號。先讀它連到的課程內容,再為 SliceDrop 寫出你自己的版本。

            資料存取層:Mongo 進,單純的值出

            第 6 週的 products 資料存取層,是資料層的範本。每個函式只做一次 Mongo 呼叫,並回傳一個單純的值:轉換過的文件、null 或布林值。它從不選擇狀態碼。

            TypeScript · 第 6 週 · src/db/products-repository.ts:12–22
            const toProduct = (doc: Document): Product => {
              return {
                id: doc._id.toString(),
                modelName: doc.modelName,
                modelNumber: doc.modelNumber,
                manufacturer: doc.manufacturer,
                color: doc.color,
                price: doc.price,
                quantity: doc.quantity
              };
            };

            toProduct 是唯一知道 _id 的函式。它把 ObjectId 轉成字串 id,所以上面的任何一層都不會碰到 Mongo 的型別(id).

            TypeScript · 第 6 週 · src/db/products-repository.ts:72–77
            export const deleteProduct = async (id: string): Promise<boolean> => {
              const result = await getDb()
                .collection(PRODUCTS)
                .deleteOne({ _id: new ObjectId(id) });
              return result.deletedCount === 1;
            };

            刪除會回報有沒有刪掉東西,再由控制器決定這在 HTTP 中代表什麼。新增也是一樣:insertOne 交回 insertedId,它會變成新的字串 id(同一個檔案的第 36–56 行)。更新使用 findOneAndUpdate 搭配 $set 與 returnDocument: 'after',沒有符合的文件時交回 null(第 58–70 行;跟著一個 PATCH 請求走).

            409 是一個有名字的錯誤

            TypeScript · 第 6 週 · src/services/auth-service.ts:25–29
              // 1. is the username free?
              const existing = await usersRepository.findUserByUsername(username);
              if (existing) {
                throw new ConflictError('That username is taken');
              }
            TypeScript · 第 6 週 · src/middleware/error-handler.ts:27–30
              if (err instanceof ConflictError) {
                res.status(409).json({ error: err.message });
                return;
              }

            註冊時會檢查使用者名稱是否已存在,存在就拋出 ConflictError;錯誤處理器會把這個類別轉成 409(註冊使用者)。這個檢查問的是已儲存的資料,不是請求的格式,所以它放在驗證之後、寫入之前。第 6 週在服務裡問這個問題;HW2 的作業說明則把同一類問題交給菜單控制器。

            刪除成功回 204,沒有主體

            TypeScript · 第 4 週 · src/controllers/product-controllers.ts:23–30
            const deleteProduct = async (req: Request, res: Response): Promise<void> => {
              const deleted = await productService.remove(String(req.params.id));
              if (!deleted) {
                res.status(404).json({ error: 'No product with that id' });
                return;
              }
              res.status(204).send();
            };

            沒有刪掉任何東西時,控制器回 404。否則回 204,並呼叫什麼都不放的 send(),因為 204 不帶主體(追蹤建立與刪除流程).

            防護在控制器之前依序執行

            TypeScript · 第 6 週 · src/routes/products-router.ts:16–21
            productsRouter.patch(
              '/:id',
              requireJwt,
              requireRole('staff'),
              productsController.patchProduct
            );
            TypeScript · 第 4 週 · src/routes/product-routes.ts:9
            productRouter.get('/:id', validateId, productControllers.getProduct);

            路由由左到右列出它的中介軟體,每一個要嘛回應,要嘛呼叫 next (中介軟體要嘛繼續,要嘛回應)。第 6 週先檢查你是誰,再檢查你可以做什麼,然後才執行控制器。第 4 週在控制器查詢之前,先檢查 id 的格式。HW2 的作業說明把兩者合在一起:先是 token 中介軟體,然後是 id 檢查,最後是控制器。先問你是誰,再問你的請求格式對不對。

            篩選讀取 req.query

            TypeScript · 第 6 週 · src/controllers/products-controller.ts:8–12
              if (req.query.lowStock === 'true') {
                res.json(await productsService.getLowStockProducts());
                return;
              }
              res.json(await productsService.getAllProducts());

            查詢參數的值都以字串送來,所以課堂程式碼是和字串 'true' 比較。篩選是選擇性的:沒有查詢參數時,控制器回傳全部。HW2 的 GET /orders?status= 是同一個概念,只是換了欄位。決定由哪一層負責篩選,並準備好說明原因。

            非同步錯誤會到達錯誤處理器

            TypeScript · 第 6 週 · src/services/products-service.ts:55–57
              if (quantity < 0) {
                throw new ValidationError('quantity cannot be negative');
              }
            TypeScript · 第 6 週 · src/app.ts:13
            app.use(errorHandler);

            服務在 async 函式裡拋出錯誤,而 await 它的控制器沒有攔截。Express 5 看到被拒絕的 Promise,就把錯誤交給錯誤處理器,而錯誤處理器是最後註冊的,在路由器之後(錯誤處理放最後)。在 Express 4 中,你必須自己攔截錯誤並呼叫 next(err)。你的程式碼仍然必須做的一件事是 await:沒有人 await 的 Promise 裡的錯誤,永遠到不了錯誤處理器(三種結果).

            重點整理

            課堂程式碼已經示範了 HW2 的每個模式:隱藏 _id、回傳 null 或布林值的資料存取層,轉成 409 的具名錯誤,沒有主體的 204,列在控制器之前的防護,從 req.query 讀取的篩選,以及由 Express 5 交給錯誤處理器的非同步錯誤。

            檢查 · 先預測再選擇

            第 6 週的 deleteProduct 資料存取層函式回傳 result.deletedCount === 1。哪一層把 false 轉成 404?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • Controller — 控制器是說 HTTP 的那一層。它讀取布林值,選擇 404 或 204。
            • 資料存取層,呼叫 res.status(404) — 資料存取層沒有 res。它只和 Mongo 溝通,並回傳單純的值。
            • 錯誤處理器,因為 false 是一個錯誤 — 沒有東西被拋出。false 是一個普通的答案,由控制器讀取。

            一次建好一層,讓檢查帶路

            1. 先寫型別。 讓 model 型別描述新菜單。npm run typecheck 是你的第一個測試:它不需要資料庫,也不需要伺服器,而通過它就值 120 分中的 20 分。
            2. 非同步的修改。 把訂單驗證程式改成 async。規則不變。現在型別檢查應該會通過。
            3. 一次一層。 作業說明建議這個順序:訂單資料層、兩個路由器、下訂單與計價,最後是菜單 CRUD。先完成一層,再從上一層呼叫它。
            4. 寫的時候只跑一個測試套件。 npm test orders 只跑訂單的測試套件,和 HW1 一樣,而 npm run test:watch 會在每次存檔時重新執行。盡量少推送來取得自動評分回饋,先在你的電腦上讓 npm test 通過。
            5. 觀察資料。 用 Postman 送請求時,讓 Compass 開著你的開發資料庫,看每筆訂單出現。npm run dev 需要你本機的 MongoDB(第 3 週的設定); npm test 則不需要,因為測試會啟動自己的記憶體內 Mongo(MongoMemoryServer).

            改程式碼之前,先讀失敗訊息。 型別檢查錯誤會指出檔案、行號與錯誤代碼;讀懂紅色的 TypeScript 錯誤 示範怎麼讀每個部分。失敗的測試會指出測試名稱、預期值與實際收到的值;讀懂 Vitest 的失敗訊息 逐行解讀了一個例子。判斷是哪一層產生了錯誤的值,再打開那一層。

            作業說明這樣分配 120 分:

            評分項目分數
            菜單測試套件25
            訂單測試套件25
            身分驗證測試套件25
            錯誤測試套件25
            npm run typecheck 通過20
            總計120

            同一個測試套件內的部分分數,依你通過的測試比例計算,所以大部分轉綠的套件仍能拿到 25 分中的大部分。截止日期在 Canvas 上;本指南不寫任何截止日期。

            重點整理

            從型別開始,因為型別檢查隨時可以免費執行,而且值 20 分。接著把驗證改成非同步,一次建好一層,一次跑一個測試套件,並在改程式碼之前,從每個失敗訊息找出是哪一層造成的。

            檢查 · 先預測再選擇

            一個測試對格式正確但找不到資料的 id 預期 404,卻收到 500。你先看哪裡?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • token 中介軟體 — token 問題是 401,而且它會在任何查詢之前就擋下請求。
            • model 型別 — 型別在程式碼執行之前就被刪除了,所以它們不可能產生狀態碼。
            • 控制器與它呼叫的查詢:某處拋出了錯誤,而不是回傳 null — 500 是錯誤處理器對它不認得的錯誤所給的答案。找不到的文件應該以 null 回來,並在控制器中變成 404。

            準備好說明它

            講師可以對任何一份 HW2 作業提出這些問題。每個答案都來自課堂程式碼或本指南的課程內容,所以你可以在不打開任何人作業的情況下練習。先把每個答案說出來,再讀它。

            哪一層決定狀態碼?

            控制器。資料存取層回傳文件、null 或布林值。服務回傳值,或拋出具名錯誤。控制器,或處理被拋出錯誤的錯誤處理器,把這些轉成 HTTP。第 4 週的刪除在控制器中把 false 轉成 404;第 6 週的錯誤處理器把 ConflictError 轉成 409(每一層只負責一件事).

            為什麼要在碰資料庫之前先驗證?

            錯誤的請求不應該改變任何東西。先拒絕它,就不會有只寫了一半的文件(第 4 週備忘錄中的錯誤 示範了不這樣做會發生什麼),而驗證之後的程式碼可以相信它查詢的東西存在,就像作業說明談到計價時所說的。

            為什麼資料存取層要隱藏 _id?

            為了讓 Mongo 只待在一個資料夾裡。第 6 週的 toProduct 把 _id 轉成字串 id,而控制器、服務與測試處理的都是單純的物件,從不持有 ObjectId。如果換了資料庫,只有一個資料夾需要改。

            id 的格式與是否存在有什麼不同?

            格式是關於字串的問題:它是不是 24 個十六進位字元?id 檢查在任何查詢之前就以 400 回答。是否存在是要問資料庫的問題:有沒有文件擁有這個 id?只有查詢才能回答,而答案是沒有時,控制器回 404(id 的格式與是否存在是兩個問題).

            id 已被使用時,為什麼是 409 而不是 400?

            400 表示請求本身有錯:修正之後,它在哪裡都能成功。409 表示請求沒問題,但和已儲存的資料衝突:同一個主體在空的資料庫上會成功。第 6 週對已被使用的使用者名稱回 409,也是同樣的理由(註冊使用者).

            為什麼總價要和訂單一起儲存?

            作業說明把訂單比喻成收據:就算下週價格變了,這筆訂單當時花多少就是多少。在下單時計算一次總價,過去就不會被改動。

            接下來,第 6 週 會用真正的帳號取代共用密碼,這才終於讓顧客能讀取自己的訂單。

            重點整理

            每個答案都指向一層:控制器決定狀態碼,驗證在資料庫之前執行,所以錯誤的請求不會改變任何東西,資料存取層隱藏 _id,讓 Mongo 只待在一個資料夾,格式錯誤是 400、不存在是 404,而和已儲存資料衝突是 409。

            檢查 · 先預測再選擇

            POST /menu 送出格式正確的主體,但它的 id 已經被使用。哪個答案正確?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 409,因為主體沒問題,但和已存在的品項衝突 — 同一個主體在空的資料庫上會成功。問題在於和已儲存資料的衝突。
            • 400,因為主體不合法 — 主體格式正確。修正它也不會改變什麼,因為它沒有任何錯。
            • 404,因為找不到這個 id — 這個 id 找到了,而這正是問題所在。

            來源依據 · 公開的 HW2 作業說明(2026 年 10 月 14 日閱讀,只有連結,沒有保留副本),以及第 4 週與第 6 週課堂練習儲存庫的程式碼,引用自已固定的快照。本章不含任何作業程式碼。

            W6

            第 6 週 · 密碼、bcrypt、JWT、角色與擁有者檢查

            弄清楚是誰在問。決定他能做什麼。

            兩個問題:你是誰?你可以做這件事嗎?

            第 6 週替第 5 週的商品 API 加上使用者帳號。「Auth」其實是兩件不同工作的簡稱,程式碼會把它們分開。

            本章在哪裡用戶端把請求送到伺服器。請求依序經過路由、中介軟體、控制器和服務;服務使用資料庫,回應再送回用戶端。這一章強調的部分:檢查 token 和角色的中介軟體,以及檢查被要求的東西屬於誰的服務。用戶端瀏覽器、Postman、curl請求回應伺服器路由中介軟體控制器服務資料庫中介軟體:token 有效嗎?角色對嗎?服務:這是呼叫者自己的嗎?本章
            這一章加上兩道檢查:中介軟體問是誰在呼叫、他是什麼角色;服務問這個東西是不是屬於他。

            標出地圖 · 先點名稱,再點空格

            每一道 auth 檢查在哪裡執行

            把每個名稱放到地圖上它該在的位置。

            名稱被遮住的本章地圖和上面同一張地圖,原本的名稱換成 5 個編號空格。1?瀏覽器、Postman、curl請求回應伺服器路由2?3?4?5?

            這個練習需要 JavaScript。上面的地圖標出了每個名稱的位置。

            工作問的問題什麼時候失敗狀態碼
            驗證身分這個請求是誰送的?沒有 token,或 token 驗證不過401
            檢查權限這個呼叫者可以做這件事嗎?token 有效,但角色不對,或呼叫者不是擁有者403

            HW1 和 HW2 用的是共用密碼:所有員工共用一個 token,所有顧客共用另一個。沒有人有帳號,所以伺服器分不出這位顧客和那位顧客,也無法限制顧客只能看自己的訂單。第 6 週把共用密碼換成登入時發給單一使用者的 token。

            HW1 讀的是同一個 Authorization 標頭,裡面放的是共用的 token。

            這一週結束時,API 有五個新增或加上保護的路由:

            路由保護回答
            POST /auth/register無201 與公開的使用者資料;缺少欄位時 400;使用者名稱已被使用時 409
            POST /auth/login無200 與一個 token;使用者名稱或密碼錯誤時 401
            GET /auth/me有效的 token200 與 token 的內容;否則 401
            POST /products有效且角色為員工的 token員工得到 201;沒有 token 或 token 有問題時 401;顧客得到 403
            GET /users/:id有效的 token,再加上服務裡的擁有者檢查員工可以讀任何人;顧客只能讀自己;其他情況 403;沒有這個 id 的使用者時 404

            重點整理

            身分驗證問的是誰在呼叫,失敗時回應 401。權限檢查問的是這個呼叫者能不能做這件事,失敗時回應 403。第 6 週把 HW1 的共用 token 換成每位登入使用者各自的 token。

            檢查 · 先預測再選擇

            一位已登入的顧客帶著有效的 token 呼叫只有員工能用的 POST /products。會得到哪個狀態碼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 401,因為這位顧客沒有通過身分驗證 — token 是有效的,所以伺服器很清楚這是誰。
            • 403,因為伺服器知道這是誰,而答案是不行 — 身分驗證通過了,權限檢查沒過,這正是 403 的意思。
            • 201,因為只要 token 有效就夠了 — 有效的 token 只能證明身分;這個路由還要求員工角色。

            絕對不要儲存密碼

            處理密碼只有一條規則:絕對不要儲存密碼本身。要存的是一個可以拿來核對輸入密碼的東西,而且就算資料庫被偷,對小偷也沒有用。

            雜湊函式把輸入變成固定大小的指紋,而且是單向的:無法從輸出反推回輸入。想像一個非常簡單的雜湊來體會一下:單字的長度。

            輸入輸出
            apple5
            banana6
            mango5

            偷到這些輸出,攻擊者幾乎得不到什麼;而登入時輸入 apple 會再得到 5,剛好相符。這個玩具也顯示了弱點:mango 同樣得到 5。真正的雜湊有非常多種可能的輸出,錯的輸入幾乎不可能相符。

            bcrypt 是專為密碼設計的雜湊演算法。在數學之外,它還多做了兩件事。

            bcrypt 加上它做什麼它擋下什麼
            鹽在雜湊之前混入的隨機值,所以同一個密碼在每位使用者身上會得到不同的雜湊事先算好的常見密碼雜湊對照表,稱為彩虹表
            成本它是故意做得慢:成本 11 代表要做 2048 輪運算數百萬次的猜測;單次登入仍然只要不到一秒
            一個 bcrypt 雜湊 · 取自第 6 週筆記
            $2b$11$k.ywVRU10znZsfQTv5.zhOIrbnclw9eOuacaFbTQoVqKiGMfGhUEu
            \__/\/ \____________________/\_____________________________/
            alg cost        salt                      hash

            成本和鹽都存在輸出裡面,所以之後核對時,只需要這個存下來的字串。

            雜湊保護的是儲存起來的密碼。它不保護傳往伺服器途中的密碼:在課堂上,密碼是以明文經由 HTTP 傳送。公開的伺服器需要 HTTPS 來處理這件事,課程會在之後的週次談到。

            重點整理

            儲存 bcrypt 雜湊,絕不儲存密碼。雜湊是單向的;鹽讓相同的密碼得到不同的雜湊;成本讓猜測變慢。雜湊不保護傳輸中的密碼,那是 HTTPS 的工作。

            檢查 · 先預測再選擇

            兩位使用者用同一個密碼註冊。users 集合裡存的是什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 兩個一模一樣的 hashedPassword 字串 — 每個雜湊都有自己的隨機鹽,所以兩個字串不同。
            • 兩個不同的 hashedPassword 字串 — bcrypt 會在每個雜湊裡混入新的鹽,並把鹽存在輸出裡。
            • 用 JWT_SECRET 加密過的密碼 — 密碼是被雜湊,不是被加密,而且 JWT 的密鑰跟這件事無關。

            同一位使用者的三種形狀

            起始專案已經給了使用者的型別和使用者的資料存取層,因為它們和第 5 週商品的寫法相同。

            models/user.ts · 取自第 6 週筆記
            export type Role = 'customer' | 'staff';
            
            // A user as the app stores it. hashedPassword never leaves the server.
            export type User = {
              id: string;
              username: string;
              hashedPassword: string;
              role: Role;
            };
            
            // What the repository needs to create one: everything but the id.
            export type NewUser = {
              username: string;
              hashedPassword: string;
              role: Role;
            };
            
            // A user as the outside world sees it.
            export type PublicUser = {
              id: string;
              username: string;
              role: Role;
            };
            型別欄位用途
            Userid、username、hashedPassword、role資料存取層交回來的東西
            NewUser除了 id 以外的全部資料存取層建立使用者時需要的東西
            PublicUserid、username、role唯一可以離開伺服器的形狀

            Product 和 NewProduct 就是同樣的兩種形狀。PublicUser 是新的概念:回應只帶它,不帶別的。攻擊者拿到雜湊過的密碼做不了太多事,但把它傳來傳去沒有任何好處,所以它留在後端裡,只用於登入。

            db/users-repository.ts · 取自第 6 週筆記
            const toUser = (doc: Document): User => {
              return {
                id: doc._id.toString(),
                username: doc.username,
                hashedPassword: doc.hashedPassword,
                role: doc.role
              };
            };
            
            export const findUserByUsername = async (
              username: string
            ): Promise<User | null> => {
              const doc = await getDb().collection(USERS).findOne({ username });
              return doc ? toUser(doc) : null;
            };
            
            export const findUser = async (id: string): Promise<User | null> => {
              const doc = await getDb()
                .collection(USERS)
                .findOne({ _id: new ObjectId(id) });
              return doc ? toUser(doc) : null;
            };

            這個資料存取層做的事和商品的那個一樣。它的 toUser 函式把 MongoDB 文件轉成 User,所以 _id 不會離開資料庫這一層。注意 findUser 會用 id 建立 ObjectId,等到替服務寫單元測試時,這一點會再出現。

            重點整理

            User 是儲存的樣子,NewUser 是沒有 id 的 User,PublicUser 是沒有雜湊密碼的 User。只有 PublicUser 可以放進回應。

            檢查 · 先預測再選擇

            控制器可以在回應裡送出哪一種型別?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • User — User 帶著 hashedPassword,而它絕不離開伺服器。
            • NewUser — NewUser 同樣帶著 hashedPassword,而且還沒有 id。
            • PublicUser — 它有 id、username 和 role,完全沒有密碼欄位。

            註冊:檢查、雜湊、儲存

            POST /auth/register 接收使用者名稱和密碼,然後建立使用者。服務依序做三件事。

            services/auth-service.ts · 取自第 6 週筆記
            import bcrypt from 'bcryptjs';
            import jwt from 'jsonwebtoken';
            import * as usersRepository from '../db/users-repository.js';
            import { getJwtSecret } from '../config.js';
            import {
              AuthenticationError,
              ConflictError,
              ValidationError
            } from '../errors.js';
            import type { PublicUser } from '../models/user.js';
            
            const SALT_ROUNDS = 11;
            
            export const registerUser = async (
              username: string,
              password: string
            ): Promise<PublicUser> => {
              if (!username || !password) {
                throw new ValidationError('username and password are required');
              }
            
              // 1. is the username free?
              const existing = await usersRepository.findUserByUsername(username);
              if (existing) {
                throw new ConflictError('That username is taken');
              }
            
              // 2. hash the password
              const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
            
              // 3. save the user -- everyone who registers is a customer
              const user = await usersRepository.insertUser({
                username,
                hashedPassword,
                role: 'customer'
              });
            
              return { id: user.id, username: user.username, role: user.role };
            };
            情況服務回應
            缺少使用者名稱或密碼拋出 ValidationError400
            使用者名稱已經被使用拋出 ConflictError409
            其他情況把密碼雜湊、新增一位顧客、回傳 PublicUser201

            新的錯誤類別沿用第 5 週的做法:每一個都是 Error 的簡單子類別,唯一的工作就是讓人認得出來。

            errors.ts · 取自第 6 週筆記
            export class ValidationError extends Error {}
            
            // 409: the request clashes with something that already exists.
            export class ConflictError extends Error {}
            
            // 401: we don't know who you are -- no credentials, or bad ones.
            export class AuthenticationError extends Error {}
            
            // 403: we know who you are, and you're not allowed to do this.
            export class AuthorizationError extends Error {}
            middleware/error-handler.ts · 取自第 6 週筆記
            import type { NextFunction, Request, Response } from 'express';
            import {
              AuthenticationError,
              AuthorizationError,
              ConflictError,
              ValidationError
            } from '../errors.js';
            
            export const errorHandler = (
              err: Error,
              req: Request,
              res: Response,
              next: NextFunction
            ): void => {
              if (err instanceof ValidationError) {
                res.status(400).json({ error: err.message });
                return;
              }
              if (err instanceof AuthenticationError) {
                res.status(401).json({ error: err.message });
                return;
              }
              if (err instanceof AuthorizationError) {
                res.status(403).json({ error: err.message });
                return;
              }
              if (err instanceof ConflictError) {
                res.status(409).json({ error: err.message });
                return;
              }
              res.status(500).json({ error: 'Something went wrong' });
            };

            使用者名稱已被使用,既不是格式錯誤的請求,也不是伺服器的錯。它是和已經存在的東西衝突,HTTP 把這稱為 409 Conflict。

            第 5 週介紹了這個錯誤處理器,當時只有一種錯誤型別。

            controllers/auth-controller.ts · 取自第 6 週筆記
            export const postRegister = async (
              req: Request,
              res: Response
            ): Promise<void> => {
              const user = await authService.registerUser(
                req.body?.username,
                req.body?.password
              );
              res.status(201).json(user);
            };

            控制器只把兩個欄位往下傳,其他都不傳。請求沒有主體時,req.body?.username 會得到 undefined 而不是當掉,所以由服務自己的檢查回應 400。

            每一位註冊的人都是顧客。角色由服務寫入,從不讀取請求裡的值,所以用戶端無法把自己註冊成員工。

            重點整理

            registerUser 檢查欄位(400)、檢查使用者名稱是否可用(409)、用 bcrypt 把密碼雜湊,然後儲存一位顧客。它回傳 PublicUser,控制器回應 201。

            檢查 · 先預測再選擇

            用戶端把使用者名稱 ada、一個密碼,以及 role staff 送到 /auth/register。這個名稱沒有人用。會存下什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 一位名叫 ada 的顧客 — 控制器只傳 username 和 password,而服務永遠寫入顧客角色。
            • 一位名叫 ada 的員工 — 主體裡的 role 從來不會被讀取;員工帳號只來自種子腳本。
            • 什麼都沒有;多出來的欄位會得到 400 — 服務只驗證 username 和 password 是否存在;多出來的欄位會被忽略。

            登入:比對,然後交出 token

            POST /auth/login 檢查使用者名稱和密碼。這是完成後的函式;內容裡的 role 會在角色那一課說明。

            services/auth-service.ts · 取自第 6 週筆記
            export const logIn = async (
              username: string,
              password: string
            ): Promise<string> => {
              const user = await usersRepository.findUserByUsername(username);
              if (!user) {
                throw new AuthenticationError('Wrong username or password');
              }
            
              const matches = await bcrypt.compare(password, user.hashedPassword);
              if (!matches) {
                throw new AuthenticationError('Wrong username or password');
              }
            
              return jwt.sign(
                { sub: user.id, username: user.username, role: user.role },
                getJwtSecret(),
                { expiresIn: '1h' }
              );
            };

            bcrypt.compare 從存下來的雜湊裡讀出鹽和成本,用同樣的方式把輸入的密碼雜湊,最後得到 true 或 false。明文密碼從不儲存,也從不直接比對。

            兩種失敗都拋出同一個 AuthenticationError,訊息也相同。如果說出是哪一半錯了,等於告訴攻擊者哪些使用者名稱存在。

            請求帶著狀態碼主體
            正確的使用者名稱和密碼200{ token }
            沒有人使用的使用者名稱401Wrong username or password
            正確的使用者名稱、錯誤的密碼401Wrong username or password

            為什麼要交東西回去?HTTP 請求是無狀態的:這個請求一結束,伺服器就不記得這次登入。所以伺服器給用戶端一個能證明使用者是誰的 token,用戶端之後每個請求都帶著它。

            controllers/auth-controller.ts · 取自第 6 週筆記
            export const postLogin = async (
              req: Request,
              res: Response
            ): Promise<void> => {
              const token = await authService.logIn(
                req.body?.username,
                req.body?.password
              );
              res.json({ token });
            };

            重點整理

            logIn 找到使用者,讓 bcrypt.compare 用存下來的雜湊核對密碼,然後簽發 token。使用者名稱錯和密碼錯會得到同樣的 401 和同樣的訊息。

            檢查 · 先預測再選擇

            有人用不存在的使用者名稱登入。回應會怎麼說?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 404,沒有這個使用者名稱的使用者 — 那等於確認了哪些使用者名稱存在,共用訊息就是要避免這件事。
            • 400,使用者名稱無效 — 400 是給缺少資料或格式錯誤的請求;這個請求格式沒有問題。
            • 401,使用者名稱或密碼錯誤 — 兩種失敗都拋出同一個 AuthenticationError,錯誤處理器把它對應到 401。

            JWT:誰都能讀,只有伺服器做得出來

            JSON Web Token 有三個部分,用句點分開:標頭、內容、簽章。

            登入得到的 token · 取自第 6 週筆記
            eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2NjUwYTFjMmU0YjBmM2E5ZDFjMmIzYTQiLCJ1c2VybmFtZSI6ImFkYSIsImlhdCI6MTc5MTY4OTk5MiwiZXhwIjoxNzkxNjkzNTkyfQ.FkWpv-Lu9SI1HzD4bCSbVrKYshbsOGWi_sxfbWhqQrg
            部分解碼後它的工作
            標頭{"alg":"HS256","typ":"JWT"}token 的種類,以及簽章的方式
            內容{"sub":"6650a1c2e4b0f3a9d1c2b3a4","username":"ada","iat":1791689992,"exp":1791693592}這個 token 說的是誰;每個欄位稱為一個聲明
            簽章不是 JSON由標頭、內容,加上只有伺服器知道的密鑰計算出來

            標頭和內容看起來不像 JSON,是因為它們經過 base64url 編碼。那是一種轉寫,不是鎖:沒有金鑰,任何人都能轉回來。token 放在 HTTP 標頭裡傳送,而 JSON 的引號、大括號和空白在那裡會造成麻煩。

            base64 · 取自第 6 週筆記
            Hi!   →   SGkh
            聲明意義
            sub主體:這是誰的 token。這裡是使用者的 id
            iat簽發時間:token 是何時簽的
            exp到期時間:token 何時不再被接受
            username, role這個 app 為了自己使用而加上的聲明

            所以內容是公開的。絕對不要把密碼或其他秘密放進去。保護 token 的是簽章:標頭或內容只要改一個字元,簽章就不再相符,而只有擁有密鑰的人才算得出相符的簽章。

            jwt.io 可以在瀏覽器裡把 token 解碼 ↗

            把你自己登入得到的 token 貼到那裡,就能讀到它的內容。不要貼上真實系統正在使用的密鑰。

            重點整理

            JWT 是 header.payload.signature。前兩部分只是編碼,誰都讀得到。簽章需要伺服器的密鑰,所以別人改不了 token,也做不出新的。

            檢查 · 先預測再選擇

            有人複製了一位顧客的 token,但沒有伺服器的密鑰。他做不到什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 把角色改成員工,並讓伺服器接受 — 修改內容會讓簽章失效,而新的簽章需要密鑰。
            • 讀出內容裡的使用者名稱 — 內容只是 base64url 編碼,任何人都能解碼。
            • 查出 token 何時到期 — exp 聲明就放在讀得到的內容裡。

            密鑰放在環境裡

            簽章需要密鑰,而密鑰不放在程式碼裡,因為程式碼會上傳到 GitHub。密鑰放在環境裡:一組存在於程式碼之外、程式執行時才讀取的變數。

            檔案git 裡有嗎?內容
            .env.example有一個佔位值和說明
            .env沒有;.gitignore 把它排除了你真正的 JWT_SECRET
            vitest.config.ts有只有測試使用的固定密鑰
            終端機 · 取自第 6 週筆記
            cp .env.example .env
            
            node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

            第二個指令會把 32 個隨機位元組印成文字。把那個值放進 .env,並完全保留這一行的形狀:名稱、前後沒有空白的等號,以及放在引號裡的值。

            .env · 取自第 6 週筆記
            JWT_SECRET="a23ae72558e4fafd21dcdfcf563edbf5a54ea69c027617d282af3d3dc4dbab32"

            Node 只有在被告知時才會讀 .env。package.json 裡的腳本傳入了這個旗標:

            package.json · 取自第 6 週筆記
            "dev": "tsx watch --env-file=.env src/index.ts",
            "start": "node --env-file=.env dist/index.js",
            config.ts · 取自第 6 週筆記
            // Settings that come from the environment rather than from the code.
            //
            // `npm run dev` and `npm start` load .env with Node's --env-file flag (see
            // package.json). The tests set their own value in vitest.config.ts.
            
            export const getJwtSecret = (): string => {
              const secret = process.env.JWT_SECRET;
              if (!secret) {
                throw new Error('JWT_SECRET is not set. Copy .env.example to .env.');
              }
              return secret;
            };

            變數不存在時,getJwtSecret 會帶著清楚的訊息停下來。這比用 undefined 去簽每一個 token 好。

            測試從不讀 .env:它不在儲存庫裡,所以剛複製下來的專案或自動評分程式都沒有這個檔案。測試從 Vitest 的設定取得一個固定的密鑰。

            vitest.config.ts · 取自第 6 週筆記
            import { defineConfig } from 'vitest/config';
            
            export default defineConfig({
              test: {
                hookTimeout: 120_000,
                testTimeout: 20_000,
                // Tests never read .env -- it isn't in the repo, so a fresh clone or
                // the autograder wouldn't have one. They use this fixed secret instead.
                env: {
                  JWT_SECRET: 'test-secret'
                }
              }
            });

            監看模式會在程式碼改變時重新載入 app,而 .env 不是程式碼。修改 .env 之後,要用 Ctrl-C 停掉開發伺服器,再重新啟動。

            較舊的專案和許多教學會用名為 dotenv 的套件載入 .env,那是 Node 還不能自己讀這個檔案時的做法。語法不同,結果相同。

            重點整理

            JWT_SECRET 放在 git 會忽略的 .env 裡;.env.example 是提交到 git 的範本。Node 用 --env-file 載入它,config.ts 讀取它,測試則使用 vitest.config.ts 裡的固定密鑰。修改 .env 之後要重新啟動開發伺服器。

            檢查 · 先預測再選擇

            你在 npm run dev 執行中修改了 .env 裡的 JWT_SECRET。用舊密鑰簽的 token 仍然有效。為什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 驗證 token 不需要密鑰 — jwt.verify 會用密鑰重新計算簽章;整個檢查就是這件事。
            • 監看程式只在程式碼改變時重新載入,所以伺服器手上還是舊的值 — 環境是在程序啟動時讀取的;重新啟動伺服器才會載入新的密鑰。
            • 伺服器執行時,vitest.config.ts 會蓋過 .env — 那份設定只對測試生效,對 npm run dev 沒有作用。

            requireJwt:先檢查 token,再做其他事

            需要已登入使用者的路由,必須在其他事情發生之前先檢查 token。這是中介軟體的工作。

            middleware/require-jwt.ts · 取自第 6 週筆記
            import type { NextFunction, Request, Response } from 'express';
            import jwt from 'jsonwebtoken';
            import { getJwtSecret } from '../config.js';
            import { AuthenticationError } from '../errors.js';
            
            export const requireJwt = (
              req: Request,
              res: Response,
              next: NextFunction
            ): void => {
              const header = req.headers.authorization;
              if (!header || !header.startsWith('Bearer ')) {
                throw new AuthenticationError('No token');
              }
              const token = header.slice('Bearer '.length);
            
              try {
                res.locals.user = jwt.verify(token, getJwtSecret());
              } catch {
                throw new AuthenticationError('Invalid or expired token');
              }
            
              next();
            };
            1. 讀取 Authorization 標頭。如果它不存在,或不是以 Bearer 開頭,就拋出 AuthenticationError。
            2. 把前綴切掉。剩下的就是 token。
            3. jwt.verify 用密鑰重新計算簽章,並檢查 exp 聲明。任何一項沒過它就會拋出錯誤,catch 區塊再改拋出 AuthenticationError。
            4. 成功時 verify 會回傳內容。把它存進 res.locals.user,這是 Express 提供給中介軟體留資料給後面處理函式的地方。
            5. 呼叫 next(),讓請求繼續往下走。

            錯誤處理器會把每一個 AuthenticationError 變成 401,所以這個中介軟體自己從不碰 res.status。

            最簡單的受保護路由,就是告訴呼叫者 token 說他是誰。

            controllers/auth-controller.ts · 取自第 6 週筆記
            export const getMe = (req: Request, res: Response): void => {
              res.json(res.locals.user);
            };
            routes/auth-router.ts · 取自第 6 週筆記
            import express from 'express';
            import * as authController from '../controllers/auth-controller.js';
            import { requireJwt } from '../middleware/require-jwt.js';
            
            export const authRouter = express.Router();
            
            authRouter.post('/register', authController.postRegister);
            authRouter.post('/login', authController.postLogin);
            authRouter.get('/me', requireJwt, authController.getMe);

            中介軟體寫在路由裡、控制器的前面。只有 /me 列出 requireJwt,所以註冊和登入仍然對所有人開放,本來也必須如此。

            在 Postman 裡,到 Authorization 分頁選擇 Bearer Token,再貼上 token。Postman 會替你寫好標頭。

            重點整理

            requireJwt 從 Authorization 標頭讀出 Bearer token,驗證它的簽章和到期時間,並把內容存進 res.locals.user。任何問題都會變成 AuthenticationError,由錯誤處理器回應 401。

            檢查 · 先預測再選擇

            一個請求帶著標頭 Authorization: abc123,沒有 Bearer 前綴。會發生什麼事?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • jwt.verify 拋出錯誤,回應是 401 Invalid or expired token — 前綴檢查先失敗,所以 verify 根本不會執行。
            • 回應是 401 No token,而且 jwt.verify 從未被呼叫 — 標頭不是以 Bearer 前綴開頭,所以第一個檢查就拋出錯誤。
            • next() 會執行,而控制器找不到使用者 — 中介軟體在走到 next 之前就拋出錯誤,所以控制器不會執行。

            攻擊你自己的 token

            測試可以自己簽 token,因為測試知道測試用的密鑰。這讓測試可以拿出登入永遠不會發出的 token。

            __tests__/auth.e2e.test.ts · 取自第 6 週筆記
            const payload = { sub: '650000000000000000000001', username: 'ada' };
            
            it('200s with the username for a token we sign ourselves', async () => {
              const token = jwt.sign(payload, getJwtSecret());
            
              const res = await request(app)
                .get('/auth/me')
                .set('Authorization', `Bearer ${token}`);
            
              expect(res.status).toBe(200);
              expect(res.body.username).toBe('ada');
            });
            token測試怎麼做出它預期
            沒有 token不放 Authorization 標頭401
            不是 JWT送出 Bearer not-a-token401
            用錯誤的密鑰簽的jwt.sign(payload, 'not-the-secret')401
            已到期簽的時候加上 { expiresIn: -10 }401
            內容被改過把改過的內容重新編碼,並保留原本的簽章401
            來自真正的登入註冊、登入,使用拿到的 token200 與使用者名稱
            __tests__/auth.e2e.test.ts · 為本指南撰寫,以 Vitest 5.0 在第 6 週起始專案上實際執行
            // A negative expiresIn means it expired ten seconds ago.
            it('401s for a token that has expired', async () => {
              const token = jwt.sign(payload, getJwtSecret(), { expiresIn: -10 });
            
              const res = await request(app)
                .get('/auth/me')
                .set('Authorization', `Bearer ${token}`);
            
              expect(res.status).toBe(401);
            });
            __tests__/auth.e2e.test.ts · 為本指南撰寫,以 Vitest 5.0 在第 6 週起始專案上實際執行
            // Anyone can read and rewrite the payload, but the signature was made
            // from the original one, so it no longer matches.
            it('401s for a token whose payload has been edited', async () => {
              const [header, body, signature] = jwt
                .sign(payload, getJwtSecret())
                .split('.');
              const claims = JSON.parse(Buffer.from(body, 'base64url').toString());
              claims.username = 'mallory';
              const forgedBody = Buffer.from(JSON.stringify(claims)).toString(
                'base64url'
              );
            
              const res = await request(app)
                .get('/auth/me')
                .set('Authorization', `Bearer ${header}.${forgedBody}.${signature}`);
            
              expect(res.status).toBe(401);
            });

            內容被改過的這個測試,最能說明簽章的用途。它把一個正常的 token 從句點切開,解碼中間那一段,改掉使用者名稱,重新編碼,再接回原本的標頭和簽章。伺服器用收到的內容重新計算簽章,結果不再相符。

            __tests__/auth.e2e.test.ts · 為本指南撰寫,以 Vitest 5.0 在第 6 週起始專案上實際執行
            // The only test here that needs the database.
            it('200s with the username for a token from a real login', async () => {
              const credentials = { username: 'ada', password: 'correct-horse' };
              await request(app).post('/auth/register').send(credentials);
              const login = await request(app).post('/auth/login').send(credentials);
            
              const res = await request(app)
                .get('/auth/me')
                .set('Authorization', `Bearer ${login.body.token}`);
            
              expect(res.status).toBe(200);
              expect(res.body.username).toBe('ada');
            });

            前五個測試在試這把鎖。最後一個測試試的是登入發出來的鑰匙,也是唯一需要資料庫的測試。

            重點整理

            在測試裡自己簽 token,試遍 token 出問題的每一種方式:沒有、不是 JWT、密鑰錯誤、已到期、被改過。每一種都必須得到 401。另外再用一個測試真的註冊並登入,它必須得到 200。

            檢查 · 先預測再選擇

            requireJwt 被改成呼叫 jwt.decode,它只讀出內容而不檢查簽章。哪一個測試仍然會通過?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 用錯誤密鑰簽的 token 得到 401 — decode 會接受任何格式正確的 token,所以這個請求會得到 200,測試會失敗。
            • 內容被改過的 token 得到 401 — 再也沒有人拿內容去和簽章比對,所以修改不會被發現。
            • 真正登入得到的 token 回應 200 與使用者名稱 — 正常的 token 仍然能解碼出正確的使用者名稱,這也是為什麼只靠這個測試證明不了什麼。

            角色:requireRole 在 requireJwt 之後執行

            光是登入還不足以更動商品目錄。建立商品是員工的工作。依照呼叫者的角色決定他能做什麼,稱為角色型存取控制,簡稱 RBAC。

            註冊永遠只會建立顧客,所以起始專案附了一個腳本,在你的本機資料庫建立一位員工。在 MongoDB 執行時跑一次 npm run seed。它會建立使用者 staff,密碼是 staff-password,只供本機開發使用。

            登入時會把角色複製到 token 的內容裡,之後的請求不必再查一次資料庫就能檢查。在這個修改之前發出的 token 沒有角色:重新登入就能拿到有角色的 token。

            middleware/require-role.ts · 取自第 6 週筆記
            import type { NextFunction, Request, Response } from 'express';
            import { AuthorizationError } from '../errors.js';
            import type { Role } from '../models/user.js';
            
            type Middleware = (req: Request, res: Response, next: NextFunction) => void;
            
            // Not middleware itself: a function that MAKES middleware for one role.
            // Always runs after requireJwt, which has put the token's payload in
            // res.locals.user.
            export const requireRole = (role: Role): Middleware => {
              return (req: Request, res: Response, next: NextFunction): void => {
                if (res.locals.user.role !== role) {
                  throw new AuthorizationError(`Only ${role} may do this`);
                }
                next();
              };
            };

            requireRole 本身不是中介軟體。它是一個替某個角色製造中介軟體的函式。requireRole('staff') 只在路由器檔案載入時執行一次,而它回傳的函式會在每個請求上執行。

            routes/products-router.ts · 取自第 6 週筆記
            import express from 'express';
            import * as productsController from '../controllers/products-controller.js';
            import { requireJwt } from '../middleware/require-jwt.js';
            import { requireRole } from '../middleware/require-role.js';
            
            export const productsRouter = express.Router();
            
            productsRouter.get('/', productsController.getProducts);
            productsRouter.get('/:id', productsController.getProduct);
            productsRouter.post(
              '/',
              requireJwt,
              requireRole('staff'),
              productsController.postProduct
            );
            productsRouter.patch('/:id', productsController.patchProduct);
            productsRouter.delete('/:id', productsController.deleteProduct);

            動手重建 · 把程式行排好

            把關卡排好順序

            重建這個只讓員工建立商品的路由。

            顯示排好的程式碼
            productsRouter.post(
              '/',
              requireJwt,
              requireRole('staff'),
              productsController.postProduct
            );

            Express 會依照列出的順序執行路由的處理函式。requireJwt 弄清楚是誰在呼叫,requireRole 讀取那個答案,只有兩者都放行時控制器才會執行。

            控制器前面的兩道關卡一個 POST /products 請求由左到右遇到三個處理函式。沒有 token 或 token 有問題時,requireJwt 回應 401 Unauthorized。token 有效但角色不對時,requireRole 回應 403 Forbidden。只有員工 token 能到達控制器,由它回應 201 Created。POST /products處理函式由左到右執行requireJwt你是誰?requireRole可以嗎?控制器建立商品沒有 token,或 token 有問題角色不對員工 token401未通過驗證403禁止存取201已建立
            注意是哪一道關卡在回應:401 來自 requireJwt,403 來自 requireRole,這兩種情況控制器都不會執行。
            請求帶著狀態碼意思誰說的
            沒有 token,或 token 有問題401 Unauthorized我們不知道你是誰requireJwt
            有效的 token、不對的角色403 Forbidden我們知道你是誰,而答案是不行requireRole
            有效的員工 token201 Created請繼續商品控制器

            順序是行為的一部分。requireRole 讀的是 res.locals.user.role,而它只有在 requireJwt 存好內容之後才存在。如果兩者對調,res.locals.user 是 undefined,讀它的 role 會拋出 TypeError,錯誤處理器就會對所有人回應 500,連員工也一樣。

            第 2 週說明過,中介軟體的順序是行為的一部分。

            第 5 週那些新增商品的測試現在會以 401 失敗,因為 app 照著指示在做事。測試知道測試用的密鑰,所以在檔案開頭附近先簽好兩個 token。

            __tests__/products.e2e.test.ts · 取自第 6 週筆記
            // POST /products needs a staff token now -- and one customer token, to
            // check that a customer is turned away.
            const staffToken = jwt.sign(
              { sub: '650000000000000000000001', username: 'tester', role: 'staff' },
              getJwtSecret()
            );
            const customerToken = jwt.sign(
              { sub: '650000000000000000000002', username: 'ada', role: 'customer' },
              getJwtSecret()
            );
            
              it('401s without a token', async () => {
                const res = await request(app)
                  .post('/products')
                  .send({ modelName: 'Sneaky' });
            
                expect(res.status).toBe(401);
              });
            
              it('403s for a customer', async () => {
                const res = await request(app)
                  .post('/products')
                  .set('Authorization', `Bearer ${customerToken}`)
                  .send({ modelName: 'Sneaky' });
            
                expect(res.status).toBe(403);
              });

            應該成功的測試也用同樣的方式送出員工 token。選做的任務會替 PATCH 和 DELETE 加上同樣的兩道關卡,之後第 5 週的六個 PATCH 測試也需要員工 token。

            重點整理

            登入時把角色放進 token。requireRole(role) 回傳一個中介軟體,當 token 的角色不符時拋出 AuthorizationError,回應 403。它必須排在 requireJwt 之後,因為是 requireJwt 把內容放進 res.locals.user。

            檢查 · 先預測再選擇

            在 POST /products 上,requireRole 被列在 requireJwt 前面。一位員工送出有效的 token。會得到什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 201,因為 token 有效而且角色是員工 — requireRole 先執行,在 res.locals 裡找不到使用者,所以請求根本走不到那裡。
            • 403,因為 requireRole 看不到角色 — 讀取 undefined 使用者的 role 會拋出 TypeError,不是 AuthorizationError。
            • 500,因為讀取角色時拋出了預期之外的錯誤 — res.locals.user 還是 undefined,而錯誤處理器把任何不認得的錯誤對應到 500。

            擁有者檢查:角色表達不了的規則

            有些規則取決於被要求的東西屬於誰。顧客可以讀自己的個人資料,不能讀別人的;員工可以讀任何人的。角色檢查說不出這件事,因為兩位顧客的角色相同。

            services/users-service.ts · 取自第 6 週筆記
            import * as usersRepository from '../db/users-repository.js';
            import { AuthorizationError } from '../errors.js';
            import type { PublicUser, Role } from '../models/user.js';
            
            // Who is asking, as far as their token says.
            type Caller = {
              id: string;
              role: Role;
            };
            
            export const getUser = async (
              caller: Caller,
              id: string
            ): Promise<PublicUser | null> => {
              // Staff may read anyone. Everyone else may read only themselves.
              if (caller.role !== 'staff' && caller.id !== id) {
                throw new AuthorizationError('You can only see your own profile');
              }
            
              const user = await usersRepository.findUser(id);
              if (!user) {
                return null;
              }
              return { id: user.id, username: user.username, role: user.role };
            };

            這條規則放在服務裡,政策和業務規則本來就屬於那裡。就這個路由而言,它其實可以放在中介軟體,因為需要的資訊都在 token 和 URL 裡。但通常不是這樣。要判斷一筆訂單是否屬於呼叫者,必須先把訂單載入,再拿它的 customerId 和呼叫者的 id 比較。

            controllers/users-controller.ts · 取自第 6 週筆記
            import type { Request, Response } from 'express';
            import * as usersService from '../services/users-service.js';
            
            export const getUser = async (req: Request, res: Response): Promise<void> => {
              const caller = { id: res.locals.user.sub, role: res.locals.user.role };
              const user = await usersService.getUser(caller, String(req.params.id));
              if (!user) {
                res.status(404).json({ error: 'No user with that id' });
                return;
              }
              res.json(user);
            };

            控制器用 token 的內容組出呼叫者:sub 變成 id,角色照抄。服務收到的是一個單純的物件,完全不知道 token 或 HTTP,這正是單元測試可以直接呼叫它的原因。

            routes/users-router.ts · 取自第 6 週筆記
            usersRouter.get('/:id', requireJwt, usersController.getUser);
            呼叫者要求結果
            顧客 ada她自己的個人資料200
            顧客 adagrace 的個人資料403
            員工grace 的個人資料200
            員工沒有任何使用者擁有的 id404
            無人:沒有 token任何個人資料401

            端對端測試在每個測試之前新增 ada、grace 和 staff,再用每位使用者真正的 id 當作 sub 來簽 token。它們從不登入,所以那些佔位用的雜湊密碼從來不會被檢查。

            __tests__/users.e2e.test.ts · 取自第 6 週筆記
            it("403s for a customer reading someone else's profile", async () => {
              const token = jwt.sign(
                { sub: adaId, username: 'ada', role: 'customer' },
                getJwtSecret()
              );
            
              const res = await request(app)
                .get(`/users/${graceId}`)
                .set('Authorization', `Bearer ${token}`);
            
              expect(res.status).toBe(403);
            });
            __tests__/users.e2e.test.ts · 為本指南撰寫,以 Vitest 5.0 在第 6 週起始專案上實際執行
            it('200s for a customer reading their own profile', async () => {
              const token = jwt.sign(
                { sub: adaId, username: 'ada', role: 'customer' },
                getJwtSecret()
              );
            
              const res = await request(app)
                .get(`/users/${adaId}`)
                .set('Authorization', `Bearer ${token}`);
            
              expect(res.status).toBe(200);
              expect(res.body.username).toBe('ada');
            });
            
            it("200s for staff reading anyone's profile", async () => {
              const token = jwt.sign(
                { sub: staffId, username: 'staff', role: 'staff' },
                getJwtSecret()
              );
            
              const res = await request(app)
                .get(`/users/${graceId}`)
                .set('Authorization', `Bearer ${token}`);
            
              expect(res.status).toBe(200);
              expect(res.body.username).toBe('grace');
            });

            重點整理

            擁有者規則是拿呼叫者和被要求的東西比較:員工可以讀任何人,其他人只能讀自己。它放在服務裡,因為大多數擁有者檢查都需要資料庫裡的資料。路由只需要 requireJwt。

            檢查 · 先預測再選擇

            GET /orders/:id 只能回應下這筆訂單的顧客。這個檢查可以在哪裡執行?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 在服務裡,訂單載入之後 — 擁有者記在訂單上,所以要先讀出訂單,才能拿呼叫者去比對。
            • 在 requireRole 裡,用顧客角色 — 每位顧客都是顧客角色,所以角色分不出這位顧客的訂單和那位的。
            • 在中介軟體裡,只靠 token — token 只說是誰在呼叫,沒說訂單是誰下的;那是在資料庫裡。

            先檢查,再查詢

            端對端測試證明了顧客讀不到別人的個人資料。它證明不了服務是怎麼拒絕的,而這一點其實很重要。

            services/users-service.unit.test.ts · 取自第 6 週筆記
            import { beforeEach, describe, expect, it, vi } from 'vitest';
            import * as usersService from './users-service.js';
            import * as usersRepository from '../db/users-repository.js';
            import { AuthorizationError } from '../errors.js';
            import type { User } from '../models/user.js';
            
            beforeEach(() => {
              vi.restoreAllMocks();
            });
            
            // A user as the repository hands it back -- hashed password and all.
            const grace: User = {
              id: 'grace-id',
              username: 'grace',
              hashedPassword: '$2b$11$not-a-real-hash',
              role: 'customer'
            };
            
            it('never looks the user up when a customer asks for someone else', async () => {
              const findUser = vi
                .spyOn(usersRepository, 'findUser')
                .mockResolvedValue(grace);
            
              await expect(
                usersService.getUser({ id: 'ada-id', role: 'customer' }, 'grace-id')
              ).rejects.toThrow(AuthorizationError);
            
              expect(findUser).not.toHaveBeenCalled();
            });

            放在 findUser 上的監視函式做了三件事:不需要資料庫、由測試決定查詢回傳什麼、事後測試還能問查詢到底有沒有發生。像 grace-id 這樣的 id 在這裡沒有問題,因為真正的資料存取層需要 24 個字元的 ObjectId,而它根本沒有執行。

            第 5 週介紹了 vi.spyOn 和 mockResolvedValue。

            這裡有另一個看起來同樣合理的 getUser:先查詢使用者,再檢查。

            services/users-service.ts,兩個步驟對調 · 取自第 6 週筆記
              const user = await usersRepository.findUser(id);
              if (!user) {
                return null;
              }
              // Staff may read anyone. Everyone else may read only themselves.
              if (caller.role !== 'staff' && caller.id !== id) {
                throw new AuthorizationError('You can only see your own profile');
              }
            版本端對端的 403 測試單元測試顧客能得知什麼
            先檢查,再查詢通過通過什麼都得不到:其他所有 id 都回應 403
            先查詢,再檢查通過失敗:findUser 被呼叫了哪些 id 存在:不存在的 id 得到 404,真實的 id 得到 403

            對調後的版本在檢查呼叫者能不能問之前,就先回答了 id 不存在。這樣任何顧客都能試探 id,得知哪些屬於真正的使用者。端對端測試仍然是綠的,因為真實使用者得到的回應還是 403。單元測試變紅,因為它問了唯一能分辨這兩個版本的問題:資料庫有沒有被碰到?

            剩下的四個單元測試把這個函式其餘的契約固定下來。

            services/users-service.unit.test.ts · 為本指南撰寫,以 Vitest 5.0 在第 6 週起始專案上實際執行
            it('lets staff read anyone, and looks up exactly that id', async () => {
              const findUser = vi
                .spyOn(usersRepository, 'findUser')
                .mockResolvedValue(grace);
            
              const user = await usersService.getUser(
                { id: 'staff-id', role: 'staff' },
                'grace-id'
              );
            
              expect(user?.username).toBe('grace');
              expect(findUser).toHaveBeenCalledWith('grace-id');
            });
            
            it('lets a customer read themselves', async () => {
              vi.spyOn(usersRepository, 'findUser').mockResolvedValue(grace);
            
              const user = await usersService.getUser(
                { id: 'grace-id', role: 'customer' },
                'grace-id'
              );
            
              expect(user).toEqual({ id: 'grace-id', username: 'grace', role: 'customer' });
            });
            
            it('never returns the hashed password', async () => {
              vi.spyOn(usersRepository, 'findUser').mockResolvedValue(grace);
            
              const user = await usersService.getUser(
                { id: 'staff-id', role: 'staff' },
                'grace-id'
              );
            
              expect(user).not.toHaveProperty('hashedPassword');
            });
            
            it("returns null for a user that doesn't exist", async () => {
              vi.spyOn(usersRepository, 'findUser').mockResolvedValue(null);
            
              const user = await usersService.getUser(
                { id: 'staff-id', role: 'staff' },
                'nobody-id'
              );
            
              expect(user).toBeNull();
            });
            要檢查使用
            服務查的正是被要求的那個 idtoHaveBeenCalledWith('grace-id')
            不存在的使用者得到 nullmockResolvedValue(null)
            雜湊密碼絕不離開服務not.toHaveProperty('hashedPassword')
            被拒絕的呼叫者沒有引發任何查詢not.toHaveBeenCalled()

            重點整理

            在碰資料庫之前先檢查權限。先查詢會洩漏哪些 id 存在,而端對端測試看不出差別。單元測試可以:它監視 findUser,並斷言它從未被呼叫。

            檢查 · 先預測再選擇

            getUser 被改寫成先查詢使用者,再檢查呼叫者。顧客 ada 要求一個沒有任何使用者擁有的 id。她會得到什麼?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 403,和任何不屬於她的 id 一樣 — 那是正確的版本;改寫後的版本在走到檢查之前就回傳 null 了。
            • 404,這等於告訴她沒有使用者擁有這個 id — 查詢先執行並回傳 null,所以控制器送出 404,漏洞就此打開。
            • 401,因為她的 token 不能用在那個 id 上 — 她的 token 是有效的,所以 requireJwt 會讓請求通過。

            這一週沒有涵蓋的部分

            這裡的技巧只要用得正確就很可靠,但它們只是起點。要知道它們的界線在哪裡。

            缺口實際上代表什麼
            密碼以明文經由 HTTP 傳送在伺服器使用 HTTPS 之前,任何能監看網路的人都讀得到
            token 無法收回被偷的 token 在 exp 時間之前都有效;伺服器端沒有登出這回事
            角色在登入時被複製進 token被降級的使用者在 token 到期之前仍保有舊角色
            沒有更新機制一小時後使用者必須重新登入
            沒有重設、沒有電子郵件驗證、沒有第二重驗證忘記密碼或密碼被猜中都沒有補救辦法
            只有兩種角色沒有管理員,也沒有哪個角色能做哪個動作的對照表

            基於這些原因,大多數正式上線的 app 會使用 auth 函式庫或服務供應商,例如 Auth0、Logto 或 Firebase,它們會持續修補以對抗新的攻擊。這些概念原封不動地沿用:身分驗證與權限檢查、token、角色和擁有者檢查,在每一個方案裡都會出現。

            auth 的狀態碼在不同作業之間也不一樣。第 2 週的課堂程式對有問題的 token 回應 403,而 HW1 連角色錯誤都要求 401。第 6 週在不知道呼叫者是誰時用 401,知道是誰但拒絕時用 403。動手之前先讀每份作業的契約,不要自己假設。

            重點整理

            這一週的 auth 沒有 HTTPS、無法撤銷、沒有更新機制,也沒有密碼重設,而且角色變更要等舊 token 到期才生效。正式上線的 app 通常依靠 auth 服務供應商;概念是相同的。

            檢查 · 先預測再選擇

            一位員工在 2:00 被資料庫改成顧客。他的 token 是 1:30 發出的,效期一小時。2:15 時他送出 POST /products。會發生什麼事?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 403,因為資料庫現在說他是顧客 — requireRole 從 token 讀取角色,從不去問資料庫。
            • 201,因為在 2:30 之前 token 仍然說他是員工 — 角色在登入時被複製進 token,而這個 token 仍然有效。
            • 401,因為舊的 token 被取消了 — 這個設計裡沒有任何東西能在 token 到期前取消它。

            先說出來,再動手試

            每一題先大聲回答,再打開答案。

            1. 身分驗證還是權限檢查:哪一個回應 403?

            權限檢查。伺服器知道是誰在呼叫,而答案是不行。身分驗證失敗則回應 401。

            2. users 集合用什麼代替密碼來儲存?

            bcrypt 雜湊,它自己帶著鹽和成本。

            3. 鹽擋下了什麼?

            兩位密碼相同的使用者得到相同的雜湊,連帶也擋下了事先算好的雜湊對照表。

            4. 為什麼使用者名稱已被使用時,註冊回應 409 而不是 400?

            請求的格式沒有問題。它是和已經存在的東西衝突,這正是 Conflict 的意思。

            5. 為什麼使用者名稱錯和密碼錯會得到同樣的訊息?

            不同的訊息會讓攻擊者知道哪些使用者名稱存在。

            6. 沒有密鑰的人讀得到 JWT 的內容嗎?

            讀得到。它只是 base64url 編碼。密鑰是簽章時需要,不是讀取時。

            7. jwt.verify 檢查哪兩件事?

            簽章與標頭和內容相符,以及 exp 時間還沒過。

            8. requireJwt 把內容留在哪裡給後面的處理函式?

            在 res.locals.user。

            9. 為什麼測試不讀 .env?

            它不在儲存庫裡,所以剛複製下來的專案或自動評分程式都沒有。vitest.config.ts 設定了一個固定的測試密鑰。

            10. 為什麼 requireRole 是一個回傳中介軟體的函式?

            這樣同一段程式碼就能保護任何角色:角色在定義路由時就固定下來。

            11. 為什麼擁有者檢查放在服務裡?

            大多數擁有者檢查都需要存下來的紀錄,例如訂單的 customerId,而且政策本來就屬於服務。

            12. 哪一個斷言能證明被拒絕的呼叫者從未碰到資料庫?

            expect(findUser).not.toHaveBeenCalled(),用在放在資料存取層函式上的監視函式。

            這些任務來自兩個 Now You Try 活動和其中選做的部分。

            任務 A。替用錯誤密鑰簽的 token 寫測試。

            用任何其他字串簽這份內容,把它當作 Bearer token 送到 GET /auth/me,並預期 401。

            任務 B。把 DELETE /products/:id 鎖起來,只有員工可以刪除。

            在控制器之前先放 requireJwt,再放帶著員工角色的 requireRole。測試沒有 token 時 401、顧客 token 時 403、員工 token 時 204,之後再 GET 一次應該得到 404。

            任務 C。用同樣的方式把 PATCH /products/:id 鎖起來。第 5 週的六個測試變紅了。為什麼?怎麼修?

            它們沒有送 token,所以 requireJwt 在控制器執行之前就回應 401。在每個 PATCH 請求上把 Authorization 標頭設成員工 token。

            重點整理

            把密碼雜湊、登入時簽發 token、在中介軟體驗證它、在中介軟體檢查角色、在服務檢查擁有者,並且在任何查詢之前先檢查權限。401 表示不知道是誰;403 表示知道是誰但拒絕。

            檢查 · 先預測再選擇

            哪一組斷言能顯示顧客在資料庫被碰到之前就被拒絕了?

            先解釋,再比較推理

            查看前:說明為何你的選擇合適,以及另一選項為何不對。本指南無法評分你的解釋。選對只能證明這一題,不代表完全理解。

            • 回應狀態碼是 403,而且主體裡有錯誤訊息 — 先查詢使用者的服務也會送出同樣的回應。
            • getUser 以 AuthorizationError 拒絕,而且 findUser 上的監視函式從未被呼叫 — 第一個斷言檢查答案,第二個斷言檢查沒有發生任何查詢。
            • 回傳的使用者沒有 hashedPassword,而且使用者名稱正確 — 那些描述的是被允許的請求,不是被拒絕的請求。

            來源依據 · 第 6 週筆記(Auth,2026 年 10 月 13 日擷取)。自行撰寫的測試以 Vitest 5.0 在第 6 週起始專案上實際執行過。

            08

            練習實驗室 · 追蹤、預測、撰寫與檢查

            先試著回答,再揭曉。

            選擇一次讀書安排

            可用時間建議順序
            30 分鐘10 分鐘 HTTP 輸入/狀態碼 → 10 分鐘分層/ObjectId → 10 分鐘不看筆記回答回想題。
            90 分鐘15 分鐘第 1–2 章 → 15 分鐘中介軟體/HW1 → 20 分鐘 Mongo CRUD → 20 分鐘 REST 追蹤 → 20 分鐘測試/練習。
            多次讀書每次讀一章。大聲解釋程式碼、完成練習,能重現概念後才標記為有把握。

            每題先預測,再展開說明。要執行程式碼時,使用練習副本或隔離的測試資料庫,結束後還原暫時修改。此頁只是讀書工具,不會呼叫你的 API 或變更課程資料。

            動手重建

            這 14 題各自放在所屬的課程裡,就在它要重建的程式碼之後。程式行會打亂給你,你只要排好順序,不用打字。

            實驗 0 · 伺服器之前的 JavaScript

            J1. 一筆訂單送達時,quantity 是字串 2。order.quantity + 1 和 order.size 各會得到什麼?
            JavaScript
            const order = { pizza: 'cheese', quantity: '2' };
            console.log(order.quantity + 1);
            console.log(Number(order.quantity) + 1);
            console.log(order.size);
            Shell · 輸出
            $ node lab-j1.js
            21
            3
            undefined

            quantity 是字串,所以 + 會把文字接起來,得到 21。Number() 先把它轉成數字,之後 + 才會相加。size 從來沒設定過,所以是 undefined,不是錯誤。

            J2. 寫出 isCheap(product),價格低於 5 時回傳 true。用它印出便宜產品的名稱。
            JavaScript
            const products = [
              { name: 'Notebook', price: 3 },
              { name: 'Pencil', price: 1 },
              { name: 'Stapler', price: 9 }
            ];
            
            const isCheap = (product) => product.price < 5;
            
            const cheap = products.filter(isCheap);
            console.log(cheap.length);
            cheap.forEach((product) => {
              console.log(product.name);
            });
            Shell · 輸出
            $ node lab-j2.js
            2
            Notebook
            Pencil

            isCheap 是回呼函式。filter 對每個產品呼叫它一次,並留下它回傳 true 的產品。接著 forEach 對每個留下的產品呼叫一次自己的回呼函式。

            J3. 用白話說出這一行的意思:app.get('/menu', (req, res) => { res.send('menu'); });

            當針對 /menu 的 GET 請求送達時,執行這個函式。app.get 只是把函式存起來。Express 之後才會呼叫它,每個符合的請求一次,並把請求當成 req、回應當成 res 傳進去。res.send 會送出回覆。

            J4. const product = findProduct(2); 之後的 console.log(product.price); 印出 undefined。為什麼?要怎麼修?

            findProduct 回傳的是 Promise,而 Promise 沒有 price 欄位。在 async 函式裡或模組的最上層加上 await。修好的版本使用 JavaScript 章節的替身 db.js。

            JavaScript
            import { findProduct } from './db.js';
            
            const showPrice = async (id) => {
              const product = await findProduct(id);
              console.log(product.price);
            };
            
            await showPrice(2);
            Shell · 輸出
            $ node lab-j4.js
            9

            實驗 A · HTTP 與 TypeScript

            1. 標出每個輸入來源:POST /tracks?preview=true,帶有 JSON title 與 Authorization 標頭。

            方法是 POST;路徑是 /tracks;req.query.preview 是查詢輸入;req.body.title 是解析後的主體輸入;req.headers.authorization 是標頭輸入。這個路由沒有路徑參數。

            2. 寫出 /browse/:category 與查詢篩選,並解釋 /browse/frozen?keywords=organic+corn。
            JavaScript
            app.get('/browse/:category', (req, res) => {
              res.send(`Searching for ${req.query.keywords} in the ${req.params.category} category`);
            });

            category 是 frozen;keywords 解碼後是 organic corn。同一個路由模式可以服務多種類別。

            3. 為什麼郵遞區號必須是字串?為什麼數值年份篩選需要轉換?

            郵遞區號是可能以零開頭的識別碼。模型中的書籍年份是數字,查詢卻以文字傳入;嚴格相等比較前,必須先將單一字串查詢值轉成數字。

            4. 憑記憶重建 TypeScript 練習的六項功能。

            Book 介面;catalog: Book[];BookStatus 列舉;具型別的參數/回傳值;BookFilter = string | number 並用 typeof 縮小型別;泛型 getFirstItem<Item> 回傳 Item | undefined。將執行示範結果與原本未改動的 JavaScript 輸出比較。

            5. getFirstItem([]) 回傳什麼?const catalog 還能 push 嗎?

            空陣列回傳 undefined。可以 push:const 禁止重新指定 catalog,不禁止修改陣列。精確型別註記或非空輸入能提供推斷所需的元素型別。

            6. 將 "lcd,smart,sony" 轉成第 1 週的顯示文字。
            JavaScript
            const terms = 'lcd,smart,sony'.split(',').join(' AND ');
            // lcd AND smart AND sony

            實驗 B · 中介軟體與 HW1

            7. 預測第 2 週 POST /books 帶有錯誤身分驗證與空白 title 的結果;再預測 HW1 POST /orders 沒有身分驗證且主體為 {} 的結果。

            第 2 週為 403,HW1 為 401。兩者都在欄位驗證之前停在路由身分驗證。若 JSON 文字本身格式錯誤,則解析器更早攔下。

            8. parseBearerToken("Bearer nope") 會回傳 null 嗎?

            不會。格式合法,所以回傳 "nope"。角色中介軟體接著拒絕此候選值。解析與授權回答的是不同問題。

            9. 建立至少含三個錯誤的訂單。伺服器應如何回應?

            使用空白 customerName、未知 menuItemId 與 quantity 0,並帶上顧客 token。預期 400 與 errors 陣列,包含能安全找出的問題。沒有 token 時預期 401。不要為了找選項錯誤而讀取不存在的菜單品項。

            10. 追蹤一個送往未註冊路徑的請求。

            解析器在適用時執行,路由器沒有符合,接著一般的最後備用處理送出 404。不需要發生例外。四參數錯誤處理函式用於傳遞過來的錯誤。

            11. 建立訂單、重啟 Node,再列出訂單。預測結果。

            陣列只存在於該程序,因此新啟動的應用程式沒有儲存的訂單。使用員工身分驗證時,GET 回傳 200 與 []。後續練習使用 Mongo 儲存,解決應用程式重啟後資料保存的問題。

            實驗 C · 四個直接 Mongo 任務與 API 檢查

            12. 寫出 findPricierTest,並預測原始膠帶資料中哪些會相符。
            TypeScript
            return await db.collection('products').find({ price: { $gte: 5 } }).toArray();

            原始價格是 5.99、3.99、2.01,只有 Duct Tape 相符。$gte 包含等於邊界值 5 的情況。

            13. 寫出 Scotch Tape 的價格更新;哪些欄位不應改變?
            TypeScript
            return await db.collection('products').updateOne(
              { name: 'Scotch Tape' },
              { $set: { price: 4.29 } }
            );

            name、quantity 與 id 保留。檢查 matched/modified 結果,再讀取文件確認。

            14. 寫出 deleteByNameTest 與 markOnSaleTest。
            TypeScript
            await db.collection('products').deleteOne({ name: 'Masking Tape' });
            
            await db.collection('products').updateMany(
              { price: { $lt: 5 } },
              { $set: { onSale: true } }
            );

            原始測試資料中,Scotch Tape 與 Masking Tape 都低於 5。如果已刪除 Masking Tape,就只剩 Scotch Tape 相符。預測必須反映目前資料狀態。

            15. 測試 PATCH:如何證明它是部分更新?

            建立膠帶,只 PATCH price,再次 GET,斷言新價格與未改變的 name/quantity/id。也要斷言格式正確但不存在的 id 為 404、格式錯誤的 id 為 400。判斷商品是否存在的是 matchedCount,不是 modifiedCount。

            16. 寫出不只檢查狀態碼的 DELETE 測試。

            建立或填入商品、刪除它、檢查該週指定的成功狀態,再 GET 它(404),並列出商品證明它已不存在。刪除不存在的 id 得到 404,格式錯誤的 id 得到 400。

            實驗 D · 第 4 週路由與測試活動

            17. 在每個必要分層加入 GET /products/:id。哪一層不需要新的轉換器?

            資料庫新增依 ObjectId 查找;服務回傳 Product | null;控制器讀取 params 並選擇 200/404;路由對應 GET /:id。既有的 productFromDocument 模型轉換器已能處理文件。

            18. 寫出「Write the tests」核心練習要求的兩個測試。

            有兩筆初始商品時,先 GET /products 取得現有 id,再 GET 該 id:200,並以 toEqual 比較預期商品物件。對格式正確但不存在的 id,GET 後斷言 404。兩個測試都會自動取得 beforeEach 測試資料。完整示範見第 7 章。

            19. 設計顧客/訂單路由,不暴露集合儲存安排。

            即使訂單獨立存於自己的集合,GET/POST /customers/:id/orders 仍能表達某位顧客的訂單。公開路徑反映用戶端需求,不必照搬資料庫的巢狀安排。

            20. 故意改錯測試中的一個預期狀態碼,執行並讀懂失敗。之後應還原什麼?

            使用練習副本或暫時修改,將預期值改成刻意錯誤的狀態碼,執行指定測試,閱讀預期/實際輸出。還原正確斷言,確認測試通過。目的是理解失敗,不是放寬斷言。

            21. 找錯誤:add() 先插入 productInfo,再呼叫 productFromFields()。你送出沒有 modelNumber 與 quantity 的商品,得到 201 與 id。Compass 會顯示什麼?兩個修正是什麼?

            Compass 顯示 _id,以及你送出的那些欄位,沒有別的。預設值是在插入之後才套用的,套用的對象是控制器除了 id 以外都丟棄的值。修正一:先呼叫 productFromFields(),再插入它的結果。這樣所有欄位都會儲存,但佔位用的 id 也會存進去,就在 _id 旁邊。修正二:插入時不要包含 id,可以列出六個欄位,或使用 const { id, ...productWithoutId } = product,並回傳 insertedId.toString()。完整的錯誤故事見第 6 章。

            來源依據 · 練習改編自連結的 NYT 任務與六份現有指南。

            09

            主動回想 · 42 題,可展開答案

            關上筆記。試著解釋。

            每天複習幾張卡片

            複習模式會把下方的題目一次顯示一題,並在逐漸拉長的間隔後讓每一題再出現:答不出來的題目較快回來,已經會的題目較晚回來。先大聲說出答案,或在心裡回答,再顯示答案,然後標記這次的結果。這裡不打分數,也不會有任何資料離開這個頁面。

            複習模式需要 JavaScript。沒有 JavaScript 時,請使用下方的清單:先說出答案,再打開卡片。

            卡片目前的位置

              複習計畫如何運作
              • 新卡片依照下方清單的順序出現,同一天最多 10 張。想要更多時,可以自己加。
              • 「我會了」讓卡片往上移一盒,新卡片則進入第 1 盒。「還不會」讓卡片回到第 1 盒,並在這次複習結束前再出現一次。
              • 第 1 到第 5 盒的卡片,分別在你上次回答的 1、2、4、7、14 天後回來。只要你一直答得出來,第 5 盒的卡片就留在那裡。
              • 這裡的一天是這台裝置上的日曆日,不是 24 小時。深夜回答的卡片,隔天早上就可以再複習。
              • 額外的練習輪不會改變複習計畫。
              • 複習計畫只保存在這個瀏覽器,與檢查結果分開,三種語言共用同一份。如果某一題的文字被修改,只有那張卡片重新開始。
              • 這裡不會檢查你的答案。選「我會了」或「還不會」,由你自己決定。

              開啟每張卡片之前,先大聲回答

              這些題目涵蓋完整核心流程。有把握的解釋應包含範例,而不只是記住一個詞。複習或列印時,可使用上方的「顯示所有答案」。

              1. 什麼決定 API 路由的唯一性?

              HTTP 方法加上路徑模式。GET /orders 與 POST /orders 是不同路由。

              2. req.params、req.query 與 req.body 各對應哪種輸入來源?

              依序是路徑預留位置、URL 查詢鍵值,以及解析後的提交內容。

              3. HTML 表單輸入如何變成物件鍵名?

              name 屬性決定提交的鍵名;action 與 method 決定端點。

              4. 為什麼把主體解析器放最前面?

              後續中介軟體/處理函式需要解析後的 req.body;格式錯誤的資料也可能先在解析器失敗。

              5. res.status(...).send(...) 後面的 return 有什麼作用?

              停止目前處理函式,避免繼續執行並嘗試送出第二次回應。

              6. 聯集型別代表什麼?

              值可能是列出的其中一種型別;使用特定型別操作前,先以執行時期檢查縮小範圍。

              7. 介面與執行時期驗證有何差別?

              介面是會被移除的編譯時期資訊。驗證則實際檢查執行時期收到的值。

              8. 為什麼查找回傳 Book | undefined?

              陣列中可能沒有相符的書籍。

              9. 泛型保留了什麼?

              不同呼叫之間,輸入元素型別與輸出型別的關係。

              10. Partial 與 Omit 做什麼?

              Partial 讓欄位變成選填;Omit 從編譯時期型別排除指定欄位。

              11. await 做什麼?

              在非同步函式內等待 Promise 成功或失敗。拒絕結果可能以錯誤形式繼續傳遞。

              12. 哪些空值會觸發 ???零會嗎?

              null 與 undefined 會觸發。零、false 與空字串不會。

              13. Router 掛載於 /menu,內部路徑為 /:id,合起來是什麼?

              公開路徑 /menu/:id。

              14. 一般中介軟體的兩種結果?

              呼叫 next(),或結束回應。next(error) 則將錯誤往後傳。

              15. 如何辨識錯誤處理中介軟體?

              使用四參數形式 (err, req, res, next),並註冊在相關路由之後。

              16. 為什麼中介軟體順序會改變狀態碼?

              前面的中介軟體可能在後面的驗證器或處理函式執行前,就結束請求。

              17. 第 2 週與 HW1 的身分驗證失敗有何差別?

              第 2 週課堂實作使用 403;HW1 要求 401,包含角色錯誤的 token。

              18. 合法 bearer 格式與有效憑證有何差別?

              格式讓解析器能擷取候選值;角色中介軟體仍必須接受該候選值。

              19. 為什麼收集所有訂單錯誤?

              用戶端可以一起修正多個欄位,結構防護則避免不安全的檢查。

              20. 未知的集合篩選應回傳什麼?

              本練習通常是 200 與 [];與未知單一資源的 404 不同。

              21. 使用記憶體的伺服器重啟時會失去什麼?

              程序內狀態,例如目錄/訂單陣列與計數器。

              22. 集合/文件/欄位大致對應什麼?

              資料表/資料列/資料欄,但不表示關聯式行為完全相同。

              23. JSON 只有字串嗎?

              不是:它包含字串、數字、布林值、null、陣列與物件。BSON 另外支援更豐富的資料庫型別。

              24. find 與 findOne 各回傳什麼?

              find 回傳游標;findOne 回傳文件或 null。

              25. $gte、$lt 與 $set 代表什麼?

              大於或等於、嚴格小於,以及只修改或新增提供的欄位。

              26. 為什麼合法更新的 modifiedCount 可能為零?

              文件已經包含指定值;matchedCount 仍顯示它存在。

              27. 為什麼要將 URL 字串轉成 ObjectId?

              查詢必須使用已儲存、自動產生的 _id 所具有的 BSON 型別。

              28. 格式錯誤的 id 與格式正確但不存在的 id 有何差別?

              格式無法使用時為 400;合法查詢找不到資源時為 404。

              29. 為什麼 connect/init 要有參數?

              應用程式與測試可以使用不同 URI/資料庫,不必修改資料庫層程式碼。

              30. 什麼讓請求符合無狀態原則?

              請求提供解讀它所需的資訊,不依賴先前請求的對話狀態。

              31. 什麼是資源表述?

              用戶端看得到的資源狀態副本;修改副本不會直接修改伺服器儲存的資源。

              32. 說出 REST 的六個限制條件。

              一致介面、用戶端–伺服器、無狀態、可快取、分層系統,以及選用的按需提供程式碼。

              33. 第 4 週應在哪裡選擇 HTTP 狀態碼?

              控制器。服務與資料庫回傳應用程式資料,或不存在的結果。

              34. _id 在哪裡變成 id?

              模型層的 productFromDocument 將 ObjectId 轉成公開的字串。

              35. 204 回應有什麼特別?

              成功完成,沒有回應主體。

              36. 為什麼暫時 Mongo 不能取代 beforeEach?

              它在每次執行間都是全新狀態,但同一次執行中的測試仍可能共用並修改資料。

              37. 為什麼直接透過資料庫填入 GET 測試的初始資料?

              讓測試準備不必依賴正常運作的 POST 路由。

              38. toBe 與 toEqual 有何差別?

              基本型別值/物件身分相同,與巢狀內容相等的差別。分別建立的商品物件使用 toEqual。

              39. 為什麼正向與反向斷言要搭配?

              錯誤的空結果也能符合「不包含 X」;正向斷言則證明預期結果存在。

              40. 準備–操作–斷言是什麼?

              準備已知資料,執行操作,再檢查可觀察的結果與狀態。

              41. 為什麼依格式/屬性斷言自動產生的值?

              id 與時間戳記會變動。應測試要求的格式/可解析性,而非某次執行的精確值。

              42. Supertest 沒有涵蓋哪些啟動行為?

              index.ts 的監聽器/資料庫初始化,以及一般部署/執行環境設定。

              來源依據 · 整合所有主題的自我檢查。

              10

              指令、連接埠、狀態碼、疑難排解與詞彙

              桌邊速查。

              在正確的資料夾執行正確的指令碼

              在指定專案資料夾開啟終端機。只有儲存庫的 package.json 定義了指令碼,它才存在。這些指令供查閱;讀書頁面不會執行它們。

              專案要知道的指令碼一般應用程式/資料庫
              Classes/week1hellonpm start連接埠 1679;沒有資料庫。
              Classes/si-679-f-26-week1-nyt-kelvintigernpm start連接埠 3000;沒有資料庫。npm test 是佔位指令,不是本機測試套件。
              Classes/si-679-f-26-ts-basics-kelvintigertypecheck, build, start, dev, js終端機圖書目錄;沒有 HTTP 伺服器,也沒有 npm test 指令碼。
              Classes/si-679-f-26-week02-nyt-kelvintigertest, test:watch, typecheck, build, dev, start連接埠 6790;記憶體內的目錄。
              Homework/si-679-f-26-hw1-kelvintigertest, test:watch, typecheck, build, dev連接埠 3000;記憶體內的訂單。沒有 npm start 指令碼。
              Classes/si-679-f-26-week03-nyt-kelvintigertest, test:watch, typecheck, build, explore, dev, start連接埠 6790;應用程式使用 week3app,探索使用 week3db。
              Classes/si-679-f-26-week-04-nyt-kelvintigertest, test:watch, typecheck, build, dev, start連接埠 6790;week4/products;sampleData/products.json。
              指令用途
              npm ci現有工作目錄具有有效 lockfile 時,依 lockfile 安裝相依套件。
              npm install安裝/更新相依套件;講義起始專案使用此方式。
              npm test在有定義該指令的儲存庫中,通常以 vitest run 執行一次。
              npm run test:watch原始碼改變時重新執行測試。
              npm run typechecktsc --noEmit 檢查型別,不產生檔案。
              npm run build將 TypeScript 編譯到 dist。HW1 使用排除測試的建置設定。
              npm run dev使用 tsx 執行 TypeScript(通常啟用監看);不能取代型別檢查。
              npm start執行該專案定義的 start:可能是監看的 JS,或已建置的 dist。
              Shell
              # From the existing Week 4 repository:
              npm ci
              npm test
              npm run typecheck
              npm run build
              
              # For manual practice, separately start/connect your normal MongoDB,
              # then import sampleData/products.json into week4/products in Compass.
              npm run dev
              # Keep this terminal open; use a second one for curl. Stop with Ctrl-C.

              預設 Mongo URI 是 mongodb://127.0.0.1:27017。若這是你建立的資料目錄,手動啟動伺服器可以使用 mongod --dbpath ~/data/mdata 。這些是課程設定說明,不代表這台 Mac 目前已安裝或正在執行 mongod/mongosh/Compass。

              完整的第 4 週手動往返測試

              Shell · 只刪除你自己建立的練習商品
              curl -i http://localhost:6790/products
              
              curl -i -X POST http://localhost:6790/products   -H 'Content-Type: application/json'   -d '{"modelName":"Practice coffee maker","price":25,"quantity":3}'
              
              # Replace YOUR_ID with the id just returned above.
              curl -i http://localhost:6790/products/YOUR_ID
              curl -i -X DELETE http://localhost:6790/products/YOUR_ID
              curl -i http://localhost:6790/products/YOUR_ID

              預期清單 200、建立 201、讀取 200、刪除 204 且主體為空,再次讀取 404。 -i 顯示回應標頭/狀態碼; -X 選擇方法; -H 加入標頭; -d 提供主體。刪除時使用自己新建的練習紀錄。

              Shell · 課堂提供的憑證
              # HW1 manual calls (server on port 3000):
              curl 'http://localhost:3000/menu?category=pizza'
              curl -X POST http://localhost:3000/orders   -H 'Authorization: Bearer slicedrop-customer-secret'   -H 'Content-Type: application/json'   -d '{"customerName":"Ada","items":[{"menuItemId":"soda","quantity":1}]}'
              curl http://localhost:3000/orders   -H 'Authorization: Bearer slicedrop-staff-secret'

              HW1 的 POST 與清單請求要在同一個執行中的程序完成,才看得到訂單。Shell 中包含 & 的 URL 要加引號,示意 id 則應替換,不要直接照抄。

              不要混淆的狀態碼與 API 契約

              狀態碼意義課程範例
              200成功回應GET/清單;第 1 週 tracks;第 3 週 PATCH/DELETE。
              201已建立資源第 2 週 books、HW1 orders、第 3–4 週 products POST。
              204成功,沒有主體本機第 4 週 DELETE。
              400無法使用的用戶端輸入錯誤 JSON/欄位,或格式錯誤的 id。
              401身分驗證憑證不被接受HW1 缺少、無效或角色錯誤的 token。一般語意與練習的簡化角色處理不同。
              403禁止存取一般表示沒有權限;第 2 週則用於所有未通過課堂 token 檢查的情況。
              404要求的路由/資源不存在格式正確但不存在的 id;未知路由。篩選後集合為空仍是 200。
              500非預期的伺服器錯誤應用程式拋出錯誤;檢查伺服器紀錄。
              差異較早的範例較後/其他範例
              Id第 1 週菜單形式的文字/第 2 週數值 Date.nowMongo ObjectId 在 URL 中表示為十六進位文字。
              PATCH 路徑第 2 週 /books,主體包含 id第 3 週 /products/:id。第 4 週本機練習沒有 PATCH 路由。
              DELETE 路徑第 2 週 /books,主體中的 id第 3–4 週 /products/:id。
              DELETE 狀態碼第 2/3 週 200第 4 週本機為 204;筆記允許 200 或 204。
              儲存第 2 週/HW1 程序內陣列第 3–4 週一般 Mongo 持久儲存;測試使用暫時 Mongo。
              回應中的識別欄位第 3 週文件顯示 _id第 4 週公開 Product 顯示 id,省略 _id。
              Product 結構第 3 週 name/price/quantity第 4 週 modelName/modelNumber/manufacturer/color/price/quantity/id。

              由外往內除錯

              症狀可能需要檢查的位置下一步檢查
              連線立即遭拒HTTP 監聽器或連接埠錯誤正確的開發伺服器有啟動嗎?檢查 1679/3000/6790 與終端機錯誤。
              等待很久,接著出現 Mongo 連線錯誤資料庫伺服器/URI確認指定 URI 能連到 mongod,不能只確認已安裝 npm 驅動程式。
              Cannot POST /products/404 HTML方法、掛載位置、已註冊路由檢查 POST 與 GET,以及路由器的相對路徑/前綴。
              body 為 undefined/只有空的預設值Content-Type、主體格式、解析器Postman 使用 raw JSON;express.json 放在路由之前;檢查本機驗證回應。
              GET 清單為 []選用的資料庫/集合/測試資料week3db、week3app 或 week4;是否匯入或填入同一位置?
              資料錯誤之前先出現 401/403身分驗證中介軟體確認課堂憑證與角色完全正確;留意不同週次的狀態碼。
              YOUR_ID 得到 400id 格式將預留文字替換成實際回傳的 24 個十六進位字元 id。
              Headers already sent控制流程檢查是否在回應後繼續執行,或回應後又呼叫 next。
              請求卡住中介軟體/await/連線每個流程都有呼叫 next 或回應嗎?資料庫操作完成了嗎?
              測試第一次通過,之後卻失敗共用狀態在測試間重設;避免持久資料庫殘留;還原 mock。
              開發伺服器能執行,卻有型別錯誤編譯器與執行器tsx 執行與 npm run typecheck 是分開的。
              連接埠已被使用另一個執行中的應用程式停止衝突程序,或使用專案可設定的連接埠。

              依此順序檢查:方法/完整路徑 → 狀態碼 → 標頭/主體 → 解析器/中介軟體 → 處理函式/服務 → 資料庫狀態 → 回應。遇到 500,讀取紀錄。測試失敗時,查看預期/實際值與測試資料。修正後,重新執行相關測試與型別檢查。

              白話詞彙表

              課程用到的每個詞彙,依課程教到的先後排列。每一項都標出教這個詞的那一課。

              在課文裡,帶有虛線底線的詞,可以就地打開它的意思。

              術語意義
              後端系統中接收請求、檢查內容、完成工作並回覆的部分。 教學位置:0.1 · 從你已經懂的事情開始
              用戶端向另一個程式提出要求的軟體。 教學位置:0.1 · 從你已經懂的事情開始
              前端你實際操作的部分:按鈕、表單,以及畫面上顯示的內容。 教學位置:0.1 · 從你已經懂的事情開始
              請求用戶端送給伺服器、提出一項要求的一則訊息。 教學位置:0.1 · 從你已經懂的事情開始
              回應伺服器針對一個請求送回的回覆。 教學位置:0.1 · 從你已經懂的事情開始
              伺服器接收請求、完成工作並送出回應的程式。 教學位置:0.1 · 從你已經懂的事情開始
              API軟體請其他軟體做事的約定:允許哪些請求、要送什麼,以及回覆代表什麼。 教學位置:0.2 · 兩種提出要求的方式
              主體請求送出的內容,或回應傳回的內容。 教學位置:0.2 · 兩種提出要求的方式
              HTTP網頁請求與回應共同遵守的格式。 教學位置:0.2 · 兩種提出要求的方式
              方法請求中指出動作種類的字,例如 GET 或 POST。 教學位置:0.2 · 兩種提出要求的方式
              路徑請求中指出動作對象的部分,例如 /tracks。 教學位置:0.2 · 兩種提出要求的方式
              資源API 讓用戶端操作的東西,例如一筆訂單或一項商品。 教學位置:0.2 · 兩種提出要求的方式
              身分驗證檢查是誰送出請求。 教學位置:0.3 · 打開廚房的門
              權限檢查檢查送出請求的人能不能做這件事。 教學位置:0.3 · 打開廚房的門
              資料庫儲存長久紀錄的程式,即使伺服器重新啟動,之後仍能查到資料。 教學位置:0.3 · 打開廚房的門
              持久儲存儲存狀態能在應用程式程序重啟後保留。 教學位置:0.3 · 打開廚房的門
              測試送出已知輸入、檢查結果是否符合預期的程式碼。 教學位置:0.3 · 打開廚房的門
              指令在終端機輸入、按 Enter 執行的一個指示。 教學位置:開啟終端機並切換資料夾
              終端機一個視窗,讓你輸入指令、按 Enter,再閱讀電腦印回來的文字。 教學位置:開啟終端機並切換資料夾
              Node在瀏覽器之外執行 JavaScript 的程式。 教學位置:安裝 Node 並確認
              npm隨 Node 一起安裝的工具,負責下載套件並執行專案定義的指令碼。 教學位置:安裝 Node 並確認
              套件別人寫好、由 npm 下載到你專案裡的程式碼。 教學位置:安裝 Node 並確認
              commitgit 專案的一個已儲存版本,附一則簡短訊息說明改了什麼。 教學位置:用 git 與 GitHub 儲存並繳交作業
              git記錄專案資料夾各個版本的程式。 教學位置:用 git 與 GitHub 儲存並繳交作業
              儲存庫由 git 追蹤的資料夾。 教學位置:用 git 與 GitHub 儲存並繳交作業
              相依套件應用程式需要的套件。 教學位置:建立專案並安裝 Express
              Lockfile記錄解析後的套件版本,以重現相依套件安裝。 教學位置:建立專案並安裝 Express
              localhost代表你自己這台電腦的名稱。 教學位置:執行伺服器並造訪它
              連接埠一個號碼,決定電腦上哪個正在等待的程式收到請求。 教學位置:執行伺服器並造訪它
              Postman一個應用程式,送出你手動組好的 HTTP 請求,並顯示完整的回應。 教學位置:用 Postman 送出請求
              敘述程式裡一個完整的指令。 教學位置:執行一行 JavaScript
              布林值只會是 true 或 false 的值。 教學位置:值:文字、數字,以及 true 或 false
              字串文字型的值,寫在引號之間。 教學位置:值:文字、數字,以及 true 或 false
              型別一個值屬於哪一種資料,例如字串、數字或布林值。 教學位置:值:文字、數字,以及 true 或 false
              值一筆資料。 教學位置:值:文字、數字,以及 true 或 false
              變數保存著值的名稱,用 const 或 let 建立。 教學位置:替值取名字:const 與 let
              欄位物件或文件裡一個有名稱的值。 教學位置:物件:有標籤的資訊
              物件一組有標籤的值。 教學位置:物件:有標籤的資訊
              屬性物件裡一個標籤連同它的值,也稱為欄位。 教學位置:物件:有標籤的資訊
              陣列有順序的值清單。 教學位置:陣列:有順序的清單
              索引項目在陣列中的位置,從 0 開始數。 教學位置:陣列:有順序的清單
              假值當成條件使用時,被視為 false 的值。0、空字串、null 和 undefined 都是假值。 教學位置:做決定:if、else 與比較
              真值當成條件使用時,被視為 true 的值。 教學位置:做決定:if、else 與比較
              迴圈把同樣的敘述執行不只一次的程式碼。 教學位置:迴圈:對每個項目重複一次
              引數呼叫函式時傳入的值。 教學位置:函式:有名字的一組指令
              箭頭函式函式的簡短寫法:先寫參數,再寫箭頭,最後是主體。 教學位置:函式:有名字的一組指令
              函式有名字、可重複使用的程式碼,被呼叫時才執行。 教學位置:函式:有名字的一組指令
              參數代表函式其中一個輸入的名稱。 教學位置:函式:有名字的一組指令
              回呼函式傳給另一個函式,讓它在適當時機執行的函式。 教學位置:回呼函式:把函式交給另一個函式
              處理函式相符的請求抵達時,Express 會執行的函式。 教學位置:回呼函式:把函式交給另一個函式
              解構用一個敘述,從物件的欄位建立名稱。 教學位置:課程到處都在用的三種簡寫
              範本字面值寫在反引號之間、可以放入值的字串。 教學位置:課程到處都在用的三種簡寫
              匯出標示一個檔案提供哪些內容給其他檔案的宣告。 教學位置:模組:一個檔案使用另一個檔案
              匯入把另一個檔案的名稱帶進這個檔案的宣告。 教學位置:模組:一個檔案使用另一個檔案
              模組裡面的程式碼可供其他檔案使用的檔案。 教學位置:模組:一個檔案使用另一個檔案
              Promise代表還沒準備好的結果的物件。await 會取得它的值。 教學位置:等待慢的工作:Promise、async、await
              堆疊追蹤一路走到錯誤的呼叫鏈,最新的排在最前面。 教學位置:程式出錯時:throw、try、catch
              端點用戶端可存取的操作,由方法與路徑模式辨識。 教學位置:先從雙方的對話理解
              Express協助 Node 程式接收 HTTP 請求、比對路由、解析資料並送出回應的函式庫。 教學位置:先從雙方的對話理解
              標頭請求或回應上一行有名稱的額外資訊,例如 Content-Type。 教學位置:先從雙方的對話理解
              狀態碼回應裡說明發生了什麼事的數字,例如 200、201、400 或 404。 教學位置:先從雙方的對話理解
              URL網址:通訊協定、主機、可省略的連接埠、路徑,以及可省略的查詢。 教學位置:先從雙方的對話理解
              JSON以固定語法寫成的結構化資料文字:字串、數字、布林值、null、陣列與物件。 教學位置:知道輸入資料放在哪裡
              解析把文字轉成程式可以使用的值,例如 express.json() 處理 JSON 主體。 教學位置:知道輸入資料放在哪裡
              路徑參數路由路徑中的佔位符,例如 :id,實際的值會放在 req.params。 教學位置:知道輸入資料放在哪裡
              查詢參數URL 中 ? 之後一個有名稱的值,從 req.query 讀取。 教學位置:知道輸入資料放在哪裡
              路由把請求的方法與路徑對應到處理函式的程式碼。 教學位置:由左至右讀懂路由
              序列化將值轉成可傳輸的文字或二進位格式,例如 JSON。 教學位置:由左至右讀懂路由
              驗證在採取動作之前,先檢查收到的資料是否可用。 教學位置:由左至右讀懂路由
              記憶體內只存在執行中的程式裡,程序重新啟動就會消失。 教學位置:環境設定與手動用戶端練習
              監看模式監看的檔案改變時,重新執行或啟動。 教學位置:環境設定與手動用戶端練習
              編譯時期程式執行前,檢查型別與原始碼的時候。 教學位置:TypeScript 改變了什麼
              列舉TypeScript 的一種型別,替一組固定的允許值取名字。 教學位置:TypeScript 改變了什麼
              介面TypeScript 對物件有哪些欄位、各是什麼型別的描述。 教學位置:TypeScript 改變了什麼
              執行時期程式執行的時候。 教學位置:TypeScript 改變了什麼
              型別註記TypeScript 寫在名稱後面的註記,例如 : string,說明這個值必須是哪種型別。 教學位置:TypeScript 改變了什麼
              TypeScript加上型別註記的 JavaScript,由編譯器在程式執行前檢查。 教學位置:TypeScript 改變了什麼
              縮小範圍檢查聯集的值實際是哪一種型別(例如用 typeof),讓 TypeScript 在那個分支裡知道。 教學位置:使用聯集型別前先縮小範圍
              可選串連用 ?. 讀取屬性,遇到 null 或 undefined 時得到 undefined,而不是讓程式崩潰。 教學位置:使用聯集型別前先縮小範圍
              聯集型別允許幾種可能其中之一的型別,例如 string | number。 教學位置:使用聯集型別前先縮小範圍
              泛型帶有型別參數的函式或型別,同一份定義能保留每個呼叫端使用的型別。 教學位置:用泛型保留型別
              型別參數代表某個型別的佔位符,每次呼叫時指定或推論出來。 教學位置:用泛型保留型別
              掛載把路由器以某個路徑前綴接到應用程式上。 教學位置:整理路由,同時保留 API 行為
              路由器Express 的一種物件,把相關的路由收在一起,讓應用程式以同一個路徑前綴掛載。 教學位置:整理路由,同時保留 API 行為
              中介軟體請求處理鏈中的函式,可以繼續、回應,或傳遞錯誤。 教學位置:中介軟體要嘛繼續,要嘛回應
              契約用戶端與測試所依賴、約定好的輸入/輸出行為。 教學位置:第 2 週的確切 API 契約
              錯誤處理器有四個參數 (err, req, res, next) 的 Express 中介軟體,在錯誤沿著處理鏈傳來時負責回應。 教學位置:分開啟動程式;錯誤處理放最後
              開發相依套件開發、建置或測試應用程式所用的工具。 教學位置:TypeScript 設定、標頭與三個 NYT 練習區塊
              token用戶端送出的一段秘密字串(這裡放在 Authorization 標頭),讓伺服器決定是否接受請求。 教學位置:四個端點;兩種角色
              正規表示式描述哪些文字相符的樣式,在 JavaScript 裡寫在兩個斜線之間。 教學位置:解析憑證與接受憑證是兩回事
              BSONMongoDB 儲存用的二進位格式,多了 ObjectId 與日期等型別。 教學位置:選擇資料模型;認識術語
              集合MongoDB 資料庫裡一組有名稱的文件,大致相當於資料表。 教學位置:選擇資料模型;認識術語
              文件MongoDB 裡儲存的一筆紀錄,就像一張填好的表單。 教學位置:選擇資料模型;認識術語
              驅動程式讓 Node 程式與 MongoDB 溝通的 npm 套件,負責在 JavaScript 值與 BSON 之間轉換。 教學位置:選擇資料模型;認識術語
              MongoDB把文件存放在集合裡的資料庫。 教學位置:選擇資料模型;認識術語
              CRUD建立、讀取、更新、刪除:對儲存資料的四種基本操作。 教學位置:CRUD 使用查詢物件與更新運算子
              游標用來取得或逐一走訪資料庫查詢結果的物件。 教學位置:CRUD 使用查詢物件與更新運算子
              查詢物件告訴 MongoDB 這次操作要比對哪些文件的物件。 教學位置:CRUD 使用查詢物件與更新運算子
              ObjectIdMongoDB 產生的 id 型別:12 個位元組的值,通常顯示成 24 個十六進位字元。 教學位置:id 的格式與是否存在是兩個問題
              可快取回應會說明自己能不能存起來,之後直接重複使用,不必再請求一次。 教學位置:REST 是一種架構風格
              按需提供程式碼REST 的選用限制條件:伺服器可以送出程式碼讓用戶端執行。 教學位置:REST 是一種架構風格
              分層系統以分層建構的系統,每個部分只看得到相鄰的那一層。 教學位置:REST 是一種架構風格
              表述用戶端看得到的資源狀態副本。 教學位置:REST 是一種架構風格
              REST一套一致的 API 慣例,以資源為中心,建立在網頁的 HTTP 模型上。 教學位置:REST 是一種架構風格
              無狀態每個請求都帶著理解它所需的脈絡;伺服器不依賴先前對話的記憶。 教學位置:REST 是一種架構風格
              一致介面用戶端與伺服器互動的一致方式:依標準意義使用標準 HTTP 方法,端點命名也保持一致。 教學位置:REST 是一種架構風格
              控制器負責網路對話的那一層:讀取請求、請服務完成工作,再組出回應。 教學位置:每一層只負責一件事
              分層一個程式區域負責一項職責,邊界清楚。 教學位置:每一層只負責一件事
              模型描述程式所用資料形狀的程式碼;第 4 週時它也負責把儲存的文件轉成這個形狀。 教學位置:每一層只負責一件事
              關注點分離讓每個程式區域只負責一項職責,邊界清楚。 教學位置:每一層只負責一件事
              服務執行應用程式工作與規則的那一層,完全不知道 req 或 res。 教學位置:每一層只負責一件事
              MongoMemoryServer由測試啟動與停止的暫時 MongoDB 程序,是真的資料庫。 教學位置:三個工具,三項工作
              Supertest讓測試對 Express 應用程式送出 HTTP 請求的函式庫,不必手動啟動伺服器。 教學位置:三個工具,三項工作
              Vitest課程使用的測試執行工具:執行測試、掛鉤、比對器與 mock。 教學位置:三個工具,三項工作
              測試資料安排好的已知資料,讓測試有明確的預期結果。 教學位置:啟動一次;每次重設;停止一次
              掛鉤測試執行工具在測試前後呼叫的函式,例如 beforeAll 或 beforeEach,用來準備與清理。 教學位置:啟動一次;每次重設;停止一次
              模擬函式測試中替代原本行為的模擬,例如刻意拋出錯誤。 教學位置:啟動一次;每次重設;停止一次
              初始資料探索或測試之前先放進去的已知起始紀錄。 教學位置:啟動一次;每次重設;停止一次
              測試隔離測試結果不依賴其他測試或執行留下的狀態。 教學位置:啟動一次;每次重設;停止一次
              斷言測試中對預期行為的陳述,由比對器檢查。 教學位置:選擇能問對問題的比對器
              比對器斷言中說明要怎麼比較的部分,例如 toBe 或 toEqual。 教學位置:選擇能問對問題的比對器
              資料存取層讀寫資料庫,並把儲存的文件轉成應用程式自己的物件(例如 Product)的那一層。 教學位置:四層,四項工作
              監視函式包住真正的函式、記錄它如何被呼叫的測試替身;單獨使用時仍會執行真正的程式碼。 教學位置:假函式、模擬函式、監視函式:控制與觀察
              測試替身由測試控制、用來代替受測程式所呼叫之物的替代品,例如模擬函式或監視函式。 教學位置:假函式、模擬函式、監視函式:控制與觀察
              bcrypt專為密碼設計的雜湊演算法,會混入隨機的鹽,而且故意做得慢。 教學位置:絕對不要儲存密碼
              雜湊由輸入計算出來的固定大小指紋,無法再變回原本的輸入。 教學位置:絕對不要儲存密碼
              鹽雜湊之前混入密碼的隨機值,讓相同的密碼得到不同的雜湊。 教學位置:絕對不要儲存密碼
              聲明token 內容裡的一個欄位,例如 sub、exp 或 role。 教學位置:JWT:誰都能讀,只有伺服器做得出來
              JWT由三部分組成的簽章 token:header.payload.signature;誰都讀得到,只有伺服器做得出來。 教學位置:JWT:誰都能讀,只有伺服器做得出來
              環境變數存在於程式碼之外、程式執行時才讀取的具名值,例如 JWT_SECRET。 教學位置:密鑰放在環境裡
              角色型存取控制依照呼叫者擁有的角色(例如顧客或員工)決定他能做什麼。 教學位置:角色:requireRole 在 requireJwt 之後執行

              來源依據 · 目前七個本機專案的指令碼與原始碼,以及課程筆記。

              11

              涵蓋範圍、原始指南與來源資料庫

              每個主題都有來源可循。

              檢視了哪些內容

              整理於 2026 年 9 月 29 日,資料來自七個本機課程資料夾、其中可用的指南、原始碼/測試,以及四個提供的課程頁面。本機 Homework 資料夾包含 HW1,未發現本機 HW2/HW3。這是整合第 1–6 週、TypeScript 基礎活動與 HW1 的技術讀書指南,並非即時 Canvas 作業或截止日期稽核。

              兩個 Confluence 頁面已在瀏覽器實際呈現後閱讀,並儲存為詳細主題/練習摘錄。兩份公開 GitHub 筆記已下載,完整主要文字也已保留。四份資料皆可讀取。行政公告與歷史截止日期保留在來源摘錄中,但不當成目前截止日期。來源範例中明顯的語法或措辭錯誤會加以說明,不會直接照抄。

              描述你儲存庫的確切行為時,以本機程式碼為依據。只有講義提出的設計,以及本機改進,都有標示。建立本指南並未修改、提交或重新執行課程作業。先前儲存的評分或安裝說法,不作為目前證據。

              公開的第 3 週準備頁面於 2026 年 10 月 4 日加入,作為 MongoDB 設定主題的來源。它的 HTML 與主要文字和其他快照一起保留。設定這一節的終端機、編輯器與 git 基礎屬於一般用法,不是課程文字。其中的範例輸出擷取自一台 Mac,並標示為範例。

              第 4 週課後備忘錄於 2026 年 10 月 4 日加入,作為第 6 節錯誤故事主題與修改後 Now You Try #2 的來源。它的 HTML 與主要文字和其他快照一起保留。

              JavaScript 章節於 2026 年 10 月 4 日加入。它是一般的 JavaScript 用法,不是課程內容,並沿用本指南的披薩、書籍與產品範例。其中每個範例和頁面上的每段輸出,都在一台 Mac 上用 Node 24 實際執行過。錯誤輸出裡這台電腦的資料夾,已改成範例資料夾。

              公開的第 5 週筆記在 2026 年 10 月 6 日加入,作為第 7+ 章的來源。它的 HTML 與主要文字和其他快照一起保留。那一章也參考了 Vitest 的模擬函式指南,只有連結,沒有保留副本。章裡準備、執行、斷言那一課的 Python 範例用 pytest 9.1 執行過;另外三個 Python 範例用 Python 3.9 執行過。自行撰寫的單元測試範例先用 Vitest 3.2 執行過,對照的是筆記中服務的副本,資料存取層換成替身;之後又和自行撰寫的端對端測試一起,用 Vitest 5.0 在第 5 週起始專案上執行過。

              公開的第 6 週筆記於 2026 年 10 月 13 日加入,作為 W6 章的來源。它的 HTML 與主要文字和其他快照一起保留。標示為取自第 6 週筆記的程式碼是逐字引用。自行撰寫的端對端測試與單元測試,是在放入課堂程式碼的第 6 週起始專案上以 Vitest 5.0 實際執行。

              第 2+ 章引用第 2、3、4、6 週課堂練習儲存庫的程式碼(課程公開了這些解答),來源是在清單中固定的快照。第 HW2 章摘要並連結公開的 HW2 作業說明,並用同樣的方式引用第 4 週與第 6 週儲存庫的課堂程式碼。兩章都不含作業程式碼;只要任何一章出現作業解答中的名稱,檢查就會失敗。

              課程來源涵蓋清單

              來源章節涵蓋內容研讀位置
              第 1 週 · 入門/第一個路由/路由Node/npm/ESM/監看模式、Express app/listen、方法加路徑、回應 API開啟主題 →
              第 1 週 · POST/表單/表單中介軟體action/method/name、req.body、URL 編碼解析器、文字郵遞區號開啟主題 →
              第 1 週 · 查詢字串/路由參數/錯誤req.query/params、解碼、數值防護、400/預設 200開啟主題 →
              第 1 週 · NYT #1catalog, play, getform/submitform, browse開啟主題 →
              第 1 週 · Postman/JSON/NYT #2postData、hithere、tracks/錯誤/時間戳記、products AND 關鍵字開啟主題 →
              TypeScript 基礎 · 六項任務全數涵蓋介面、型別註記、列舉、簽章、聯集/縮小型別、泛型開啟主題 →
              課堂練習儲存庫,第 2–6 週把型別刪掉:冒號、角括號、type 行、as const開啟主題 →
              課堂練習儲存庫,第 2–6 週十二種形狀,依它們在課程 .ts 檔中出現的頻率排列開啟主題 →
              課堂練習儲存庫,第 2–6 週控制器的結構、res.status().json()、.js import、describe/it/expect、Supertest開啟主題 →
              在課堂儲存庫的副本上執行 tsc九個真實的 TypeScript 錯誤,以及怎麼讀每個部分開啟主題 →
              在課堂儲存庫的副本上執行 Vitest一個真實的測試失敗,逐行解讀開啟主題 →
              課堂練習儲存庫 · 複習空白檔案練習開啟主題 →
              第 2 週 · TS 設定/路由/NYT #1NodeNext/build/dev/typecheck、Router/服務、組合精確篩選開啟主題 →
              第 2 週 · HTTP 標頭/中介軟體標頭中繼資料/大小寫/自訂值、記錄器、身分驗證、路由順序開啟主題 →
              第 2 週 · 錯誤/狀態碼/NYT #2四參數處理函式、badroute、201、依 id GET、PATCH 主體中的 id開啟主題 →
              第 2 週 · Supertest/分離 app/狀態殘留/NYT #3請求鏈、重設、body/text 斷言、身分驗證/篩選/刪除情況開啟主題 →
              HW1 · 完整本機實作與指南菜單/篩選、兩種角色、精確解析器、完整訂單驗證、狀態/錯誤/測試開啟主題 →
              第 3 週 · 什麼是 Mongo/NoSQL/BSONSQL 取捨、集合/文件/欄位、巢狀結構/型別、伺服器與驅動程式開啟主題 →
              第 3 週 · 資料庫/起始專案/連線Compass/mongosh、URI、用戶端/資料庫/集合/ping/斷線、監看模式重複資料開啟主題 →
              第 3 週 · 插入/查找/查詢/更新/刪除insertOne/Many、游標/findOne、ObjectId、比較、$set/結果/選項開啟主題 →
              第 3 週 · NYT #1 與延伸較貴商品、Scotch 價格、刪除 Masking、多筆 onSale 更新開啟主題 →
              第 3 週 · Express/DB/路由器/app/伺服器/Postman所有 CRUD 路由、監聽前連線、型別結構與狀態碼開啟主題 →
              第 3 週 · 真實資料庫測試/暫時 Mongo/比對器跨執行與跨測試的資料干擾、掛鉤、比對器/拒絕結果開啟主題 →
              第 3 週 · NYT #2 與延伸PATCH 保留欄位/404、DELETE 200/404、格式錯誤 id 的中介軟體測試開啟主題 →
              第 4 週 · REST/原則/情境六個限制條件、表述、實體/欄位/端點/規則邊界開啟主題 →
              第 4 週 · 架構/建立 GET/所有分層各層職責、匯入、轉換器、map、組裝/錯誤/啟動開啟主題 →
              第 4 週 · 建立 POST/DB/模型/服務/控制器/路由器/PostmanPartial、??、insertedId、201/id、讀回/Compass、疑難排解開啟主題 →
              第 4 週 · NYT #1 與 DELETE 延伸GET 單筆/404/公開 id、刪除數量/讀回、控制器的 HTTP 決策開啟主題 →
              第 4 週 · Supertest/暫時 DB/init/掛鉤/初始資料/斷言app 與啟動流程的邊界、直接建立資料庫測試資料、比對器範例開啟主題 →
              第 4 週 · NYT #2/撰寫測試/DELETE 延伸測試精確的指定物件/404 測試;成功刪除/不存在的刪除與清單狀態開啟主題 →
              第 1 週 · 建立 Express 專案/Postman 設定/Classroom50 流程npm init、express@latest、type module、start 指令碼、localhost:3000、接受/clone/push開啟主題 →
              第 3 週準備 · 安裝/啟動/驗證 MongoDBCommunity Edition、mongosh、Compass、背景服務或 --dbpath、連接埠 27017、admin/config/local開啟主題 →
              第 4 週 · 課後備忘錄add() 的程式行順序、插入前填入預設值、_id 旁的佔位 id、解構與其餘元素、只回傳 id、修改後的 NYT #2開啟主題 →
              第 5 週 · 你拿到的應用程式/跟著一個請求走各資料夾的角色、資料存取層負責轉換、app 與 index開啟主題 →
              第 5 週 · 路由器/控制器/服務/資料存取層/模型PATCH 路徑、??、先找再驗證再寫入、findOneAndUpdate、各層的承諾開啟主題 →
              第 5 週 · 錯誤處理的小插曲ValidationError、錯誤處理器 400/500、null 對應 404、不存在與格式錯誤的 id開啟主題 →
              第 5 週 · 單元測試/回歸/整合單元、整合、端對端與回歸;假函式、模擬函式與監視函式的名稱開啟主題 →
              第 5 週 · 開始之前/讓測試跑得更順npm install/test、test:unit 與 test:e2e 篩選、--passWithNoTests、it.todo開啟主題 →
              第 5 週 · 端對端測試/測試 #1 到 #3準備、執行、斷言;和初始資料不同的新值;比對器開啟主題 →
              第 5 週 · 測試套件的設定方式beforeAll、beforeEach 重設、afterAll開啟主題 →
              第 5 週 · 測試 #2、測試 #3、Now You Try #1負數數量、不存在 id 的 404、三個待辦測試開啟主題 →
              第 5 週 · 模擬函式與監視函式假函式、模擬函式、監視函式;控制與觀察開啟主題 →
              Vitest 指南 · 模擬函式vi.fn、呼叫比對器、mockReturnValue、一次性答案佇列、mockImplementation開啟主題 →
              Vitest 指南 · 模擬函式mockResolvedValue、mockRejectedValue、rejects.toThrow開啟主題 →
              Vitest 指南 · 模擬函式mock.calls、mock.results、第 n 次與最後一次呼叫、引數的參照開啟主題 →
              第 5 週 · 模擬函式與監視函式/第一個真正的單元測試單獨的 vi.spyOn 與給了答案的 vi.spyOn,模擬依賴開啟主題 →
              第 5 週 · 建立測試檔/釘住邊界匯入、restoreAllMocks、makeProduct、低庫存與它的邊界開啟主題 →
              第 5 週 · 問監視函式看到了什麼/沒有答案的監視函式/Now You Try #2確切的寫入引數、not.toHaveBeenCalled、價格與商品不存在的測試開啟主題 →
              第 5 週 · Now You Try #2 第 3 題createProduct 的預設值,單元測試與端對端測試的證據開啟主題 →
              Vitest 指南 · 模擬函式mockClear、mockReset、mockRestore、restoreAllMocks、restoreMocks開啟主題 →
              Vitest 指南 · 模擬函式vi.mock、vi.mocked、提升開啟主題 →
              第 5 週筆記與 Vitest 指南 · 複習會誤導的結果,以及判斷測試的七個問題開啟主題 →
              第 5 週筆記與 Vitest 指南 · 複習十五個練習問題與五個任務開啟主題 →
              HW2 作業說明 · Overview / Your repo does not compile從記憶體到 MongoDB、每一層一個資料夾、非同步驗證、兩個型別檢查錯誤開啟主題 →
              HW2 作業說明 · What you build / Endpoints / Status code summary每項任務所在的層、對應的課程內容與狀態碼開啟主題 →
              課堂練習儲存庫,第 4 週與第 6 週資料存取層、409、204、防護順序、req.query 篩選、Express 5 的錯誤轉交開啟主題 →
              HW2 作業說明 · Tips / Grading先跑型別檢查、一次一層與一個測試套件、讀失敗訊息、120 分開啟主題 →
              課堂程式碼與本指南 · 複習講師可能會問的六個問題,用課堂程式碼回答開啟主題 →
              第 6 週 · Auth and Auth身分驗證與權限檢查、HW1 與 HW2 的共用密碼、五個路由開啟主題 →
              第 6 週 · Passwords, briefly / Hashing / bcrypt單向雜湊、鹽、成本、bcrypt 字串的組成、HTTPS開啟主題 →
              第 6 週 · What you’ve been givenUser、NewUser 與 PublicUser;使用者的資料存取層開啟主題 →
              第 6 週 · Registering a userregisterUser、ConflictError 與 409、錯誤處理器、控制器與路由開啟主題 →
              第 6 週 · Logging inbcrypt.compare、兩種失敗共用一個訊息、為什麼需要 token開啟主題 →
              第 6 週 · Tokens and JWTs / What’s in a JWT標頭、內容、簽章、base64url、聲明、jwt.io開啟主題 →
              第 6 週 · Where the secret lives / Signing a token at login.env 與 .env.example、--env-file、config.ts、測試密鑰、重新啟動開發伺服器、dotenv開啟主題 →
              第 6 週 · Checking the token: requireJwt middleware / GET /auth/meBearer 標頭、jwt.verify、res.locals.user、401開啟主題 →
              第 6 週 · Writing a test together / Now You Try #1在測試裡簽的 token:沒有、不是 JWT、密鑰錯誤、已到期、被改過、真正的登入開啟主題 →
              第 6 週 · Roles in the token / Only staff: requireRole / Testing with npm test預先建立的員工、role 聲明、製造中介軟體的函式、關卡順序、401 與 403 與 201、員工與顧客的測試 token開啟主題 →
              第 6 週 · Ownership: what roles can’t say / Testing ownership服務裡的 getUser、為什麼不放在中介軟體、呼叫者、GET /users/:id 的測試開啟主題 →
              第 6 週 · A unit test for the service / How the unit test earns its place / Now You Try #2監視 findUser、先檢查再查詢、id 外洩、另外四個單元測試開啟主題 →
              第 6 週 · Wrapping upHTTPS、撤銷、過期的角色、更新機制、重設、auth 服務供應商開啟主題 →
              第 6 週筆記 · 複習十二道練習題與三個任務開啟主題 →

              本指南涵蓋的課程頁面

              第 1 週 · 課程介紹、Express 入門 ↗

              實際呈現頁面的主題/練習摘錄

              第 2 週 · Express 路由 & 中介軟體、單元測試 ↗

              實際呈現頁面的主題/練習摘錄

              第 3 週 · MongoDB ↗

              完整主頁文字快照

              第 4 週 · REST API 與撰寫測試 ↗

              完整主頁文字快照

              第 3 週準備 · 安裝 MongoDB ↗

              完整主頁文字快照 · 2026 年 10 月 4 日加入

              第 4 週 · 課後備忘錄 ↗

              完整主頁文字快照 · 2026 年 10 月 4 日加入

              第 5 週 · 測試 ↗

              完整主頁文字快照 · 2026 年 10 月 6 日加入

              第 6 週 · Auth ↗

              完整主頁文字快照 · 2026 年 10 月 13 日加入

              作業 2 · 作業說明 ↗

              只有連結,沒有保留副本。2026 年 10 月 14 日為第 HW2 章閱讀;該章摘要其內容,不引用任何作業程式碼。

              Vitest · 模擬函式 ↗

              只有連結,沒有保留副本。用於第 7+ 章。

              六份現有指南,都能在這裡閱讀

              開啟即可閱讀完整原始說明、執行步驟、練習題與答案。指南已嵌入並排版,不需要 Markdown 閱讀器。原始課程檔案維持不變;可讀的來源快照隨指南資料夾一同提供。

              TypeScript 基礎 · 完整原始指南

              Original Markdown snapshot · Classes/si-679-f-26-ts-basics-kelvintiger/STUDY_PLAN.md

              TypeScript Basics Study Plan

              This exercise takes a working JavaScript book catalog and adds TypeScript one idea at a time. Read src/index.ts beside this guide.

              Study order
              • 1. Interface: Book describes the required fields and their types. Its

              status must be a BookStatus, so an unrelated string cannot be stored.

              • 2. Typed array: Book[] means the catalog may contain only books.
              • 3. Enum: BookStatus gives names to the three allowed status strings and

              prevents spelling variations throughout the program.

              • 4. Function signatures: parameter and return types document each function

              and let TypeScript check every call.

              • 5. Union type: BookFilter = string | number allows an author or a year.

              The typeof check narrows the union before comparison.

              • 6. Generic: getFirstItem<Item> preserves the item type for any array. A

              book array produces Book | undefined; a string array would produce string | undefined.

              findBookByTitle() and getFirstItem() may return undefined when no item exists. The demo uses optional chaining (?.) so that possibility is handled instead of ignored.

              Exercises and checkpoints
              • Run npm run typecheck. Explain why it prints no output when successful.
              • Run npm run build && npm start and compare every output line with the

              README.

              • Add a practice book with an invalid status string and read the compiler

              error. Remove it afterward.

              • Call findBooksBy() with a boolean and read the compiler error. Remove the

              call afterward.

              • Call getFirstItem() with [1, 2, 3] and hover over the result in your

              editor. TypeScript should infer number | undefined without any.

              The central idea is that TypeScript checks assumptions before the program runs. It does not replace the JavaScript program; the build step produces JavaScript that Node executes.

              第 1 週 · 完整原始指南

              Original Markdown snapshot · Classes/si-679-f-26-week1-nyt-kelvintiger/STUDY_PLAN.md

              Week 1 Study Plan: Express and HTTP from the Beginning

              This guide explains the Week 1 class and the finished assignment in server.js. Work through it in order; each section depends on the one before it.

              What you should be able to do

              By the end of the week, you should be able to:

              • 1. explain the roles of a client, server, request, and response;
              • 2. define GET and POST routes in Express;
              • 3. find data in a query string, route parameters, form body, or JSON body;
              • 4. return a response body and an appropriate HTTP status;
              • 5. validate user input before using it;
              • 6. use Postman or curl to test routes that a browser address bar cannot test.
              1. Build the client/server mental model

              A client asks for something. A server receives the request, runs code, and sends a response.

              text
              client -> HTTP request -> Express route -> HTTP response -> client

              An HTTP request contains a method, a path, headers, and sometimes a body. For example:

              text
              GET /catalog?itemid=222

              GET is the method. /catalog is the path. itemid=222 is a query string. The server matches the method and path to this code:

              js
              app.get('/catalog', (req, res) => {
                // read the request and send a response
              });

              Checkpoint: explain why typing /submitform into a browser address bar cannot test a POST route. The address bar sends GET, while the route expects POST.

              2. Understand the Express setup

              At the top of server.js, express() creates the application. Two middleware functions prepare request bodies before routes use them:

              js
              app.use(express.urlencoded({ extended: true }));
              app.use(express.json());

              express.urlencoded() parses ordinary HTML form submissions. express.json() parses JSON sent by Postman or another program. After parsing, the submitted values are available in req.body.

              At the bottom, app.listen(3000) starts the server. Port 3000 is one numbered door on the computer. The full local address is http://localhost:3000.

              Exercise:

              • 1. Run npm start.
              • 2. Visit / and /about.
              • 3. Change one response string, save, and observe Node's watch mode restart the

              server.

              • 4. Restore the response before continuing.
              3. Learn the four places this assignment receives input
              Query strings

              GET /catalog?itemid=222 places 222 in req.query.itemid. Query strings are good for searches and filters because they do not identify a different route.

              The code verifies that itemid is one string, is not blank, and converts to a number. A bad value returns status 400 and the exact required message.

              Route parameters

              The colons in this route mark changing path segments:

              js
              app.get('/play/artist/:artist/song/:song', ...)

              For /play/artist/Radiohead/song/Creep, Express creates:

              js
              req.params.artist === 'Radiohead'
              req.params.song === 'Creep'

              URL encoding turns %20 into a space, so The%20Clash reaches the handler as The Clash.

              HTML form bodies

              GET /getform returns HTML containing a form. The form's action and method tell the browser to send a POST to /submitform. Each input's name becomes a key in req.body:

              js
              const { address, city, zipcode } = req.body;

              The ZIP code stays a string. That preserves a leading zero such as 01234.

              JSON bodies

              POST /tracks receives JSON. Postman must send Content-Type: application/json, and the server needs express.json() before the route. The route destructures the four fields from req.body and returns a JSON object with the added track and a timestamp.

              Checkpoint: for each assignment route, identify whether its inputs come from req.query, req.params, or req.body.

              4. Understand validation and status codes

              The catalog route returns 400 Bad Request when itemid is not numeric. The track route also returns 400 when artist or title is missing, is not a string, or contains only spaces.

              The pattern is:

              js
              if (inputIsInvalid) {
                res.status(400).send('helpful message');
                return;
              }

              The return matters. Once the server sends an error response, the handler must stop. Otherwise it may try to send a second response.

              Useful first-week statuses:

              • 200: the request succeeded;
              • 400: the client supplied unusable input;
              • 404: no route matches the requested method and path;
              • 500: application code failed unexpectedly.

              Exercise: test /catalog with 222, 3, xyz, a blank value, and no itemid key. Predict the status and body before sending each request.

              5. Walk through every assignment route
              RouteInput sourceMain transformation
              GET /catalogquery itemidvalidate number and place it in a sentence
              GET /play/artist/:artist/song/:songtwo route parametersplace both decoded values in a sentence
              GET /getformnonereturn an address form
              POST /submitformform bodycombine address, city, and ZIP code
              GET /browse/:categoryroute parameter + querycombine category and keywords
              GET /hitherequery namecreate a greeting
              POST /tracksJSON bodyvalidate and return a structured JSON result
              GET /products/:department/:categorytwo route parameters + queryturn comma-separated keywords into AND terms

              The product search calls split(',') to make an array and join(' AND ') to make the required display string. For example:

              text
              lcd,smart,sony -> ['lcd', 'smart', 'sony'] -> lcd AND smart AND sony

              The track response uses new Date().toISOString(). The exact time changes on every request, but the ISO format stays predictable.

              6. Suggested study session
              First 20 minutes: HTTP basics
              • Draw one request and response.
              • Label method, path, query string, headers, body, status, and response body.
              • Explain GET versus POST aloud.
              Next 25 minutes: trace the code
              • Read server.js from top to bottom.
              • For every route, write down where its inputs live.
              • Cover the handler and predict its response from only the request URL/body.
              Next 25 minutes: send requests
              • Create a Week 1 Postman folder.
              • Save one request for each route.
              • Test successful and invalid catalog/track cases.
              • Confirm the status as well as the visible body.
              Final 20 minutes: change and restore
              • Add one temporary route parameter to a practice route.
              • Add one temporary query-string filter.
              • Add one validation check that returns 400.
              • Test each change, then restore the submitted assignment code.
              Final self-check

              You are ready for Week 2 when you can answer these without opening the notes:

              • 1. What is the difference between a method and a path?
              • 2. Where do query parameters, route parameters, and submitted bodies appear

              on req?

              • 3. Why must body-parsing middleware come before the routes?
              • 4. Why does an error branch send a response and then return?
              • 5. What makes an input problem a 400 instead of a 500?
              • 6. Why is Postman useful for POST requests?

              Run the assignment with:

              bash
              npm install
              npm start
              第 2 週 · 完整原始指南

              Original Markdown snapshot · Classes/si-679-f-26-week02-nyt-kelvintiger/STUDY_PLAN.md

              Week 2 Study Plan: Routers, Middleware, and Supertest

              This guide explains the Week 2 class and the finished books assignment. Read the files in this order: types.ts, books-service.ts, books-router.ts, app.ts, index.ts, and finally __tests__/books.test.ts.

              What you should be able to do

              By the end of the week, you should be able to:

              • 1. use TypeScript types in an Express project;
              • 2. split routes into an Express router;
              • 3. separate data operations from HTTP handlers;
              • 4. write middleware and explain why its registration order matters;
              • 5. use headers for simple token checks;
              • 6. choose appropriate HTTP status codes;
              • 7. split the Express app from the listening server;
              • 8. test requests and responses with Vitest and Supertest.
              1. Understand the project layers

              The application separates five responsibilities:

              text
              index.ts -> app.ts -> books-router.ts -> books-service.ts -> catalog array
                                       |
                                       -> types.ts describes the data
              • types.ts defines what a book looks like.
              • books-service.ts owns the in-memory catalog and its operations.
              • books-router.ts translates HTTP requests into service calls.
              • app.ts assembles the Express application and final error handler.
              • index.ts opens port 6790 when the app runs as a real server.

              This separation makes the code easier to understand and lets tests import the app without starting a fixed network listener.

              Checkpoint: explain why a route should call getBook(id) instead of reading the catalog array directly.

              2. Review the TypeScript pieces

              The Book interface requires an ID, title, author, year, and status. BookStatus limits status to the supplied enum values. The service function signatures show what every operation accepts and returns.

              Project-local imports end in .js, even though the source files end in .ts:

              ts
              import { getAllBooks } from './books-service.js';

              TypeScript compiles the files to JavaScript, and Node ultimately loads the .js files. The NodeNext configuration checks imports using those Node ES module rules.

              updateBook() accepts Partial<Omit<Book, 'id'>>:

              • Omit<Book, 'id'> means all book fields except the ID;
              • Partial<...> makes those remaining fields optional.

              That type fits PATCH: a request may change only one field, but it should not replace the book's identity.

              3. Learn the service layer

              books-service.ts keeps the catalog private and exports focused operations:

              FunctionJob
              addBookadd one book
              getAllBooksreturn the catalog
              getBookfind a book by numeric ID
              updateBookapply selected changes to one book
              removeBookremove the matching ID
              _resetCatalogempty test state before each test

              The catalog uses let because removing or resetting books replaces the array. removeBook() uses filter() to keep every book whose ID does not match.

              The leading underscore in _resetCatalog() signals that application routes should not call it. It exists so tests remain independent.

              Exercise: start with three book IDs on paper and walk through removeBook() for the middle ID. Write the array that filter() returns.

              4. Follow middleware in order

              Middleware is a function that receives req, res, and next. It either:

              • calls next() so Express continues; or
              • sends a response and stops the chain.

              The router-wide order is:

              text
              express.json() -> logger -> matching route

              The POST route adds two route-specific steps:

              text
              JSON parser -> logger -> checkAuth -> validateBookParams -> POST handler

              Auth runs before validation. A request with no valid token and a bad book body returns 403 because it stops at checkAuth; validation never runs.

              checkAuth reads the Authorization request header. The expected value is:

              text
              Authorization: Bearer SI679

              This shared string is only a classroom example. A real application would not hard-code a credential in source code.

              validateBookParams requires title and author to be strings containing at least one non-space character. It returns 400 with a helpful message when the submitted book fails that check.

              Checkpoint: what happens if middleware neither sends a response nor calls next()? The request stays open and appears to hang.

              5. Walk through the API
              RequestAuth?Result
              POST /booksyesvalidate, create a book, return 201 and JSON
              GET /booksnoreturn all books or exact-match filters
              GET /books/:idnoreturn one book or 404
              PATCH /booksyesupdate selected fields, 400 for bad input, 404 if absent
              DELETE /booksyesremove the ID and return 200
              GET /books/badroutenothrow an example error handled as 500
              Query filtering

              GET /books checks title, author, and year. Every supplied filter must match, so author and year together act like an AND condition. Query values are strings; the code converts year to a number before comparing it with a book's numeric year.

              GET by ID

              Route parameters are strings. Number(req.params.id) converts the route value before the service compares it with a numeric book ID.

              POST status

              The assignment changes a successful POST from the default 200 to 201 Created. The response contains the created book, including its generated ID and default available status.

              PATCH validation

              The body must contain an id plus at least one field to change. The router separates them with:

              ts
              const { id, ...changes } = req.body;

              changes contains everything except id. The service applies those fields to the existing book with Object.assign().

              6. Understand the app/server split and error handler

              app.ts creates and exports the Express app. index.ts imports it and calls listen(). Supertest imports app.ts, so it can make requests without asking you to start the server first.

              The final error handler has four parameters:

              ts
              (err, req, res, next)

              Express uses that four-parameter shape to recognize error middleware. It is registered after the router because Express looks forward through the chain when a handler throws.

              Checkpoint: move the error handler above the router in a drawing. Why would an error thrown later fail to reach it?

              7. Read the tests as examples of API use

              Supertest builds a request with a readable chain:

              ts
              await request(app)
                .post('/books')
                .set('Authorization', 'Bearer SI679')
                .send({ title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 });

              The test then checks res.status, res.body for JSON, or res.text for a plain text response. The Week 2 tests cover:

              • empty initial state;
              • exact author filtering;
              • missing and incorrect tokens;
              • blank-title validation and its message;
              • POST creation and 201;
              • GET by ID and missing-ID 404;
              • PATCH auth, input validation, partial update, and unchanged fields;
              • DELETE auth and final empty state.

              beforeEach() calls _resetCatalog(). Without it, a book posted by one test would remain for the next test and make the outcome depend on test order.

              8. Suggested study session
              First 20 minutes: draw the architecture
              • Draw all five source-file layers.
              • Follow one POST request from app.ts through middleware and the service.
              • Follow the response back to the client.
              Next 25 minutes: middleware practice
              • Write the ordered chain for GET, POST, PATCH, and DELETE.
              • Predict which status wins for a request with both bad auth and bad data.
              • Use Postman to compare no token, the wrong token, and the correct token.
              Next 25 minutes: route behavior
              • POST two books by different authors.
              • Filter by one author, then by author plus year.
              • GET one returned ID.
              • PATCH its title and verify its author did not change.
              • DELETE it and confirm it is absent.
              Final 20 minutes: tests
              • Run npm test.
              • Read one test at a time using arrange, act, assert:

              set up data, send the request, check the result.

              • Temporarily remove _resetCatalog() and observe how shared state changes the

              suite. Restore it before finishing.

              Final self-check

              You are ready for Week 3 when you can answer these without opening the code:

              • 1. Why use a router instead of putting every route in index.ts?
              • 2. What are the two valid ways for middleware to finish its work?
              • 3. Why does middleware order change the response?
              • 4. What do 201, 400, 403, 404, and 500 mean here?
              • 5. Why are app.ts and index.ts separate?
              • 6. Why does every test reset the catalog?
              • 7. When should a test inspect res.body versus res.text?

              Useful commands:

              bash
              npm run typecheck
              npm test
              npm run build
              npm run dev
              HW1 · SliceDrop · 完整原始指南

              Original Markdown snapshot · Homework/si-679-f-26-hw1-kelvintiger/TEACHING_GUIDE.md

              SliceDrop HW1: A Beginner's Guide to the Finished API

              This guide explains the completed SliceDrop menu and orders API from the ground up. It assumes that HTTP, Express, TypeScript, and automated tests are all new. The examples and descriptions match the code and checked-in tests in this repository.

              The assignment is small on purpose. It gives one application enough pieces to show the Week 1 and Week 2 ideas working together:

              • a client sends HTTP requests;
              • an Express server receives them and runs middleware in order;
              • routers choose the behavior for a path;
              • plain functions validate data and look up menu items;
              • a small in-memory data module stores orders;
              • the server returns a status code, headers, and a JSON body;
              • Vitest and Supertest check the observable behavior.
              1. The mental model: a client talks to a server through an API

              An API is a set of agreed request and response shapes. A client can be a web page, a mobile app, Postman, curl, or a test. The client does not call TypeScript functions such as addOrder directly. It sends an HTTP request.

              text
              client
                │  HTTP request: method, URL, headers, optional JSON body
                ▼
              Express application (`app`)
                │  body parser → router → auth middleware → handler → data/validation
                ▼
              HTTP response: status, headers, JSON body
                │
                ▼
              client reads the result

              For example, a customer placing an order sends:

              http
              POST /orders HTTP/1.1
              Authorization: Bearer slicedrop-customer-secret
              Content-Type: application/json
              
              {"customerName":"Ada","items":[{"menuItemId":"soda","quantity":1}]}

              The server parses the JSON body, checks the token, validates the order, stores it in memory, and answers with 201 Created and a JSON representation of the new order. The client only needs the API contract: it does not need to know which array or function stored the order.

              This project has no database, browser UI, or deployed service. The API is the Express application. The index.ts file is the part that opens a network port when the application is run as a server.

              2. HTTP pieces used by this API
              Methods and routes

              An HTTP method says what kind of operation the client is requesting. A route is the method plus the path pattern. The same path can have different behavior for different methods; GET /orders and POST /orders are separate routes.

              Method and pathWho may call itBehavior
              GET /menuanyoneReturn all six menu items, or filter by category.
              GET /menu/:idanyoneReturn one menu item by its exact ID, or 404.
              POST /orderscustomer tokenValidate and create an order, or return validation errors.
              GET /ordersstaff tokenReturn all stored orders, or filter by status.

              The :id in GET /menu/:id is a route parameter. A request for /menu/hawaiian makes req.params.id equal to the string "hawaiian".

              Status codes

              The status code is a compact description of what happened.

              • 200 OK means the request succeeded. It is used for menu responses and

              successful order listings.

              • 201 Created means a new resource was created. A valid POST /orders

              returns this status.

              • 400 Bad Request means the client sent something the server cannot process,

              such as invalid JSON or an invalid order body.

              • 401 Unauthorized means the request did not present the required valid

              token. This assignment uses 401 for a missing, malformed, unknown, or wrong-role token.

              • 404 Not Found means no route handled the path, or a requested menu ID does

              not exist.

              • 500 Internal Server Error means application code failed unexpectedly. It

              is a server problem, even when it happens while handling a valid request.

              The status is part of the API contract. A client can make a useful decision from 201, 400, or 401 even before reading the body.

              Headers

              Headers are named metadata attached to an HTTP request or response. This API uses two request headers especially:

              • Authorization: Bearer <token> carries the customer or staff credential.
              • Content-Type: application/json tells the server that the body is JSON.

              express.json() uses the content type and JSON parser to turn a valid JSON request body into req.body. If JSON cannot be parsed, the parser raises an error before an order handler runs.

              Body, query string, and route parameters

              These are three different places request data can live:

              text
              GET /menu/hawaiian?category=pizza
                  └──────┬─────┘ └──────┬──────┘
               route parameter `id`      query parameter `category`
              • The body is normally used for data being submitted. The order body contains

              customerName and items.

              • A query string begins after ?. GET /menu?category=pizza puts the value

              in req.query.category; GET /orders?status=pending puts the value in req.query.status. Query filters do not create a missing-resource error: an unknown category or status produces 200 with [].

              • A route parameter is part of the path pattern. In /menu/:id, the value is

              available at req.params.id.

              Week 1 introduced these ideas with small app.get and app.post handlers. HW1 applies the same ideas after the handlers have been organized into routers.

              3. The project map and the app/server split

              The important files fit together like this:

              text
              src/index.ts                 starts listening on port 3000
              src/app.ts                   builds and exports the Express app
              src/routers/menu.ts          GET /menu and GET /menu/:id
              src/routers/orders.ts        POST /orders and GET /orders
              src/middleware/auth.ts       Bearer parsing and role checks
              src/validation/validate-order.ts
                                            order-body validation
              src/data/menu.ts             supplied menu array and lookup function
              src/data/orders.ts           in-memory order array and ID generation
              src/types.ts                 shared TypeScript types
              src/constants.ts             tokens and port
              src/__tests__/*.test.ts      Vitest/Supertest behavior checks

              src/app.ts exports app but does not call listen. src/index.ts imports that app and starts the real server:

              ts
              import { app } from "./app";
              import { PORT } from "./constants";
              
              app.listen(PORT, () => {
                console.log(`SliceDrop listening on http://localhost:${PORT}`);
              });

              This split is important for testing. Supertest can import the app and manage a temporary listener for each test run, so the developer does not have to start the server on its normal fixed port first. Calling listen in app.ts would start that fixed listener whenever the module was imported and could cause port conflicts.

              The supplied constants.ts gives the exact development values:

              text
              customer token: slicedrop-customer-secret
              staff token:    slicedrop-staff-secret
              port:           3000

              These hard-coded tokens are suitable only for this exercise. The comments in that file point toward a later course topic: real secrets should come from configuration such as environment variables, rather than source code.

              4. The middleware chain: order is behavior

              Express processes a request by walking through the middleware and route handlers in the order they were registered. Each ordinary middleware has the shape (req, res, next):

              • inspect or change the request;
              • send a response and stop, or call next() to continue;
              • never do neither, or the request can hang.

              The finished app.ts registers the shared chain in this order:

              text
              1. express.json()
              2. /menu router
              3. /orders router
              4. JSON 404 handler
              5. four-argument error handler

              The order matters in several ways.

              JSON parsing must be before routes

              The order handlers expect req.body to contain parsed JSON. Therefore express.json() must be registered before both routers. A valid JSON body is available to the order handler; malformed JSON causes the parser to pass an error to the final error handler.

              Authorization must be before order validation

              POST /orders has this route-specific chain:

              text
              express.json()
                → orders router matches `/`
                → requireCustomerToken
                → POST handler
                → validateOrder
                → addOrder

              The customer middleware is placed before the handler, so a request with no token gets 401 without being validated. The test named “auth runs before validation” deliberately sends {} with no token and expects 401, not 400. That result is a consequence of registration order, not a special case inside the validator.

              GET /orders follows the same pattern with requireStaffToken before the listing handler. A valid customer token is still rejected there because the route requires the staff token.

              404 must be after the routers

              The 404 handler is a normal fall-through middleware. If it came before the routers, it would answer every request and the routers would never run. After the routers, it means “no earlier route matched.” It returns:

              json
              {"error":"Not found"}
              The error handler must be last and have four parameters

              Express identifies error-handling middleware by the exact four-parameter form:

              ts
              (err, req, res, next)

              The finished handler is registered after the 404 handler. The next parameter is not used in this assignment, but it must remain in the function signature so Express recognizes the function as an error handler.

              5. Authentication and exact Bearer parsing

              The authentication module separates token parsing from Express middleware. That is a useful Week 2 design choice: the parser is a plain function that can be tested without constructing a request, while the middleware handles the HTTP response and next().

              What counts as a valid header

              parseBearerToken(header) accepts only the exact shape:

              text
              Bearer <one-or-more-non-whitespace-characters>

              The implementation uses the case-sensitive regular expression /^Bearer ([^\s]+)$/.

              Header valueResult
              missing/undefinednull
              Bearer slicedrop-customer-secrettoken string
              bearer slicedrop-customer-secretnull because Bearer is case-sensitive
              Basic slicedrop-customer-secretnull
              Bearer null because the token is empty
              Bearer one twonull because the token contains whitespace
              Bearer nope"nope"; parsing succeeds, but authorization rejects it

              The parser does not decide whether the token is customer or staff. It only extracts a candidate string or returns null.

              What the two middleware functions do

              requireCustomerToken reads req.headers.authorization, passes it to the parser, and compares the result to CUSTOMER_TOKEN. If it does not match, it sends:

              http
              401 Unauthorized
              json
              {"error":"Unauthorized"}

              On a match it calls next(), allowing validation and order creation to run. requireStaffToken has the same structure but compares with STAFF_TOKEN.

              The distinction is intentional: possession of a valid customer token does not grant access to the staff-only order list.

              6. The menu API
              GET /menu

              With no query parameter, the menu router returns the supplied menu array directly as JSON. It contains six items:

              text
              hawaiian, meat-lovers, build-your-own,
              bread-nugz, dipping-sauce, soda

              With ?category=pizza, the handler uses menu.filter(...) and keeps items whose category exactly equals "pizza". The result contains three pizzas.

              http
              GET /menu?category=pizza
              json
              [
                {"id":"hawaiian","name":"Hawaiian","category":"pizza", "...":"..."},
                {"id":"meat-lovers","name":"Meat Lovers","category":"pizza", "...":"..."},
                {"id":"build-your-own","name":"Build Your Own","category":"pizza", "...":"..."}
              ]

              The "..." markers above mean additional real fields are present; they are not literal response fields. A category with no matches, such as category=seafood, returns 200 and []. If a query value is not a string (for example, a repeated or unusually shaped query parameter), the handler also returns [] rather than treating it as an exact category.

              GET /menu/:id

              The handler reads req.params.id and calls findMenuItem(id). That supplied function uses menu.find(...) and returns either a MenuItem or undefined.

              For a known ID:

              http
              GET /menu/hawaiian

              the response is 200 and includes the full item, including its three sizes, crust choices, and toppings. For an unknown ID:

              http
              GET /menu/sushi

              the response is:

              http
              404 Not Found
              json
              {"error":"Menu item not found"}

              Returning [] would be appropriate for an empty collection filter, but a single requested resource that does not exist is represented by 404.

              7. Orders: creating and listing data
              POST /orders

              The request first needs the customer token. Once authorized, the handler calls validateOrder(req.body). An empty error list means the body is valid. A non-empty list is returned as:

              http
              400 Bad Request
              json
              {
                "errors": [
                  "customerName must be a non-blank string",
                  "items must be a non-empty array"
                ]
              }

              For valid input, addOrder(req.body) creates and stores the order, and the handler returns 201:

              http
              POST /orders
              Authorization: Bearer slicedrop-customer-secret
              Content-Type: application/json
              json
              {
                "customerName": "Ada",
                "items": [
                  {"menuItemId": "hawaiian", "quantity": 2, "size": "large"}
                ]
              }
              http
              201 Created
              json
              {
                "id": "1",
                "status": "pending",
                "createdAt": "2026-09-16T12:00:00.000Z",
                "customerName": "Ada",
                "items": [
                  {"menuItemId": "hawaiian", "quantity": 2, "size": "large"}
                ]
              }

              The timestamp in a real response is the current time, so tests check that it is a parseable string instead of comparing one fixed value. IDs are strings and increase from "1"; each call receives a distinct ID while the process is running. Every new order starts with status "pending".

              GET /orders

              This endpoint requires the staff token:

              http
              GET /orders
              Authorization: Bearer slicedrop-staff-secret

              With no filter it returns the current array in insertion order. With ?status=pending, it calls listOrders("pending") and returns only matching orders. An unknown or currently empty status returns 200 and []; it is not an invalid request.

              The order data is deliberately in memory. src/data/orders.ts starts with:

              ts
              let orders: Order[] = [];
              let nextId = 1;

              addOrder creates an Order, increments nextId, pushes the order, and returns it. listOrders either returns all orders or uses filter for a status. _resetOrders() clears the array and resets IDs for tests. The orders are lost and IDs start over when the Node process restarts; this is not a persistent database.

              8. Order validation, including every error

              validateOrder(body) is a plain function returning string[]. It does not send an HTTP response. The route handler decides that a non-empty array means 400. Keeping validation separate makes the rules easier to read and test.

              The function is defensive because req.body comes from an outside client. A client can send null, a number, an array, or an object with unexpected field types. The implementation checks before reading nested properties.

              Request-level checks
              • 1. It checks whether body is a non-null object and not an array. If not, the

              values used for validation are treated as undefined.

              • 2. customerName must be a string whose trim() is not empty. This rejects a

              missing value, a number, "", and whitespace-only text.

              • 3. items must be a non-empty array. If it is not an array, the function

              returns the errors found so far rather than calling .forEach on an unsafe value.

              Item-level checks

              For every item, the validator checks:

              • the item is object-like before reading its fields;
              • menuItemId is a string identifying an existing menu item;
              • quantity is an integer at least 1;
              • an optional size is offered by that menu item;
              • an optional crust is offered by that menu item;
              • an optional sauce is offered by that menu item;
              • an optional toppings value is an array, and every topping name is offered.

              The validator uses the item index in messages, such as items[0].quantity must be a whole number of at least 1. It does not stop at the first problem. For example, an order with a blank name, an unknown menu ID, and quantity 0 receives all applicable messages in the same errors array. This is more useful to a client than forcing three separate requests.

              Option checks happen only when the menu item was found. That avoids trying to read option lists from undefined. It also means an unknown menu item gets the menu-ID error without a misleading size, crust, sauce, or topping error.

              The four supplied helper functions at the bottom of the file implement the membership checks:

              • offersSize returns false when sizes is absent, then uses .some to

              compare an option's name.

              • offersCrust returns false when crusts is absent, then uses .includes.
              • offersSauce does the same for sauces.
              • offersTopping returns false when toppings is absent, then uses .some

              to compare topping names.

              Matching is exact and case-sensitive. "Large" is not the same as "large". The soda item has sizes but no crusts or toppings; a supplied unsupported option therefore fails validation.

              9. TypeScript ideas in this code
              Interfaces and type aliases

              src/types.ts describes the shapes shared across modules.

              • Category is a union of four allowed strings: "pizza", "appetizer",

              "side", and "beverage".

              • SizeOption and ToppingOption require a name and numeric price.
              • MenuItem requires id, name, and category; description, price,

              sizes, crusts, sauces, and toppings are optional with ?.

              • OrderStatus is the union "pending" | "in-progress" | "completed".
              • OrderItem requires menuItemId and quantity, while customization fields

              are optional.

              • NewOrder is the customer-submitted shape.
              • Order extends NewOrder, so an Order has all of the new-order fields plus

              id, status, and createdAt.

              An interface describes an object shape; a type alias such as Category or OrderStatus can describe a set of allowed literal values. TypeScript checks these shapes while compiling, but a client can still send bad runtime data. That is why validateOrder still performs real runtime checks.

              undefined and narrowing

              Optional properties may be absent, so TypeScript represents their values as possibly undefined. findMenuItem returns MenuItem | undefined because a lookup may fail. The code narrows before using the value:

              ts
              const menuItem = findMenuItem(id);
              if (menuItem === undefined) {
                res.status(404).json({ error: "Menu item not found" });
                return;
              }
              
              res.json(menuItem); // here TypeScript knows it is a MenuItem

              The validator uses checks such as Array.isArray(items), typeof category === "string", and menuItem === undefined to narrow broad runtime values into safe, more specific ones. The early return statements make those narrowed facts easy to follow.

              validateOrder(body: any) intentionally accepts any incoming body so it can inspect malformed values. any turns off compile-time protection for that value, so the function compensates with explicit runtime guards. The topping callback uses unknown, which is safer: a value of type unknown must be checked before treating it as a string.

              Arrays and callback methods

              Several small array methods express the business rules directly:

              ts
              menu.find((item) => item.id === id);       // one matching item or undefined
              menu.filter((item) => item.category === category); // all matches
              items.forEach((item, index) => { ... });   // validate every item
              options.some((option) => option.name === size); // any option matches
              crusts.includes(crust);                    // exact string membership

              The tests also use .map to project order names and .every to verify that a filtered result satisfies a condition. These methods take callback functions: the arrow function receives an element, and the method returns the appropriate result without requiring a manual index loop.

              10. File-by-file walkthrough of the completed work

              This section follows the changed files from the outside of the application toward the inside.

              src/app.ts
              • 1. The imports bring in Express request/response types, the two routers, and

              the types needed by the error handler.

              • 2. export const app = express() creates the application object and exports it

              for both index.ts and tests.

              • 3. app.use(express.json()) installs the body parser before any route.
              • 4. app.use("/menu", menuRouter) mounts the menu router at the /menu base

              path. app.use("/orders", ordersRouter) does the same for orders.

              • 5. The next middleware returns JSON 404 for any request that got through

              both routers without a match.

              • 6. The final four-argument middleware distinguishes malformed JSON from other

              errors. A SyntaxError with a body property is answered with 400 and { error: "Request body must be valid JSON" }. Any other error is logged with console.error and answered with 500 and { error: "Internal server error" }.

              The return statements after responses are control-flow guards. They stop the handler from trying to send a second response.

              src/middleware/auth.ts
              • 1. It imports Express types and the two token constants.
              • 2. parseBearerToken handles only extraction. It returns null for a missing

              header or a regex mismatch and returns capture group 1 for a valid header.

              • 3. requireCustomerToken calls the parser, sends 401 on any non-matching

              token, and calls next() only for the customer token.

              • 4. requireStaffToken repeats that pattern for the staff token.

              The parser is deliberately not middleware. A plain function has no response to send and no next() to call, so its job stays focused and direct.

              src/routers/menu.ts
              • 1. It imports Express request/response/router types and the supplied menu data

              functions.

              • 2. menuRouter = Router() creates a router whose paths are relative to the

              mount point in app.ts.

              • 3. menuRouter.get("/", ...) handles the mounted GET /menu. It returns the

              whole array when category is absent, returns [] when the query value is not a string, and otherwise filters for an exact category.

              • 4. menuRouter.get("/:id", ...) handles mounted GET /menu/:id. It looks up

              the ID, returns a 404 error object when there is no item, and returns the item when there is one.

              Because the router is mounted at /menu, its internal / is not just / to the client. The combination is /menu/ (and Express also handles the usual slash variation), while its internal /:id becomes /menu/:id.

              src/routers/orders.ts
              • 1. It imports the supplied addOrder and listOrders functions, both auth

              middleware functions, and validateOrder.

              • 2. ordersRouter = Router() creates a relative router.
              • 3. ordersRouter.post("/", requireCustomerToken, handler) puts customer auth

              before the handler. The handler collects validation errors, returns 400 with { errors } when needed, and otherwise calls addOrder and returns 201 with the new order.

              • 4. ordersRouter.get("/", requireStaffToken, handler) puts staff auth before

              the listing logic. The handler returns all orders without a status query, returns [] for a non-string query value, and passes a string status to listOrders.

              The leading slash in each router path is relative to the /orders mount, so the public paths are POST /orders and GET /orders.

              src/validation/validate-order.ts
              • 1. It imports the MenuItem type and findMenuItem.
              • 2. It creates an error accumulator, checks whether the body is a non-array

              object, and validates customerName.

              • 3. It validates that items is a non-empty array. If not, it returns the

              accumulated errors safely.

              • 4. It loops through every item, looks up the menu ID, checks quantity, and

              returns the menu-ID and quantity errors as appropriate.

              • 5. When an item and menu item are both safe to inspect, it checks each optional

              customization by calling the supplied helper functions.

              • 6. It returns the complete errors array. An empty array is the success signal.
              • 7. The four helpers at the bottom handle option membership and safely treat an

              absent option list as “not offered.”

              The function deliberately accumulates with errors.push(...) instead of returning immediately from the first bad field. It only returns early when continuing would be unsafe, such as when items is not an array.

              src/types.ts

              This supplied module is the shared vocabulary. It prevents a menu item from silently becoming an arbitrary object inside the typed parts of the program, documents which fields are optional, and distinguishes a customer-submitted NewOrder from a stored Order with server-generated fields.

              src/data/menu.ts

              This supplied module defines the six menu items and reusable constants for standard toppings, crusts, and dipping sauces. findMenuItem uses .find and can return undefined. The data is process-local and is not changed by the API routes.

              src/data/orders.ts

              This supplied module is the exercise's temporary database. addOrder creates the server fields, increments the ID counter, pushes into the array, and returns the object. listOrders preserves insertion order when unfiltered and uses .filter for a status. _resetOrders is test-only and is never called by application routes.

              src/constants.ts and src/index.ts

              constants.ts holds the two tokens and port. index.ts is the executable entrypoint that starts listening. Keeping this start-up action out of app.ts is what lets Supertest import the app safely.

              11. How the tests map to requirements

              Vitest supplies describe, it, beforeEach, expect, and test doubles such as vi. Supertest supplies a client-like API:

              ts
              const res = await request(app)
                .post("/orders")
                .set("Authorization", `Bearer ${CUSTOMER_TOKEN}`)
                .send(ORDER);
              
              expect(res.status).toBe(201);
              expect(res.body.status).toBe("pending");

              request(app) gives the exported Express app to Supertest. It does not require the developer to run npm run dev or start port 3000; Supertest manages the temporary listener used for the request. .set adds a header, .send supplies a JSON body, and Supertest parses JSON responses into res.body.

              src/__tests__/auth.test.ts
              • verifies that missing and unknown customer tokens produce 401;
              • verifies that the customer token allows POST /orders;
              • verifies that missing, unknown, and customer tokens are rejected by

              GET /orders;

              • verifies that the staff token allows the order list;
              • verifies that authentication runs before validation by expecting 401 for a

              missing token and bad body.

              src/__tests__/errors.test.ts
              • verifies a path no router handles returns 404 with an error field;
              • verifies invalid orders return 400 with an errors array;
              • verifies the validator catches unknown menu IDs, quantities below one,

              unsupported crusts, and unsupported sizes;

              • mocks addOrder to throw and verifies an application failure becomes 500,

              not a client-facing 400;

              • sends malformed JSON and verifies the parser error becomes a JSON 400.

              The mock test is especially useful because it distinguishes two error sources: the client sent valid JSON and a valid order, but application code still failed.

              src/__tests__/menu.test.ts
              • checks that the unfiltered menu has six items;
              • checks exact category filtering for pizzas;
              • checks an unknown category returns an empty successful array;
              • checks a known item includes customization options;
              • checks an unknown ID returns 404 with an error field.
              src/__tests__/orders.test.ts
              • checks successful creation returns 201, preserves customer and items,

              starts as pending, gives an ID, and creates an ISO-parseable timestamp;

              • checks two orders receive different IDs;
              • checks an empty order list before creation;
              • checks status filtering and the empty result for a status with no matches;
              • checks that unfiltered results preserve insertion order.

              Each order test file calls _resetOrders() in beforeEach. That keeps tests independent even though the application data module is stateful. Tests should not rely on another test having run first.

              The timestamp assertion demonstrates a general testing rule: assert a stable property, such as “is a parseable date string,” rather than an unstable exact value, such as the clock time from one particular run.

              12. Install, run, test, type-check, and build

              Run these commands from the repository directory:

              bash
              npm install
              npm test
              npm run typecheck
              npm run build

              What each command does:

              • npm install installs the dependencies in package.json and records them

              through the lockfile.

              • npm test runs vitest run, which executes the test suite once.
              • npm run test:watch runs Vitest interactively and reruns tests as files

              change.

              • npm run typecheck runs tsc --noEmit. It checks types without creating

              output files.

              • npm run build runs tsc -p tsconfig.build.json and emits compiled server

              code into dist while excluding tests.

              • npm run dev runs tsx src/index.ts, starts the server on port 3000, and

              prints its local URL. tsx runs TypeScript directly; it does not replace the separate type-check command.

              With the development server running, equivalent manual requests include:

              bash
              curl http://localhost:3000/menu
              curl 'http://localhost:3000/menu?category=pizza'
              curl http://localhost:3000/menu/hawaiian
              
              curl -X POST http://localhost:3000/orders \
                -H 'Authorization: Bearer slicedrop-customer-secret' \
                -H 'Content-Type: application/json' \
                -d '{"customerName":"Ada","items":[{"menuItemId":"soda","quantity":1}]}'
              
              curl http://localhost:3000/orders \
                -H 'Authorization: Bearer slicedrop-staff-secret'

              The last two requests must occur in the same running process if the listing is expected to show the newly created order. Restarting the process clears the in-memory order array.

              The repository README says the short workflow is to install dependencies and make the tests pass, and that the live assignment specification is the primary source when available. The checked-in tests are the concrete, executable examples of the behavior described here.

              13. Common mistakes and a debugging routine
              Common implementation mistakes
              • 1. Registering express.json() too late. Put it before both routers so

              req.body is ready.

              • 2. Putting auth inside validation or after the handler. Auth belongs as the

              route-specific middleware argument before the handler. Otherwise a missing token can incorrectly produce 400 or create an order.

              • 3. Using the wrong token for a role. The customer token is valid only for

              POST /orders; the staff token is required for GET /orders.

              • 4. Accepting loose Bearer formats. The required parser is exact: capital

              Bearer, one literal separating space, and a non-whitespace token with no extra text.

              • 5. Mounting paths twice. Once the app mounts menuRouter at /menu, the

              router should define / and /:id, not /menu and /menu/:id.

              • 6. Returning 200 for a missing single item. Unknown /menu/:id values

              are 404; unknown collection filters are 200 with [].

              • 7. Stopping validation at the first error. Keep pushing all discovered

              problems into the array.

              • 8. Calling .forEach before checking items. Check Array.isArray(items)

              first. Likewise, check that a menu lookup succeeded before reading option lists.

              • 9. Confusing malformed JSON with an invalid order. Malformed JSON is a

              parser error and gets 400 from the global handler. Valid JSON with bad fields gets 400 with { errors: [...] }. A thrown application exception gets 500.

              • 10. Forgetting to return after sending. A handler that sends an error and

              keeps running can attempt a second response and trigger another error.

              • 11. Removing the fourth error-handler parameter. Express uses the four

              parameters to recognize error middleware, even when next is unused.

              • 12. Expecting orders to survive a restart. The array is in memory. Restart

              means an empty list and IDs beginning again at "1".

              • 13. Testing an exact timestamp. Check its type and parseability, as the

              supplied test does.

              • 14. Relying on test order. Reset state in beforeEach; a test should be

              able to run by itself.

              A practical debugging routine

              When a request fails, inspect the problem in this order:

              • 1. Confirm the method and full path. POST /orders and GET /orders are

              different, and a router path is relative to its mount point.

              • 2. Check the status code before reading the body. 401 points to auth, 404

              to routing or an unknown menu ID, and 400 to parsing or validation.

              • 3. Check the headers. For orders, use the exact token and

              Content-Type: application/json.

              • 4. Check the body shape. customerName must be non-blank and items must be

              a non-empty array; each item needs a known menu ID and integer quantity.

              • 5. Add a focused test or run one existing test by its description. A failing

              test tells you the observable contract rather than an internal guess.

              • 6. If the status is 500, read the server log. The global handler logs the

              unexpected error while returning a deliberately generic client message.

              • 7. Run npm run typecheck after fixing behavior. Passing tests alone does not

              prove that TypeScript's compile-time checks pass.

              When the result seems surprising, trace the chain from the top: JSON parser, mount point, router path, route-specific middleware, handler, data function, then response. That follows the order Express actually uses.

              14. Self-check quiz

              Try to answer these without looking at the answer key.

              • 1. What is the difference between the exported app and the server started by

              index.ts?

              • 2. Which middleware makes a valid JSON body available as req.body?
              • 3. Why must that middleware be registered before the routers?
              • 4. What public path is produced when app.ts mounts menuRouter at /menu

              and the router defines get("/:id", ...)?

              • 5. Which token and method are required to create an order?
              • 6. What does parseBearerToken("Bearer nope") return, and where is the token

              accepted or rejected afterward?

              • 7. What response should a request with no token and body {} receive for

              POST /orders?

              • 8. What is the difference between an unknown category and an unknown menu ID?
              • 9. Why does validateOrder return an array instead of immediately responding?
              • 10. Name three conditions that make quantity invalid.
              • 11. Why does the validator check menuItem === undefined before checking a

              size or topping?

              • 12. What does Order extends NewOrder communicate?
              • 13. Why does the error handler need four parameters if it does not call

              next()?

              • 14. How does the application distinguish malformed JSON from an application

              exception?

              • 15. What happens to orders when the Node process restarts?
              • 16. Why do order tests call _resetOrders() before each test?
              • 17. Which command runs tests once, and which command checks types without

              emitting JavaScript?

              • 18. What status should a successful order creation return, and why is that more

              specific than 200?

              Answer key
              • 1. app.ts constructs and exports the Express app for reuse; index.ts

              imports it and calls listen on port 3000.

              • 2. express.json().
              • 3. It must parse the body before handlers that read req.body; its position in

              the chain controls whether the routes see parsed data.

              • 4. GET /menu/:id, such as GET /menu/hawaiian.
              • 5. POST /orders requires the exact customer token

              slicedrop-customer-secret in Authorization: Bearer ... form.

              • 6. It returns the string "nope"; the customer or staff middleware compares

              that candidate with its required constant and rejects it with 401.

              • 7. 401 Unauthorized, because auth runs before validation.
              • 8. An unknown category is an empty successful collection (200, []); an

              unknown single menu ID is 404 with an error object.

              • 9. A plain function can be tested and reused independently of Express; the

              route handler translates its returned errors into an HTTP response.

              • 10. It is missing, not a number, not an integer, zero, or negative. Fractions

              are also invalid.

              • 11. A failed lookup returns undefined, so reading option properties would be

              unsafe and could cause a server exception.

              • 12. A stored Order has all NewOrder fields plus server-generated id,

              status, and createdAt.

              • 13. Express recognizes error middleware by (err, req, res, next); the fourth

              parameter is part of that recognition rule.

              • 14. The handler checks for SyntaxError with a body property and returns

              400; all other errors are logged and return generic 500.

              • 15. The in-memory array disappears, so orders are lost and the next ID starts

              at "1" in the new process.

              • 16. To clear shared in-memory state and keep tests independent of execution

              order.

              • 17. npm test runs the suite once; npm run typecheck runs tsc --noEmit.
              • 18. 201 Created, because the request created a new order resource and the

              status communicates that fact to the client.

              If the answer to any question was uncertain, trace the matching request through the middleware chain and then read the corresponding test. That is the same method used to understand the rest of this API: identify the request, follow the ordered handlers, and inspect the status and JSON response.

              第 3 週 · 完整原始指南

              Original Markdown snapshot · Classes/si-679-f-26-week03-nyt-kelvintiger/STUDY_PLAN.md

              Week 3 Study Plan: MongoDB, Express, and API Tests

              Use this guide with the completed code in src/. It starts with the ideas behind the code, then walks through each file and gives you small checks to make sure you can explain what is happening.

              What you should be able to do

              By the end of this week, you should be able to:

              • 1. explain how a MongoDB collection and document compare with a SQL table and row;
              • 2. connect a TypeScript program to a MongoDB database;
              • 3. create, read, update, and delete documents;
              • 4. place database functions behind Express routes;
              • 5. recognize a MongoDB ObjectId and reject a malformed one;
              • 6. use Vitest, Supertest, and MongoMemoryServer to test an API without changing a real database;
              • 7. use test hooks to give every test a clean starting state.
              1. Start with the database vocabulary

              MongoDB is a document-oriented NoSQL database. A useful first comparison is:

              MongoDBRough SQL comparisonExample in this project
              databasedatabaseweek3app or the test database
              collectiontableproducts
              documentrowone Duct Tape product
              fieldcolumnname, price, or quantity

              A product document looks much like a JavaScript object:

              ts
              {
                name: 'Duct Tape',
                price: 5.99,
                quantity: 120
              }

              MongoDB adds an _id field when the document is inserted. The value is an ObjectId. Its printed form is a string of 24 hexadecimal characters, but the database value is an ObjectId, not an ordinary string.

              Checkpoint:

              • Which collection stores the records in this app?
              • Which part uniquely identifies one product?
              • Why must new ObjectId(id) be used before searching by _id?
              2. Practice Mongo operations directly

              Read src/db-explore.ts first. It is a scratch program for learning the database driver before Express is involved.

              The connection sequence is:

              text
              MongoClient(uri) -> client.connect() -> client.db(name) -> collection(name)

              The file includes these four CRUD operations:

              GoalMongoDB method
              create documentsinsertMany()
              read documentsfind(), findOne()
              update documentsupdateOne(), updateMany()
              delete documentsdeleteOne()

              Mongo queries are objects. { name: 'Masking Tape' } means “documents whose name equals Masking Tape.” { price: { $gte: 5 } } means “documents whose price is greater than or equal to 5.” The $set update operator changes only the fields named inside it.

              The four Now You Try functions demonstrate those ideas:

              • findPricierTest() uses $gte to find products costing at least $5.
              • updatePriceTest() changes only Scotch Tape's price.
              • deleteByNameTest() deletes a document using a name query.
              • markOnSaleTest() uses updateMany() to add onSale: true to every

              product costing less than $5.

              Exercise:

              • 1. Start local MongoDB.
              • 2. Uncomment await addProducts() in main() and run npm run explore once.
              • 3. Comment it again so repeated saves do not create duplicates.
              • 4. Run each Now You Try function one at a time and print the result.
              • 5. After each operation, print getAllProducts() and predict the output before

              looking at it.

              3. Separate database work from HTTP work

              src/db.ts is the database layer used by the application. It keeps the MongoDB driver details in one place. Express routes do not need to know how a client is created or which collection method performs an update.

              The public functions form a small interface:

              FunctionInputOutput
              connectURI and database namean open connection
              getAllProductsnoneevery product document
              getProductstring IDone document or null
              addProducta Productthe new ObjectId
              updateProductID and partial changesnumber of matched documents
              deleteProductIDnumber of deleted documents

              ProductUpdate makes each property optional. That matters because a PATCH request can change only price while leaving name and quantity alone.

              _clearProducts() is intentionally marked as a test helper. Calling it in the running app would erase the whole products collection.

              Checkpoint:

              • Why does updateProduct() return matchedCount instead of the full driver result?
              • How does a route use matchedCount === 0 to choose an HTTP status?
              • Why does connect() accept its URI and database name as arguments?
              4. Map CRUD operations to HTTP routes

              src/product-router.ts maps the database functions to an HTTP API:

              RequestDatabase actionSuccessful response
              GET /productsread all200 and a JSON array
              GET /products/:idread one200 and one JSON document
              POST /productscreate201 and { "id": ... }
              PATCH /products/:idupdate selected fields200
              DELETE /products/:iddelete one200

              A valid-looking ID that is absent from the database produces 404 Not Found. A malformed ID produces 400 Bad Request. These are different failures: the first identifies no document, while the second is not a usable MongoDB ID.

              src/validate-id.ts handles malformed IDs before a route calls the database. Without that middleware, new ObjectId('badID123') throws and looks like a server failure. ObjectId.isValid() lets the app report the client's input error accurately.

              src/app.ts first registers express.json(), then mounts the router at /products. The parser must run first so a POST or PATCH handler can read req.body.

              src/index.ts opens the real database connection before the server listens. Tests import app.ts instead, connect to their own temporary database, and do not run index.ts. This is why the app and server are split into two files.

              Exercise:

              • 1. Trace PATCH /products/abc... from app.ts to the response.
              • 2. Write down each function it passes through.
              • 3. Repeat with a malformed ID and notice where the path stops.
              5. Understand the test setup

              The tests use three tools with different jobs:

              • Vitest runs test files and provides describe, it, expect, and hooks.
              • Supertest sends HTTP requests directly to the Express app.
              • MongoMemoryServer starts a temporary real MongoDB process for the tests.

              The hooks establish a predictable lifecycle:

              text
              beforeAll:  start temporary MongoDB and connect once
              beforeEach: empty the products collection
              test:       arrange data, send request, check response and state
              afterAll:   disconnect and stop temporary MongoDB

              Cleaning before every test prevents one test's documents from changing a later test's result. Stopping MongoDB in afterAll prevents the test process from hanging.

              The main matchers in this project are:

              • toBe() for exact primitive values such as a status code;
              • toEqual() for arrays or objects with the same contents;
              • toMatch() for the 24-character ID pattern;
              • toHaveLength() for array size;
              • toContain() and .not.toContain() for membership.

              The PATCH test checks more than the changed price. It also checks that name and quantity stayed the same. That is what proves the code performs a partial update.

              The DELETE test verifies both the response and the database state by trying to read the deleted product and by checking the final product list.

              6. Suggested study session
              First 20 minutes: vocabulary and data flow
              • Draw the path from an HTTP request to MongoDB and back.
              • Label the app, router, database layer, collection, and response.
              • Explain collection, document, field, and ObjectId aloud.
              Next 25 minutes: direct Mongo practice
              • Work through db-explore.ts one function at a time.
              • Predict what each query matches.
              • Verify the resulting documents in getAllProducts() or MongoDB Compass.
              Next 25 minutes: API code
              • Read db.ts, then product-router.ts, then app.ts, then index.ts.
              • For each route, identify its input, database function, success status, and

              missing-resource behavior.

              Final 20 minutes: tests
              • Run npm test.
              • Temporarily change one expected status to the wrong value and read the

              failure. Restore it afterward.

              • Explain why every hook is beforeAll, beforeEach, or afterAll.
              • Add one extra GET-by-ID success test on your own.
              Final self-check

              You are ready to move on when you can answer these without opening the code:

              • 1. What is the difference between a MongoDB document and a JavaScript object?
              • 2. Why is a string ID converted to an ObjectId?
              • 3. What do $gte and $set do?
              • 4. Why does PATCH use optional fields?
              • 5. Why do malformed and missing IDs have different status codes?
              • 6. Why does each test need a clean collection?
              • 7. Why does the test suite import app instead of index?

              Useful commands:

              bash
              npm run typecheck
              npm run build
              npm test
              npm run explore
              npm run dev
              第 4 週 · 完整原始指南

              Original Markdown snapshot · Classes/si-679-f-26-week-04-nyt-kelvintiger/STUDY_PLAN.md

              Week 4: REST APIs, layers, and tests

              Start with this: a client asks for something, the server does the work, and the server sends an answer. In this project, the thing is a product stored in MongoDB. The client could be Postman, a browser app, or one of our tests.

              The lecture and Now You Try instructions are the source for this exercise. We built the lecture's GET and POST routes, GET by id for Now You Try #1, its tests for #2, and both DELETE stretch items.

              1. What an API request contains

              An API is an agreed way for one program to ask another program to do something. Here the agreement uses HTTP. A request has a method and a path, and sometimes a body. For example:

              text
              GET /products

              GET means read. /products names the collection of products. The server answers with a status code and a JSON body. JSON is text representing objects and arrays. It looks like JavaScript, but keys must have double quotes.

              json
              [
                {
                  "id": "507f1f77bcf86cd799439011",
                  "modelName": "Coffee maker",
                  "modelNumber": "CM1",
                  "manufacturer": "Acme",
                  "color": "Black",
                  "price": 49.99,
                  "quantity": 10
                }
              ]

              The outer square brackets mean an array. Each object is one product. That id is an illustration; use an id from your own response when making a request.

              2. What REST adds

              REST gives us principles for keeping the client/server agreement consistent. Use resource names such as /products and let the method describe the action.

              Method and pathMeaningSuccess code
              GET /productsRead the collection200
              GET /products/:idRead one product200
              POST /productsCreate a product201
              DELETE /products/:idRemove a product204

              :id is a placeholder in our route definition. A real client replaces it with the product's id. GET /products/507f1f77bcf86cd799439011 is a concrete request.

              200 means success with an answer. 201 means created. 204 means success with no response body. 400 means the request is malformed. 404 means the resource or route does not exist. 500 means an unexpected server failure.

              REST also says requests should be stateless: the request supplies what is needed to handle it. The server does not need to remember which product you asked for last time. This does not mean the database cannot store products between requests.

              The client receives a representation of a product: a JSON copy of its current fields. It does not receive direct control of the database document. If it changes its local copy, the stored product does not change.

              The lecture's six constraints are a uniform interface, client/server separation, stateless requests, cacheability, a layered system, and optional code on demand. Our exercise concentrates on naming, HTTP methods, representations, and layers. It does not implement every feature of a complete shop API.

              3. Why split the code into layers?

              We could put a database query inside every route. It works initially, but then database details, HTTP details, and application decisions are mixed together. This week separates those jobs:

              text
              client request
                  -> app -> route -> controller -> service -> database
                                           <- product <- document
              client response <- controller

              The model helps the service turn the document into a product. These are folders inside one server program, not separate servers.

              FileIts job
              src/index.tsConnect to MongoDB, then start listening
              src/app.tsAssemble Express and middleware
              src/routes/product-routes.tsMatch methods/paths to controllers
              src/controllers/product-controllers.tsRead the request and send the HTTP response
              src/services/product-service.tsCoordinate product operations and conversion
              src/models/product.tsDescribe the Product type and convert fields/documents
              src/db/db.tsConnect and run MongoDB operations
              src/middleware/validate-id.tsCheck the path id before using it
              src/middleware/validate-product.tsCheck supplied product fields
              src/middleware/error-handler.tsSend a response for unexpected errors
              src/__tests__/products.test.tsCheck behavior against a temporary database

              The route knows about the controller. The controller knows about the service. The service knows about models and the database. The database does not know about Express. That direction matters: changing the HTTP response should not require changing a Mongo query.

              4. Follow GET for one product

              Open product-routes.ts. This line registers the route:

              ts
              productRouter.get('/:id', validateId, productControllers.getProduct);

              app.ts mounts this router at /products, so the complete path is /products/:id. validateId runs first. A Mongo ObjectId is represented here by 24 hexadecimal characters (digits and letters a–f). An invalid shape receives 400.

              The controller reads req.params.id. params is where Express puts values matched by path placeholders. The controller calls:

              ts
              const product = await productService.get(String(req.params.id));

              The service calls db.getInCollection(db.PRODUCTS, id). The database converts the string to an ObjectId and searches for a document whose _id matches:

              ts
              findOne({ _id: new ObjectId(id) })

              If nothing matches, Mongo returns null. The service returns null too. The controller turns that into 404. The service does not choose 404, because that is an HTTP decision.

              If a document exists, productFromDocument creates a new object containing only the product fields. Mongo's _id becomes a string named id. The controller calls res.json(product), which sends that object with status 200.

              5. Read the TypeScript without getting stuck
              ts
              const get = async (id: string): Promise<Product | null> => {

              Read it as: "get takes a string id, does asynchronous work, and eventually gives back a Product or null."

              • const declares a name that cannot be reassigned.
              • async means the function returns a Promise: an eventual result.
              • await waits for that eventual result before continuing this function.
              • Product | null means either a Product or no matching product.
              • Product[] means an array of Products.
              • Promise<void> means asynchronous work with no useful return value.

              type Product describes an object's fields for TypeScript. It helps the compiler catch mistakes; it does not check incoming JSON at runtime. Middleware does that. import type brings in a type for the compiler. Normal import brings in code that runs. Imports end in .js because that is the extension after compilation.

              Partial<Product> makes every Product field optional. The lecture uses it for incoming fields. fields.price ?? 0 supplies zero when price is missing, while preserving a supplied zero. .map(...) builds a new array by converting each element of the old array.

              6. Follow POST and DELETE

              For POST, express.json() parses the JSON into req.body. Validation checks that it is an object and that any supplied fields have the right types. Strings stay strings; price and quantity must be nonnegative numbers, and quantity must be a whole number. Omitted fields are allowed, matching the lecture's defaults.

              productService.add selects the six known product fields and inserts them. Mongo creates _id; the caller cannot choose it by supplying id or _id. The controller responds with 201 and { "id": "..." }. Use that id in GET.

              For DELETE, Mongo's deleteOne returns a result with deletedCount. The service turns that into a boolean. The controller sends 204 if one product was deleted, or 404 if none was. Deleting twice should produce 204 then 404.

              Middleware order is important: parse JSON, route the request, handle unknown routes, then handle errors. Error middleware has four parameters, with err first; Express uses that signature to recognize it. Express 5 forwards rejected Promises from async controllers to the error handler.

              7. Run it yourself

              From this repository folder:

              bash
              npm ci
              npm test
              npm run typecheck
              npm run build

              Tests do not need your normal MongoDB server. To use Postman or curl, start your normal MongoDB separately at 127.0.0.1:27017. In Compass, connect to it, create database week4 and collection products, and import sampleData/products.json. Then:

              bash
              npm run dev

              Keep that terminal open. In another terminal:

              bash
              curl -i http://localhost:6790/products
              curl -i -X POST http://localhost:6790/products \
                -H 'Content-Type: application/json' \
                -d '{"modelName":"Practice coffee maker","price":25,"quantity":3}'

              Copy the new id from POST. Replace YOUR_ID below with it:

              bash
              curl -i http://localhost:6790/products/YOUR_ID
              curl -i -X DELETE http://localhost:6790/products/YOUR_ID
              curl -i http://localhost:6790/products/YOUR_ID

              Use a product you created for practice, because DELETE removes it. Expect 200, then 204 with an empty body, then 404. Stop the dev server with Control-C. npm run build followed by npm start runs compiled JavaScript instead of the development watcher. Optional MONGO_URI, DB_NAME, and PORT overrides let you point this app at a separate practice database.

              If you get connection refused, MongoDB is not reachable at the chosen address. If the port is in use, stop the other server or choose another PORT. If GET returns [], check the database/collection and your import. A 400 id error means you did not replace YOUR_ID with a real 24-character id.

              8. What the tests teach

              Vitest runs the tests. Supertest sends HTTP requests to our Express app. A MongoMemoryServer launches a real temporary MongoDB on a separate port. The tests call db.init(mongo.getUri(), 'week4-tests') so they use that database.

              beforeAll starts/connects once. beforeEach empties only that test database's products and inserts the same two coffee makers. afterEach restores the one service mock used for the error test. afterAll disconnects and stops MongoDB. Never call _clearCollection on your normal database just to run these tests.

              Each test follows arrange, act, assert. Setup arranges two known products; a request acts; expect checks the result. GET's setup inserts directly through the database layer so a broken POST does not cause a misleading GET failure.

              Use toBe for values like a status number, toEqual for an object's contents, toHaveLength for array size, and toMatch for an id's shape. Two separate objects can have equal contents while failing toBe, which checks identity.

              The suite checks list shape, creation/readback, successful and absent GET, successful and absent DELETE, empty collections, defaults, invalid input, unknown paths, and unexpected service errors. It runs the source tests once; compiled copies under dist/ are excluded.

              Supertest imports app.ts, so it does not execute index.ts. Passing tests alone do not prove the real server starts. That is why checking npm start and actual HTTP requests is a separate step. GitHub's small classroom check also does not substitute for these route checks or the instructor's review.

              9. Practice and check yourself
              • 1. Trace GET by id through the five layers without running it. At which point

              does the id become an ObjectId? At which point does it become a string again?

              • 2. Create a product with price zero. Explain why ?? preserves that value.
              • 3. Write a test that creates and deletes a product, then confirms GET returns 404.
              • 4. Think through adding PATCH without coding it yet: which layer chooses the

              response status, which layer coordinates the update, and which talks to Mongo?

              • 5. Change a test's expected status to the wrong number, run that test, and read

              the failure. Restore the expectation afterward.

              Answers: ObjectId conversion happens in the database query; document-to-product conversion makes the response id a string. ?? only replaces null/undefined, not zero. The controller chooses HTTP status, the service coordinates the update, and the database layer executes it. A good deletion test checks both the delete response and a later read, rather than trusting the first response alone.

              Suggested first session: spend ten minutes on requests/statuses, fifteen tracing GET through the files, fifteen making POST/GET/DELETE requests, and ten reading and changing one test. If you can explain why a missing product becomes null in the service but 404 in the controller, you understand the central idea of today.

              程式碼資料夾與閱讀順序

              資料夾依此順序閱讀
              week1hello/第 1 週 NYTserver.js → Postman/curl 中對應的請求。
              TypeScript 基礎README.md → src/index.js → src/index.ts → 預期的示範輸出。
              第 2 週types.ts → books-service.ts → books-router.ts → app.ts → index.ts → 測試。
              HW1types/data → auth/validation → routers → app/index → auth/menu/orders/errors 測試。
              第 3 週db-explore.ts → db.ts → validate-id.ts/product-router.ts → app/index → 測試。
              第 4 週db/db.ts → models/product.ts → services → controllers → routes/middleware → app/index → 測試。

              Week 3 · HTML source · Week 4 · HTML source · Week 3 prep · HTML source · Week 4 memo · HTML source · Week 5 · HTML source · Week 6 · HTML source

              來源依據 · 目前課程頁面、本機檔案與保留的指南。原始指南文字完整保留。

              ↑ 查看詞彙表