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

              来源依据 · 目前课程页面、本机文件与保留的指南。原始指南文字完整保留。

              ↑ 查看词汇表