DrillLab

模拟考 B · Book Reviews SubgraphMock exam B · Book Reviews Subgraph

图书评论 subgraph。它既不拥有 Author 也不拥有 Book —— 两者都由 Catalog subgraph 提供,本服务只往它们身上挂 reviews 和 averageRating。Book 用的是复合 key(isbn + edition),这是真实项目里很常见、但比单字段 key 更容易写错的情况。A book reviews subgraph. It owns neither Author nor Book — both come from the Catalog subgraph, and this service only attaches reviews and averageRating to them. Book uses a composite key (isbn plus edition), which is common in real projects and easier to get wrong than a single-field key.

DrillLab 自出Written by DrillLab建议 90 分钟~90 min6 个任务6 tasks满分 119119 points total
这套题在考什么What this paper tests

与真实 Task 1 相同的考点:entity 与 @key、__resolveReference、字段 resolver 的 parent、schema 可空性决定的兜底策略、DataLoader 防 N+1 及其长度/顺序契约、结构化错误与 correlation id、以及「catch 不要吞掉已结构化错误」。新增三个考点:复合 @key、可空标量字段(null 与 0 的区别)、以及一处「batch 函数用了 filter」的埋雷。The same points as the real Task 1: entity and @key, __resolveReference, the parent argument of a field resolver, the fallback the schema nullability forces on you, DataLoader against N+1 and its length and order contract, structured errors with a correlation id, and not letting a catch swallow an already structured error. Three points are new: a composite @key, a nullable scalar field (where null and 0 differ), and one planted bug where the batch function uses filter.

任务与评分标准Tasks and rubric
TASK 1Task 1 · Author 上的两个字段Task 1 · The two fields on Author20 分20 pts
  • Author.reviews:按 author.id 取全部评论。schema 是 [Review!]!,绝不返回 nullAuthor.reviews: fetch all reviews by author.id. The schema says [Review!]!, so never return null
  • Author.averageRating:用 ratingDataSource.computeAverage 计算。schema 是 Float(可空),没有评论时返回 nullAuthor.averageRating: compute it with ratingDataSource.computeAverage. The schema says Float, which is nullable, so return null when there are no reviews
  • 两个都要 try/catch + 结构化错误 + correlationId 日志Both need try/catch, a structured error, and a correlationId in the log
  • catch 第一行必须放行已经是 GraphQLError 的错误The first line of the catch must let an error through if it is already a GraphQLError
6 分6 ptsreviews 用了正确的数据源方法并兜底成 []reviews uses the right data source method and falls back to []
6 分6 ptsaverageRating 返回 null 而不是 0(区分「没有数据」和「平均分是 0」)averageRating returns null rather than 0, so no data is distinct from an average of 0
4 分4 pts两个 resolver 都带 try/catch 与 correlationIdBoth resolvers have try/catch and a correlationId
4 分4 ptscatch 里放行了已结构化的 GraphQLErrorThe catch lets an already structured GraphQLError through
TASK 2Task 2 · 复合 key 的 entityTask 2 · An entity with a composite key19 分19 pts
  • Book.__resolveReference:Book 的 @key 是 "isbn edition" 两个字段,返回的对象必须同时保留这两个Book.__resolveReference: the @key on Book is the two fields "isbn edition", so the object you return has to keep both
  • Book.reviews:必须同时按 isbn 和 edition 过滤 —— 只按 isbn 会把其他版本的评论混进来Book.reviews: filter by isbn and edition together — filtering by isbn alone mixes in reviews of other editions
  • 注意 edition 是 Int、isbn 是 String,别把类型搞混Note that edition is an Int and isbn a String; do not mix the types up
8 分8 pts__resolveReference 返回了两个 key 字段(不是只有 isbn)__resolveReference returns both key fields, not isbn alone
8 分8 ptsBook.reviews 同时用了 isbn 和 edition 过滤Book.reviews filters on isbn and edition together
3 分3 pts调了 fetchByBook 而不是自己在 resolver 里 filterCalls fetchByBook instead of filtering inside the resolver
TASK 3Task 3 · Review.reviewer 与 DataLoader 契约Task 3 · Review.reviewer and the DataLoader contract22 分22 pts
  • Review.reviewer:用 loaders.reviewerLoader 防 N+1,不许直接调数据源Review.reviewer: use loaders.reviewerLoader to avoid N+1; do not call the data source directly
  • schema 里 reviewer 可空,找不到时返回 null(测试断言 toBeNull)reviewer is nullable in the schema, so return null when there is no match (the test asserts toBeNull)
  • 修好 createReviewerLoader 里的两处问题:方法名,以及那个会破坏长度/顺序契约的 filterFix the two problems in createReviewerLoader: the method name, and the filter that breaks the length and order contract
6 分6 pts走了 loader 而不是直接调 reviewerDataSourceGoes through the loader instead of calling reviewerDataSource directly
8 分8 pts修掉了 batch 函数里的 filter(长度与顺序必须与 keys 对齐)The filter in the batch function is gone; length and order must line up with keys
5 分5 pts用了数据源上真实存在的方法名(lookupReviewer,不是 getReviewer)Uses the method name the data source actually has: lookupReviewer, not getReviewer
3 分3 pts找不到时显式返回 nullReturns null explicitly when there is no match
TASK 4Task 4 · 两个 QueryTask 4 · The two queries16 分16 pts
  • Query.review:用 reviewLoader;找不到抛带 REVIEW_NOT_FOUND code 的 GraphQLErrorQuery.review: use reviewLoader; when there is no match, throw a GraphQLError carrying the REVIEW_NOT_FOUND code
  • Query.reviews:校验 authorId;schema 是 [Review!]! 所以兜底 []Query.reviews: validate authorId; the schema says [Review!]!, so fall back to []
  • 两个都带 correlationId 日志Both need a correlationId in the log
5 分5 ptsQuery.review 用了 loaderQuery.review uses the loader
6 分6 pts找不到时抛 REVIEW_NOT_FOUND(不是 SERVICE_ERROR)Throws REVIEW_NOT_FOUND when there is no match, not SERVICE_ERROR
5 分5 ptsQuery.reviews 校验了 authorId 并兜底 []Query.reviews validates authorId and falls back to []
TASK 5Task 5 · 修好 Mutation.createReviewTask 5 · Fix Mutation.createReview24 分24 pts
  • 它注释说「提供作参考」,但它是坏的 —— 自己找出并修好全部问题Its comment says it is provided for reference, but it is broken — find and fix everything wrong with it
  • 至少有三处:数据源键名、insertReview 的调用方式、以及 catch 吞掉结构化错误There are at least three: the data source key name, the way insertReview is called, and a catch that swallows a structured error
  • 还有一处最隐蔽:insertReview 内部要用 reviewer.displayName 写审计日志,所以传进去之前必须先把 reviewer 查出来附上One more is the least obvious: insertReview writes an audit log using reviewer.displayName, so the reviewer has to be looked up and attached before it is passed in
5 分5 pts修对了数据源键名(reviewDataSource,不是 reviewAPI)The data source key name is correct: reviewDataSource, not reviewAPI
5 分5 pts按真实签名调用 insertReview(五个位置参数,不是一个对象)insertReview is called with its real signature: five positional arguments, not one object
8 分8 pts创建前先查出 reviewer 并附上(本题最隐蔽的一处)The reviewer is looked up and attached before creating, the least obvious part of this task
6 分6 ptscatch 第一行放行已结构化的 GraphQLError,让 INVALID_INPUT 传得出去The first line of the catch lets a structured GraphQLError through, so INVALID_INPUT reaches the caller
TASK 6Task 6 · 验证Task 6 · Verify18 分18 pts
  • npm test 全部 14 个测试通过All 14 tests pass under npm test
  • 自己写一个 verify 脚本:查 _service 的 SDL、用 _entities 分别解析 Author 和 Book(后者要传两个 key 字段)Write a verify script yourself: read the SDL from _service, and resolve Author and Book separately through _entities (the second one takes two key fields)
  • 在日志里确认 reviewerLoader 的批量合并真的发生了(一行 Batching,N 大于 1)Confirm in the log that reviewerLoader really did batch (one Batching line, with N greater than 1)
8 分8 pts14 个测试全过All 14 tests pass
6 分6 ptsverify 脚本能用 _entities 解析复合 key 的 BookThe verify script resolves a composite-key Book through _entities
4 分4 pts确认了 DataLoader 的合并(日志里 N > 1)DataLoader batching is confirmed, with N > 1 in the log

这一页故意不给运行环境There is deliberately no runner on this page

不是做不到。是这一档的意义就在于什么都不给。Coding 题里那 11 道浏览器沙箱把文件、依赖、 测试全备好了 —— 你只要写函数体。真实考试不是这样:你会拿到一个空文件夹 或者一份跑不起来的脚手架,自己 npm install、自己读报错、 自己决定文件放哪。Not because we cannot. Because getting nothing is the point of this tier. The 11 browser sandboxes under Coding hand you files, deps and tests. A real assessment does not: you get an empty folder or a scaffold that does not build, and you install, read the errors and lay out the files yourself.

所以下面给足了三样东西:能直接抄的命令完整的文件树起始态和做对之后各该看到什么(都是实测数字)。搭不起来不该是这道题的难点。So below you get three things: commands you can paste, the full file tree, and what the tests actually print before and after (both measured). Getting set up should not be the hard part.

去哪跑:本机 + VS Code 最接近真实考试,要先装好 Node。 装不了 Node 就用 StackBlitz —— 它把 Node 编译进了浏览器,所以这种要 npm test 的服务端项目它也能跑 (本站用的 Sandpack 不行,浏览器 iframe 里没有 Node)。Where: local VS Code is closest to the real thing; install Node first. No Node? Use StackBlitz — its WebContainers run Node in the browser, so even this server-side project works there.

从零起一个项目Bootstrap the project
mkdir book-reviews && cd book-reviews && npm init -y
空目录起步Start from an empty directory.
npm pkg set type=module
这套题用 ESM。忘了这一步,import 语句会直接报 Cannot use import statement outside a moduleThis paper uses ESM. Skip this and your import statements fail with Cannot use import statement outside a module.
npm i @apollo/server @apollo/subgraph graphql graphql-tag dataloader
运行时依赖Runtime dependencies.
npm i -D jest @jest/globals
判卷器The grader.
npm pkg set scripts.test="NODE_OPTIONS=--experimental-vm-modules jest"
ESM 下的 jest 必须带这个 flag,否则报 Cannot use import statement outside a moduleJest under ESM needs this flag, otherwise you get Cannot use import statement outside a module.
文件清单(目录可以不完全一样,接口要对得上)Files (your layout can differ; the interfaces have to match)高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
package.jsonjest 段:testEnvironment 用 node、transform 留空对象、testMatch 指到 __tests__The jest block: testEnvironment node, transform an empty object, testMatch pointing at __tests__.
src/schema.graphqlPROVIDED —— 精读它,可空性决定你的兜底策略PROVIDED — read it closely; nullability decides your fallback strategy.
src/dataSources/reviewDataSource.jsPROVIDED —— 先抄一张方法名表出来PROVIDED — copy out a table of its method names before you start.
src/resolvers/reviewResolvers.jsEDIT THIS —— 7 个 TODO 加 6 处埋雷,全部改动都在这个文件里EDIT THIS — 7 TODOs plus 6 planted traps. Every change you make lives in this file.
__tests__/resolvers.test.js判卷器,14 个测试。原样抄,不要改它The grader, 14 tests. Copy it verbatim and do not edit it.
你该看到什么What you should see
一行还没写的时候Before you write anythingTests: 10 failed, 4 passed, 14 total —— 那 4 个「通过」里有假通过,空实现恰好满足了断言,别当成做对了Tests: 10 failed, 4 passed, 14 total — some of those 4 passes are false: an empty implementation happens to satisfy the assertion. Do not read them as progress.看不到这个就是环境没搭对,先解决它再动手。If you do not see this, the setup is wrong. Fix that first.
全做对之后When you are doneTests: 14 passed, 14 totalTests: 14 passed, 14 total这个数字是参考解法在本机实测出来的,不是估的。Measured from the reference solution on a real machine.
验收命令 —— 逐条跑Acceptance commands
npm install
依赖装好
npm test
Tests: 14 passed, 14 total(starter 状态下的基线是 10 failed / 4 passed)
node verify-schema.mjs
SDL 含两个 @key(单字段的 Author 和复合字段的 Book);_entities 能分别解析两者;日志里 reviewerLoader 出现一行 Batching 且 N > 1
Starter 代码Starter code
GraphQL SDLsrc/schema.graphql(PROVIDED —— 精读它,可空性决定你的兜底策略)src/schema.graphql (PROVIDED — read it closely; nullability decides your fallback)示意Illustrative
1extend schema
2 @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])
3
4# Author 由 Catalog subgraph 拥有,本 subgraph 只贡献 reviews 与 averageRating
5type Author @key(fields: "id") {
6 id: ID! @external
7 reviews: [Review!]!
8 averageRating: Float
9}
10
11# Book 也不是本 subgraph 拥有的,而且它用「复合 key」定位
12type Book @key(fields: "isbn edition") {
13 isbn: String! @external
14 edition: Int! @external
15 reviews: [Review!]!
16}
17
18type Review {
19 id: ID!
20 authorId: ID!
21 isbn: String!
22 edition: Int!
23 rating: Int!
24 body: String!
25 createdAt: String!
26 reviewer: Reviewer
27}
28
29type Reviewer {
30 displayName: String!
31 verified: Boolean!
32}
33
34type Query {
35 review(id: ID!): Review
36 reviews(authorId: ID!): [Review!]!
37}
38
39type Mutation {
40 createReview(authorId: ID!, isbn: String!, edition: Int!, rating: Int!, body: String!): Review!
41}
四处先标出来:Author.reviews 和 Book.reviews 都是 [Review!]!(双重非空);Author.averageRating 是 Float(可空);Review.reviewer 可空;Book 的 @key 是两个字段。Mark four things before you start. Author.reviews and Book.reviews are both [Review!]! — a non-null list of non-null items. Author.averageRating is Float, so it may be null. Review.reviewer may be null. Book has a @key made of two fields.
JavaScriptsrc/dataSources/reviewDataSource.js(PROVIDED —— 抄一张方法名表)src/dataSources/reviewDataSource.js (PROVIDED — copy out a table of method names)示意Illustrative
1// src/dataSources/reviewDataSource.js —— PROVIDED,别改
2
3class ReviewDataSource {
4 constructor() {
5 this.reviews = [
6 { id: 'rev-1', authorId: 'a-7', isbn: '978-1', edition: 1, rating: 5,
7 body: '很好', createdAt: '2026-01-02T10:00:00Z' },
8 { id: 'rev-2', authorId: 'a-7', isbn: '978-1', edition: 2, rating: 3,
9 body: '一般', createdAt: '2026-01-05T10:00:00Z' },
10 { id: 'rev-3', authorId: 'a-9', isbn: '978-2', edition: 1, rating: 4,
11 body: '不错', createdAt: '2026-01-09T10:00:00Z' },
12 ];
13 }
14
15 async fetchReview(id) { /* 10ms 延迟;找不到返回 undefined */ }
16 async fetchByAuthor(authorId) { /* 10ms 延迟;找不到返回 [] */ }
17 async fetchByBook(isbn, edition) { /* 10ms 延迟;找不到返回 [] */ }
18 async insertReview(authorId, isbn, edition, rating, body) {
19 /* 10ms 延迟。注意:内部会用 reviewer.displayName 写审计日志,
20 所以调用方必须先把 reviewer 查出来附在返回对象上 */
21 }
22}
23
24class ReviewerDataSource {
25 // 注意方法名不是 getReviewer
26 async lookupReviewer(authorId) { /* 15ms;找不到返回 null */ }
27}
28
29class RatingDataSource {
30 async computeAverage(authorId) { /* 20ms;没有评论时返回 null */ }
31}
32
33export { ReviewDataSource, ReviewerDataSource, RatingDataSource };
真实方法名:fetchReview / fetchByAuthor / fetchByBook / insertReview / lookupReviewer / computeAverage。starter 里有一处调了不存在的方法。The real method names are fetchReview / fetchByAuthor / fetchByBook / insertReview / lookupReviewer / computeAverage. One call in the starter uses a method that does not exist.
JavaScriptsrc/resolvers/reviewResolvers.js(EDIT THIS —— 7 个 TODO + 6 处埋雷)src/resolvers/reviewResolvers.js (EDIT THIS — 7 TODOs plus 6 planted bugs)已跑通Verified
1// src/resolvers/reviewResolvers.js —— EDIT THIS
2
3import DataLoader from 'dataloader';
4import { GraphQLError } from 'graphql';
5
6const ErrorCodes = {
7 REVIEW_NOT_FOUND: 'REVIEW_NOT_FOUND',
8 INVALID_INPUT: 'INVALID_INPUT',
9 RATING_ERROR: 'RATING_ERROR',
10 SERVICE_ERROR: 'SERVICE_ERROR'
11};
12
13// 按 authorId 批量取审阅人信息
14function createReviewerLoader(reviewerDataSource) {
15 return new DataLoader(async authorIds => {
16 const reviewers = await Promise.all(
17 authorIds.map(id => reviewerDataSource.getReviewer(id))
18 );
19 // 过滤掉没有资料的
20 return reviewers.filter(r => r !== null);
21 });
22}
23
24function createReviewLoader(reviewDataSource) {
25 return new DataLoader(async ids => {
26 return Promise.all(ids.map(id => reviewDataSource.fetchReview(id)));
27 });
28}
29
30export const resolvers = {
31 Author: {
32 __resolveReference(author) {
33 return { id: author.id };
34 },
35
36 async reviews(author, _, { dataSources, loaders, correlationId }) {
37 // TODO 1: 取这位作者的全部评论。注意 schema 的可空性
38 return [];
39 },
40
41 async averageRating(author, _, { dataSources, correlationId }) {
42 // TODO 2: 用 RatingDataSource 算平均分。注意这个字段是可空的
43 return null;
44 }
45 },
46
47 Book: {
48 __resolveReference(book) {
49 // TODO 3: Book 用的是复合 key(isbn + edition)—— 想清楚这里该返回什么
50 return null;
51 },
52
53 async reviews(book, _, { dataSources, correlationId }) {
54 // TODO 4: 取这本书这一版的评论
55 return [];
56 }
57 },
58
59 Review: {
60 async reviewer(parent, _, { dataSources, loaders, correlationId }) {
61 // TODO 5: 用 DataLoader 取审阅人,防 N+1。可空
62 return null;
63 }
64 },
65
66 Query: {
67 async review(_, { id }, { dataSources, loaders, correlationId }) {
68 // TODO 6: 用 DataLoader;找不到抛结构化错误
69 return null;
70 },
71
72 async reviews(_, { authorId }, { dataSources, correlationId }) {
73 // TODO 7: 校验 authorId;注意可空性
74 return [];
75 }
76 },
77
78 Mutation: {
79 // 提供作参考 —— 但它是坏的
80 async createReview(_, { authorId, isbn, edition, rating, body }, { dataSources, correlationId }) {
81 try {
82 console.log(`[${correlationId}] Creating review for author: ${authorId}`);
83
84 if (!authorId || !isbn || !body) {
85 throw new GraphQLError('Invalid review input', {
86 extensions: { code: ErrorCodes.INVALID_INPUT, correlationId }
87 });
88 }
89
90 const review = await dataSources.reviewAPI.insertReview({
91 authorId, isbn, edition, rating, body
92 });
93
94 return review;
95 } catch (error) {
96 console.error(`[${correlationId}] Error creating review:`, error.message);
97 throw new GraphQLError('Failed to create review', {
98 extensions: { code: ErrorCodes.SERVICE_ERROR, correlationId }
99 });
100 }
101 }
102 }
103};
104
105export { createReviewerLoader, createReviewLoader };
这是 DrillLab 自出的模拟题,不是源项目内容。六处埋雷没有任何标注 —— 和真实考试一样,只有 README 里那句「可能存在集成问题」。DrillLab wrote this mock task; it does not come from the source project. None of the six planted bugs is marked. That matches the real exam, where the only warning is one README line saying integration problems may exist.
测试(你的判卷器)Tests — your own grader
JavaScript__tests__/resolvers.test.js(判卷器,14 个测试)__tests__/resolvers.test.js (the marker, 14 tests)已跑通Verified
1import { describe, it, expect, beforeEach } from '@jest/globals';
2import { resolvers, createReviewerLoader, createReviewLoader } from '../src/resolvers/reviewResolvers.js';
3import { ReviewDataSource, ReviewerDataSource, RatingDataSource } from '../src/dataSources/reviewDataSource.js';
4
5describe('Review Resolvers', () => {
6 let dataSources, loaders, context;
7
8 beforeEach(() => {
9 dataSources = {
10 reviewDataSource: new ReviewDataSource(),
11 reviewerDataSource: new ReviewerDataSource(),
12 ratingDataSource: new RatingDataSource()
13 };
14 loaders = {
15 reviewerLoader: createReviewerLoader(dataSources.reviewerDataSource),
16 reviewLoader: createReviewLoader(dataSources.reviewDataSource)
17 };
18 context = { dataSources, loaders, correlationId: 'test-cid' };
19 });
20
21 describe('Author.reviews', () => {
22 it('returns reviews for an author', async () => {
23 const reviews = await resolvers.Author.reviews({ id: 'a-7' }, {}, context);
24 expect(Array.isArray(reviews)).toBe(true);
25 expect(reviews.length).toBe(2);
26 expect(reviews[0]).toHaveProperty('authorId', 'a-7');
27 });
28
29 it('returns empty array for author with no reviews', async () => {
30 const reviews = await resolvers.Author.reviews({ id: 'a-999' }, {}, context);
31 expect(reviews).toEqual([]);
32 });
33 });
34
35 describe('Author.averageRating', () => {
36 it('returns a number for an author with reviews', async () => {
37 const avg = await resolvers.Author.averageRating({ id: 'a-7' }, {}, context);
38 expect(typeof avg).toBe('number');
39 });
40
41 it('returns null for an author with no reviews', async () => {
42 const avg = await resolvers.Author.averageRating({ id: 'a-999' }, {}, context);
43 expect(avg).toBeNull();
44 });
45 });
46
47 describe('Book entity', () => {
48 it('__resolveReference keeps both key fields', () => {
49 const ref = resolvers.Book.__resolveReference({
50 __typename: 'Book', isbn: '978-1', edition: 2
51 });
52 expect(ref).toEqual({ isbn: '978-1', edition: 2 });
53 });
54
55 it('Book.reviews filters by isbn AND edition', async () => {
56 const reviews = await resolvers.Book.reviews(
57 { isbn: '978-1', edition: 2 }, {}, context
58 );
59 expect(reviews.length).toBe(1);
60 expect(reviews[0].id).toBe('rev-2');
61 });
62 });
63
64 describe('Review.reviewer', () => {
65 it('returns reviewer info', async () => {
66 const r = await resolvers.Review.reviewer({ authorId: 'a-7' }, {}, context);
67 expect(r).toHaveProperty('displayName');
68 });
69
70 it('returns null when reviewer is unknown', async () => {
71 const r = await resolvers.Review.reviewer({ authorId: 'a-999' }, {}, context);
72 expect(r).toBeNull();
73 });
74 });
75
76 describe('Query.reviews', () => {
77 it('returns reviews for an author', async () => {
78 const reviews = await resolvers.Query.reviews({}, { authorId: 'a-9' }, context);
79 expect(reviews.length).toBe(1);
80 });
81
82 it('returns empty array for unknown author', async () => {
83 const reviews = await resolvers.Query.reviews({}, { authorId: 'a-999' }, context);
84 expect(reviews).toEqual([]);
85 });
86 });
87
88 describe('DataLoader contract', () => {
89 it('reviewerLoader keeps length and order aligned with keys', async () => {
90 // a-999 没有资料 -> 该位置必须是 null,不能被过滤掉
91 const results = await Promise.all(
92 ['a-7', 'a-999', 'a-9'].map(id => loaders.reviewerLoader.load(id))
93 );
94 expect(results.length).toBe(3);
95 expect(results[1]).toBeNull();
96 expect(results[2]).not.toBeNull();
97 });
98 });
99
100 describe('Mutation.createReview', () => {
101 it('creates a review with reviewer attached', async () => {
102 const review = await resolvers.Mutation.createReview({}, {
103 authorId: 'a-7', isbn: '978-3', edition: 1, rating: 5, body: '新评论'
104 }, context);
105 expect(review.id).toBeDefined();
106 expect(review.reviewer).toBeDefined();
107 expect(review.reviewer.displayName).toBeDefined();
108 });
109 });
110
111 describe('Error handling', () => {
112 it('returns INVALID_INPUT for empty body', async () => {
113 try {
114 await resolvers.Mutation.createReview({}, {
115 authorId: 'a-7', isbn: '978-3', edition: 1, rating: 5, body: ''
116 }, context);
117 throw new Error('Should have thrown');
118 } catch (error) {
119 expect(error.extensions.code).toBe('INVALID_INPUT');
120 }
121 });
122
123 it('returns REVIEW_NOT_FOUND for unknown id', async () => {
124 try {
125 await resolvers.Query.review({}, { id: 'rev-999' }, context);
126 throw new Error('Should have thrown');
127 } catch (error) {
128 expect(error.extensions.code).toBe('REVIEW_NOT_FOUND');
129 }
130 });
131 });
132});
比真实项目多了三条针对性测试:averageRating 的 toBeNull、Book.__resolveReference 的两个 key 字段、以及 reviewerLoader 的长度/顺序契约。这三条正是真实项目测不到但你该会的地方。Three tests here go past what the real project checks: toBeNull for averageRating, the two key fields of Book.__resolveReference, and the length and order contract of reviewerLoader. Those three cover exactly what the real project leaves untested but you are still expected to know.
做完之后按 rubric 给自己打个分,记在这里。When you are done, score yourself against the rubric and record it here.

讲解里会直接说出每一处陷阱在哪。请确认你已经在本机把这套题做完、跑过测试、按 rubric 自评过,再打开。The walkthrough names every trap outright. Only open it once you have finished the paper locally, run the tests, and scored yourself against the rubric.