From ccca38abfb8d84994796d215d560fa9c0261b492 Mon Sep 17 00:00:00 2001 From: Kevin Mamaqi Kapllani Date: Sat, 18 Jul 2026 21:33:14 +0200 Subject: [PATCH] content: quality pass on DDD pack, add testing-fundamentals, sync references - ddd-backend-foundations: fixed dead source anchors, an invalid TS snippet, and a missing test-runner setup step; documented the check-then-act booking race and transaction boundaries; added worked examples to modules 03 and 06 so the running example composes across 01-06; sharper Evans/Vernon and Cockburn terminology; pinned dependency versions - testing-fundamentals: new pack (7 modules + notes); every snippet and price total verified by running the assembled learner project (tsc --noEmit clean, 17/17 vitest) - references: learning-protocol gains the guide-less self-review branch; progress-database now documents the evidence table and real status/result enums - README: list both catalogue entries Co-Authored-By: Claude Fable 5 --- README.md | 9 +- .../modules/00-start-a-path.md | 71 ++++- .../modules/01-model-the-domain.md | 61 ++++- .../modules/02-draw-the-hexagon.md | 82 ++++++ .../modules/03-make-an-api-useful.md | 59 ++++- .../modules/04-persist-through-a-port.md | 80 +++++- .../modules/05-prove-behaviour.md | 60 ++++- .../modules/06-handle-failure-deliberately.md | 37 +++ .../modules/07-observe-and-ship.md | 59 ++++- courses/testing-fundamentals/course.md | 50 ++++ .../modules/00-start-with-a-failure.md | 245 ++++++++++++++++++ .../modules/01-name-the-behaviour.md | 105 ++++++++ .../modules/02-choose-a-boundary.md | 181 +++++++++++++ .../modules/03-use-doubles-honestly.md | 139 ++++++++++ .../modules/04-drive-the-edges.md | 113 ++++++++ .../modules/05-refactor-under-green.md | 98 +++++++ .../modules/06-read-confidence-honestly.md | 88 +++++++ .../notes/testing-principles.md | 83 ++++++ references/learning-protocol.md | 3 + references/progress-database.md | 17 +- 20 files changed, 1607 insertions(+), 33 deletions(-) create mode 100644 courses/testing-fundamentals/course.md create mode 100644 courses/testing-fundamentals/modules/00-start-with-a-failure.md create mode 100644 courses/testing-fundamentals/modules/01-name-the-behaviour.md create mode 100644 courses/testing-fundamentals/modules/02-choose-a-boundary.md create mode 100644 courses/testing-fundamentals/modules/03-use-doubles-honestly.md create mode 100644 courses/testing-fundamentals/modules/04-drive-the-edges.md create mode 100644 courses/testing-fundamentals/modules/05-refactor-under-green.md create mode 100644 courses/testing-fundamentals/modules/06-read-confidence-honestly.md create mode 100644 courses/testing-fundamentals/notes/testing-principles.md diff --git a/README.md b/README.md index 0b7bdff..05c3114 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,20 @@ Public, Markdown-only course packs for [LearnDeck](https://github.com/learn-deck ## What is here -The first catalogue entry is **DDD and Hexagonal Architecture with Node.js + TypeScript**. It is a project-based, six-to-eight-hour course for developers moving from feature delivery toward confident system design. +The catalogue currently holds two packs: + +- **DDD and Hexagonal Architecture with Node.js + TypeScript** (`ddd-backend-foundations`) — a project-based, six-to-eight-hour course for developers moving from feature delivery toward confident system design. +- **Testing Fundamentals** (`testing-fundamentals`) — a five-to-seven-hour course on writing tests that earn their confidence: red-first flow, boundaries, honest test doubles, edge tables, and refactoring under green. ```text courses/ ddd-backend-foundations/ course.md modules/ + testing-fundamentals/ + course.md + modules/ + notes/ references/ *.md ``` diff --git a/courses/ddd-backend-foundations/modules/00-start-a-path.md b/courses/ddd-backend-foundations/modules/00-start-a-path.md index cdf4f18..afddf8f 100644 --- a/courses/ddd-backend-foundations/modules/00-start-a-path.md +++ b/courses/ddd-backend-foundations/modules/00-start-a-path.md @@ -2,7 +2,7 @@ id: start title: Set up your backend goal: Confirm one Node.js + TypeScript workspace and make a tiny status route visible. -action: Create the agreed folder structure and one health/status route in your project folder. Run npm run dev yourself when you are ready. +action: Create src/domain/, src/application/, src/ports/, and src/adapters/ plus one health/status route in your project folder. Run npm run dev yourself when you are ready. sources: - ./00-start-a-path.md - ../../../references/language-paths.md @@ -26,6 +26,11 @@ questions: # 00 · Set up your backend +## Outcome + +I can run one Node.js + TypeScript backend workspace with a visible status +route and explain why it lives apart from LearnDeck's progress data. + You only need one project for this course: a small Node.js + TypeScript backend. LearnDeck remembers your answers locally; your project folder holds the code you will build. Keeping them separate lets you retry, rename, or delete the project @@ -42,13 +47,50 @@ without touching the course itself. 2. Use the Node.js + TypeScript checks in [`language-paths.md`](../../../references/language-paths.md). They only tell you what is present; they never install or run anything for you. -3. In your project, create the first four areas: `domain`, `application`, - `ports`, and `adapters`. Empty folders are enough today. -4. Add a tiny status endpoint such as `GET /health` that returns a simple, - honest response. +3. In your project, create these four areas: `src/domain/`, + `src/application/`, `src/ports/`, and `src/adapters/`. Empty folders are + enough today. +4. If the folder is empty, copy this minimal setup. It uses `tsx` to run + TypeScript directly; run `npm install` yourself. LearnDeck never installs + packages or starts your server. + +`package.json` + +```json +{ + "name": "ddd-backend", + "private": true, + "type": "module", + "scripts": { "dev": "tsx --watch src/server.ts" }, + "devDependencies": { "tsx": "^4.19.0", "typescript": "^5.6.0" } +} +``` + +`src/server.ts` + +```ts +import { createServer } from "node:http"; + +const server = createServer((request, response) => { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + response.writeHead(404); + response.end(); +}); + +server.listen(3000); +``` + +Run `npm install`, then `npm run dev`, and check `GET /health`. The expected +response is verbatim: `200 {"status":"ok"}`. + 5. When the project is ready, run `npm run dev` yourself and look at the - endpoint. Tell your AI guide what you ran and what you observed so it can - record that evidence through LearnDeck. + endpoint. If a guide is connected, tell it what you ran and observed so it + can record that evidence; otherwise record it with the evidence form or in + `NOTES.md` in your workspace. > [!TIP] > Do not design the perfect server today. A plain status response is valuable @@ -59,7 +101,7 @@ type: checklist id: start-ready label: Before you continue items: - - Node.js and npm are available on my Mac. + - Node.js 22 or newer and npm are available on my machine. - My backend project folder is separate from LearnDeck. - I know the status route I will make visible. ``` @@ -74,3 +116,16 @@ or collect your project outside the folder you confirm. When you are ready, answer the question below in your own words. A short, concrete answer is better than architecture vocabulary. + +## Definition of done + +Before answering, check that: + +- The project contains `src/domain/`, `src/application/`, `src/ports/`, and `src/adapters/`. +- A learner-run development command starts the backend, such as `npm run dev`. +- A visible `GET /health` route returns `200 {"status":"ok"}`. +- You can name the project folder, command, route, and observed response. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/01-model-the-domain.md b/courses/ddd-backend-foundations/modules/01-model-the-domain.md index dca557a..5030ace 100644 --- a/courses/ddd-backend-foundations/modules/01-model-the-domain.md +++ b/courses/ddd-backend-foundations/modules/01-model-the-domain.md @@ -44,15 +44,55 @@ rule? Give one example of each for a small task or booking service. > your domain must protect—even when tomorrow's client is not HTTP. 1. Choose a deliberately small problem: task tracking, booking, inventory - reservation, or another bounded workflow. Record the choice in progress. + reservation, or another bounded workflow. Record the choice in your answer + or in `NOTES.md` in your workspace. 2. Write a short domain note in your workspace: nouns, verbs, state changes, and what must always be true. 3. Define one use case in application language: input, successful outcome, and expected domain failures. 4. Create a domain type or aggregate that protects one invariant without - importing an HTTP framework, database client, or logger. -5. Ask the agent to record the domain-note and code paths. Then explain the - invariant in your own words. + importing an HTTP framework, database client, or logger. In DDD terms, an + aggregate is the consistency boundary: everything the invariant needs to + stay true is checked inside it, in one operation. A small value object—an + immutable type compared by its values, such as a `TimeRange` that refuses + `endsAt <= startsAt`—is often the cheapest first guard. +5. If a guide is connected, ask it to record the domain-note and code paths; + otherwise record those paths in the evidence form or in `NOTES.md` in your + workspace. Then explain the invariant in your own words. + +## Worked example: protect the overlap rule + +Here is one small domain decision fully worked for the booking service: + +```ts +type Booking = { + roomId: string; + startsAt: number; + endsAt: number; +}; + +export function overlaps(existing: Booking, candidate: Booking): boolean { + return existing.roomId === candidate.roomId + && existing.startsAt < candidate.endsAt + && candidate.startsAt < existing.endsAt; +} +``` + +Times here are epoch milliseconds and each booking is a half-open interval +`[startsAt, endsAt)`, so a booking that starts exactly when another ends does +not overlap. Writing that convention down in your domain note is itself a +ubiquitous-language decision: everyone, including your tests, now means the +same thing by "overlap". + +Your next analogous decision: decide what your domain operation should return +when `overlaps` is true. Write the result type and one expectation for a second +booking attempt; keep HTTP and database concerns out of it. + +Why this decision? The predicate compares the same room and intersecting time +intervals, so it protects the booking invariant independently of how a request +or row is represented. Its small boundary lets the application turn `true` +into a domain rejection without asking the domain to know about status codes or +SQL. Use [Vaughn Vernon's aggregate guidance](../../../references/source-index.md#ddd) and [the hexagonal architecture reference](../../../references/source-index.md#hexagonal) @@ -67,3 +107,16 @@ reject first, and explain why they are not the same responsibility. Given a new route, decide whether its rule belongs in the adapter, application use case, or domain model—and say why. + +## Definition of done + +Before answering, check that: + +- A domain note names the booking or other workflow's nouns, verbs, state changes, and invariant. +- One domain type or aggregate protects that invariant without HTTP, database, or logger imports. +- One use case names its input, successful outcome, and expected domain failure. +- You can distinguish the domain invariant from one transport validation rule. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md b/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md index 47ba8c6..efd7d51 100644 --- a/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md +++ b/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md @@ -53,6 +53,75 @@ direction has been reversed? What makes that costly to change or test? 5. Confirm that the inner code has no imports of the web framework, SQL driver, or environment package. +## Worked example: let the inside name the port + +Here is one small dependency decision fully worked for `CreateBooking`: + +```ts +type Booking = { roomId: string; startsAt: number; endsAt: number }; + +export interface BookingRepository { + findOverlapping(roomId: string, startsAt: number, endsAt: number): Promise; +} + +export class InMemoryBookingRepository implements BookingRepository { + constructor(private readonly bookings: Booking[]) {} + + async findOverlapping(roomId: string, startsAt: number, endsAt: number) { + return this.bookings.find((booking) => + booking.roomId === roomId && booking.startsAt < endsAt && startsAt < booking.endsAt, + ) ?? null; + } +} +``` + +Your next analogous decision: choose the next capability this use case needs, +such as saving a new booking. Write its port signature and the in-memory method, +keeping the operation named after booking behaviour rather than SQL. + +Why this decision? The use case asks whether the room and time are available, +so the port exposes exactly that capability. The in-memory adapter implements +the same inside-facing contract; a database adapter can replace it at the edge +without changing the booking decision. + +## What this is NOT + +The wrong direction makes the domain depend on the HTTP framework: + +```ts +// domain/booking.ts — wrong +import type { Request } from "express"; + +export function canBook(request: Request): boolean { + return request.body.roomId !== undefined; +} +``` + +The correct direction translates HTTP at the edge and keeps the domain plain: + +```ts +// domain/booking.ts — right +type Booking = { roomId: string; startsAt: number; endsAt: number }; +type BookingCommand = { roomId: string; startsAt: number; endsAt: number }; + +export function canBook(command: BookingCommand, existing: readonly Booking[]) { + return existing.every((booking) => + booking.roomId !== command.roomId || booking.endsAt <= command.startsAt || command.endsAt <= booking.startsAt, + ); +} +``` + +The wrong version lets Express's request shape reach a domain decision, so the +rule depends on transport. The right version accepts domain data; an outer +adapter translates the request and the inner code remains callable without +Express. + +Cockburn's names for the two sides are worth keeping: a **driving** adapter +(an HTTP handler, a test) calls the application through a port; a **driven** +adapter (a repository, a clock) is called by the application through a port it +defines. In both cases the port belongs to the inside and the adapter to the +outside—only who initiates the call changes. + Use the original [Ports and Adapters article](../../../references/source-index.md#hexagonal) for the direction, not as a folder-name ritual. @@ -66,3 +135,16 @@ database's API? Classify each as a port or adapter: `TaskRepository`, PostgreSQL query client, system clock, and Fastify handler. + +## Definition of done + +Before answering, check that: + +- One use case calls a port owned by the inner application/domain boundary. +- One in-memory adapter implements that port and is wired at the outer composition point. +- The inner code has no web framework, SQL driver, or environment-package imports. +- You can name the caller, port owner, adapter, and dependency direction for one booking decision. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md b/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md index c0e43ec..7630f2d 100644 --- a/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md +++ b/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md @@ -42,14 +42,54 @@ formatting difficult to test and change independently? > The domain rejects a genuine double booking. These are different failures, > so they deserve different messages and tests. -1. Choose one command endpoint for the use case from step 01. +1. Choose one command endpoint for the use case you named in module 01 and + wired in module 02. 2. In the HTTP adapter, parse and validate only transport-shaped input. 3. Translate the request to the application input, call the use case, and map known domain failures to stable responses. 4. Add a status/health route. If your project includes a tiny frontend, have it call or display that route; it is a visibility aid, not the course product. -5. Ask the agent to check the documented Node.js development command. Run - `npm run dev` yourself and record the route, command, and observed result. +5. If a guide is connected, ask it to check the documented Node.js development + command. Run `npm run dev` yourself and record the route, command, and + observed result; otherwise keep that record in the evidence form or in + `NOTES.md` in your workspace. + +## Worked example: two different failures, two deliberate responses + +Here is one small response decision fully worked for `POST /bookings`. The +adapter distinguishes malformed transport input from a rejected domain +decision: + +```ts +// adapters/http/bookings.ts +const result = await createBooking(command, repository); + +if (result.kind === "rejected") { + response.writeHead(409, { "content-type": "application/problem+json" }); + response.end(JSON.stringify({ + type: "urn:ddd-backend:problem:room-already-booked", + title: "Room already booked", + status: 409, + detail: `Room ${command.roomId} is already booked for that time.`, + })); + return; +} + +response.writeHead(201, { "content-type": "application/json" }); +response.end(JSON.stringify(result.booking)); +``` + +Why this decision? A body that is not valid JSON never reaches `createBooking`; +the adapter answers `400` on its own, because malformed transport input is the +transport's problem. A well-formed request that loses the booking rule gets a +stable `409 Conflict` problem response (RFC 9457's `application/problem+json` +shape), so a client can tell "fix my request" apart from "the room is taken" +without parsing prose. The use case returned `{ kind: "rejected", reason: +"room-already-booked" }` and never saw a status code. + +Your next analogous decision: choose the success representation. Decide what +`201 Created` should return for your endpoint—and which fields of the domain +object the response deliberately exposes. Use the HTTP references in [the source index](../../../references/source-index.md#http) to reason about resource semantics and problem responses. @@ -64,3 +104,16 @@ stop? Given a new error, decide whether it is a transport error, application decision, or domain failure before choosing its HTTP response. + +## Definition of done + +Before answering, check that: + +- One command endpoint and the status route are reachable with the documented development command. +- Malformed transport input stops at the HTTP boundary with a deliberate response. +- A double booking or other invariant failure produces a distinct stable response. +- You recorded the command, route, and observed results in your workspace or answer. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md b/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md index 6a53350..4c25470 100644 --- a/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md +++ b/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md @@ -48,10 +48,75 @@ When might they be the same value, and why should the model not depend on that? its location and configuration boundary. 3. Map persisted data at the adapter edge. Reconstruct the domain object before handing it to inner code. -4. Decide and document the transaction boundary for one use case. +4. Decide and document the transaction boundary for one use case. A useful + rule of thumb from Vernon's aggregate guidance: one transaction changes one + aggregate; anything wider deserves a written justification. 5. Run the relevant tests and your status route. Record the evidence and any persistence-specific failures. +## Worked example: map storage at the edge + +Here is one small persistence decision fully worked for a booking row: + +```ts +type Booking = { roomId: string; startsAt: number; endsAt: number }; +type BookingRow = { room_id: string; starts_at: number; ends_at: number }; + +function toDomain(row: BookingRow): Booking { + return { + roomId: row.room_id, + startsAt: row.starts_at, + endsAt: row.ends_at, + }; +} +``` + +Your next analogous decision: write the `toRow` function for saving a booking +and decide which identity and time fields must survive a round trip. Keep the +row type and database naming outside the port signature. + +Why this decision? `toDomain` reconstructs the object the booking rule +understands before it reaches inner code. SQL names and storage types stay in the +adapter, so replacing the store does not force a change to the invariant. + +> [!TIP] +> Be honest about concurrency: "find overlapping, then save" is two steps, so +> two simultaneous requests can both pass the check and double-book the room. +> This is exactly why the transaction boundary you document in step 4 matters. +> Run the check and the insert inside one transaction, or back the invariant +> with a database uniqueness/exclusion constraint at the adapter edge. The +> domain still owns the rule; the transaction is how persistence keeps it true +> under concurrent writes. + +## What this is NOT + +This port leaks infrastructure details into the inside: + +```ts +// wrong +import type { Pool } from "pg"; +type BookingRow = { room_id: string; starts_at: number; ends_at: number }; + +interface BookingRepository { + findOne(db: Pool, tableName: string, sql: string): Promise; +} +``` + +This port exposes the capability the booking use case actually needs: + +```ts +// right +type Booking = { roomId: string; startsAt: number; endsAt: number }; + +interface BookingRepository { + findOverlapping(roomId: string, startsAt: number, endsAt: number): Promise; +} +``` + +The wrong version makes callers know a driver, table name, SQL, and row shape; +it is an infrastructure API disguised as a port. The right version speaks in +booking terms, while the adapter owns SQL and maps rows at the edge. + Use [Fowler's Repository pattern](../../../references/source-index.md#persistence) as a vocabulary reference, while keeping the port shaped by your use case. @@ -65,3 +130,16 @@ invariant? If an adapter returns a duplicate-key error, which layer should translate it into the application's language before it reaches HTTP? + +## Definition of done + +Before answering, check that: + +- The existing repository port still expresses only the operations this use case needs. +- A persistence adapter maps storage rows to domain objects at its edge. +- One transaction boundary is written down for a booking use case. +- Tests and the status route were run, with persistence-specific results recorded honestly. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/05-prove-behaviour.md b/courses/ddd-backend-foundations/modules/05-prove-behaviour.md index 424552e..fc70d26 100644 --- a/courses/ddd-backend-foundations/modules/05-prove-behaviour.md +++ b/courses/ddd-backend-foundations/modules/05-prove-behaviour.md @@ -42,13 +42,50 @@ might obscure? > rejected.” It does not need an HTTP server or database to make that business > promise easy to understand. -1. Write one domain-level test for the invariant from step 01. -2. Write one use-case test using the in-memory adapter from step 02. -3. Add one HTTP boundary test for a deliberate input or error mapping. -4. Run the project test command, `npm test`, yourself. -5. Record test paths, command output summary, and what each test is allowed to +1. Pick one test runner and wire it to `npm test` in your `package.json`. + Vitest is a fine default for a TypeScript project; Node's built-in + `node:test` also works. Install it yourself—LearnDeck never installs + packages. The snippets below use Vitest's API; translate freely. +2. Write one domain-level test for the invariant from module 01. +3. Write one use-case test using the in-memory adapter from module 02. +4. Add one HTTP boundary test for a deliberate input or error mapping. +5. Run the project test command, `npm test`, yourself. +6. Record test paths, command output summary, and what each test is allowed to prove. Do not label a passing test as proof of every production concern. +## What this is NOT + +The wrong test asserts private implementation details and call order: + +```ts +// wrong +import { expect, it, vi } from "vitest"; + +it("checks before saving", async () => { + const find = vi.spyOn(repo, "findOverlapping"); + const save = vi.spyOn(repo, "save"); + await createBooking(input, repo); + expect(find.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]); +}); +``` + +The right test asserts the booking behaviour a learner or caller can observe: + +```ts +// right +import { expect, it } from "vitest"; + +it("rejects an overlapping booking", async () => { + const result = await createBooking(input, repoWithExistingBooking); + expect(result).toEqual({ kind: "rejected", reason: "room-already-booked" }); +}); +``` + +The wrong version can fail after a harmless refactor even when the booking rule +still works, because it prescribes internals and order. The right version stays +valuable when the implementation changes because it checks the observable +rejection of a second booking. + Use [Google's testing guidance](../../../references/source-index.md#testing) for test-value trade-offs, not as a mandated testing pyramid. @@ -61,3 +98,16 @@ does not prove. Why is a test double acceptable at the port boundary here? Given a slow flaky integration test, decide whether it belongs in a fast inner loop, a boundary suite, or a separate environment check. + +## Definition of done + +Before answering, check that: + +- A fast domain test rejects a second booking for the same room and time. +- A use-case test runs through the in-memory adapter. +- An HTTP boundary test covers one deliberate input or error mapping. +- `npm test` was run and each test's evidence and limitation are recorded. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md b/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md index 94ca88a..10a59ac 100644 --- a/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md +++ b/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md @@ -51,6 +51,30 @@ reserve inventory, or send a message? recorded. Do not add background retries just to satisfy the exercise. 5. Add one test for the duplicate or retry decision and record the result. +## Worked example: name the expected failure, let the unexpected one surface + +Here is one small failure decision fully worked for `CreateBooking`: + +```ts +type Booking = { roomId: string; startsAt: number; endsAt: number }; + +export type CreateBookingResult = + | { kind: "created"; booking: Booking } + | { kind: "rejected"; reason: "room-already-booked" }; +``` + +Why this decision? A double booking is not an accident—it is the domain doing +its job—so it belongs in the result type where every caller must handle it, +and where module 03's adapter turned it into a stable `409`. A transient +adapter failure (the database is briefly unreachable) is deliberately *not* a +result variant: the adapter throws, the failure surfaces, and whether a retry +is safe depends on what side effects already happened—which is why you choose +an idempotency boundary next, instead of wrapping everything in a retry loop. + +Your next analogous decision: for the duplicate case, decide what a retried +`POST /bookings` carrying the same idempotency key should return—the original +outcome, replayed, not a second booking. + Use [AWS's retry and backoff guidance](../../../references/source-index.md#reliability) and [the Transactional Outbox pattern](../../../references/source-index.md#reliability) to reason about failure modes, not to claim exactly-once delivery. @@ -64,3 +88,16 @@ duplicate. Is a retry safe? State the precondition that makes your answer true. Explain why an idempotency key changes the effect of a retry but does not make all work automatically safe to repeat. + +## Definition of done + +Before answering, check that: + +- Three failures are classified as domain rejection, transient adapter failure, and duplicate delivery. +- One expected domain failure is explicit in the application result. +- One side effect has a named idempotency or deduplication boundary and retry rule. +- A duplicate or retry decision has a test and recorded result. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/07-observe-and-ship.md b/courses/ddd-backend-foundations/modules/07-observe-and-ship.md index e52a816..1d02825 100644 --- a/courses/ddd-backend-foundations/modules/07-observe-and-ship.md +++ b/courses/ddd-backend-foundations/modules/07-observe-and-ship.md @@ -5,8 +5,7 @@ goal: Make the small backend diagnosable and explainable to another developer. action: Add minimal structured logging, keep a health route separate from correctness, and write a short runbook in the workspace. sources: - ./07-observe-and-ship.md - - ../../../references/source-index.md#observability - - ../../../references/source-index.md#operations + - ../../../references/source-index.md#observability-and-operations questions: - id: operate-diagnostic kind: diagnostic @@ -46,15 +45,48 @@ unsafe? 1. Add structured, minimally useful logs around one use case boundary. Include a request or operation identifier, outcome, and duration where meaningful; exclude secrets and unnecessary personal data. -2. Keep the health/status route separate from business correctness. + + A structured line can be this small and still be useful: + + ```ts + // adapters/http/bookings.ts — after the use case returns + console.log(JSON.stringify({ + event: "booking.create", + operationId, + outcome: result.kind, // "created" | "rejected" + durationMs: Date.now() - startedAt, + })); + ``` + + Note what is absent: no request body, no guest names, no credentials. The + outcome and duration answer "did it work, and was it slow?"; the operation + ID lets you find the one request that failed. + +2. Keep the health/status route separate from business correctness. `GET + /health` proves the process is up and responding; it does not prove + bookings are correct, the database is reachable, or data is intact. 3. Write a short runbook in your workspace: dependencies, development command, test command, configuration required, and one expected failure signal. 4. Run the server and test suite yourself; record the commands and outcomes. -5. Ask the agent for a release-readiness review limited to this course's - evidence: boundaries, tests, failure decision, and runbook. +5. If a guide is connected, ask it for an evidence review limited to this + course's boundaries, tests, failure decision, and runbook; otherwise keep + that review in the evidence form or in `NOTES.md` in your workspace. + +## Final evidence + +Finish this course with four concrete items: -Use [OpenTelemetry's observability overview](../../../references/source-index.md#observability) -and [The Twelve-Factor App](../../../references/source-index.md#operations) as +- A small, honest repository containing only the code and documentation you can explain. +- A runnable status route, with the command and observed response recorded. +- A passing test suite, with its command and scope stated honestly. +- A short architecture explanation in your own words covering the domain, ports, adapters, and one trade-off. + +This is **not production readiness**. It is a bounded learning project with +evidence that another developer can inspect and run; it does not prove scale, +security, reliability, or operational readiness. + +Use [OpenTelemetry's observability overview](../../../references/source-index.md#observability-and-operations) +and [The Twelve-Factor App](../../../references/source-index.md#observability-and-operations) as practical references, not as a claim that one small course service is operated at scale. @@ -67,3 +99,16 @@ not log, and one condition your health route cannot prove. From a cold checkout of your workspace, what minimum instructions let another developer run tests and the development server safely? + +## Definition of done + +Before answering, check that: + +- Structured logs include a useful operation signal while omitting secrets and unnecessary personal data. +- A status route runs separately from a business-correctness check. +- A workspace runbook names dependencies, development and test commands, configuration, and one failure signal. +- The final evidence list contains a small repository, runnable status route, passing suite, and your own architecture explanation. + +After you submit your answer, choose **Mark as self-reviewed and continue** if +you are working without a connected guide. Guide evaluation is optional, not +required; if a guide is connected, you may request feedback instead. diff --git a/courses/testing-fundamentals/course.md b/courses/testing-fundamentals/course.md new file mode 100644 index 0000000..4856c1d --- /dev/null +++ b/courses/testing-fundamentals/course.md @@ -0,0 +1,50 @@ +--- +schemaVersion: 1 +id: testing-fundamentals +title: "Testing Fundamentals" +description: "Build a small parcel-pricing service and learn to make its tests focused, honest, and useful." +category: Engineering practice +tags: + - Testing + - TypeScript + - Node.js + - Vitest +overview: + duration: 5–7 hours + sessionLength: 45–60 minutes per module + level: Early-career backend developers + outcomes: + - Write focused tests that state one parcel-pricing behaviour clearly. + - Choose a useful unit or HTTP boundary without mocking away the behaviour under test. + - Exercise boundaries, options, and failure paths with readable test cases. + - Refactor a small service under a green suite and explain what the suite does not prove. + prerequisites: + - Node.js 20 or newer, npm, and a TypeScript-capable editor. + - Basic TypeScript functions, objects, and arrays. + - Basic familiarity with HTTP requests and JSON. + - A workspace you control for one small backend project. +paths: + - id: node-typescript-vitest + label: Node.js + TypeScript with Vitest + serverCommand: npm run dev + testCommand: npm test + workspaceHint: ../parcel-pricing +--- + +# Testing Fundamentals + +You will build a small parcel-pricing service. It prices a shipment from its +weight, destination zone, and delivery options, then exposes that decision +through one HTTP edge. The project is intentionally small: the point is to +learn what a test proves, where to put it, and how to keep confidence honest. + +LearnDeck holds your learning record. Your confirmed workspace holds the +service, tests, commands, and final evidence. You will run commands yourself. +If a guide is connected, it can help you interpret what you observe; guide +evaluation is optional. Without a connected guide, answer the visible question, +review your own evidence, and use the app's **Mark as self-reviewed and +continue** action. + +The course follows one parcel through seven short sessions: first a failing +test, then focused behaviour tests, boundary choices, honest test doubles, +deliberate edge cases, a refactor under green, and a final evidence review. diff --git a/courses/testing-fundamentals/modules/00-start-with-a-failure.md b/courses/testing-fundamentals/modules/00-start-with-a-failure.md new file mode 100644 index 0000000..727b19f --- /dev/null +++ b/courses/testing-fundamentals/modules/00-start-with-a-failure.md @@ -0,0 +1,245 @@ +--- +id: start-with-a-failure +title: Start with a failing test +goal: Create a small Vitest workspace and make one parcel-pricing behaviour fail before it passes. +action: Create the parcel-pricing project at the documented paths, write one failing pricing test, implement the smallest behaviour that makes it pass, and record both test outputs. +sources: + - ./00-start-with-a-failure.md + - ../notes/testing-principles.md +questions: + - id: start-failure-diagnostic + kind: diagnostic + prompt: What would a failing test tell you here that a green test written after the implementation might not? + reference: ./00-start-with-a-failure.md + rubric: + - Distinguishes a test that demonstrates a missing behaviour from a test that only confirms an implementation already written. + - Names an observable part of the failure, such as the expected parcel price or the failing test name. + - id: start-failure-exit + kind: exit + prompt: Describe the parcel input you tested, the expected price, the failure you first saw, and the evidence that the corrected test now passes. + reference: ../notes/testing-principles.md + rubric: + - Names concrete weight, zone, options, and expected total from the shared scenario. + - Distinguishes the first failing run from the later passing run and names visible test output as evidence. +--- + +# 00 · Start with a failing test + +## Outcome + +You can point to a real test that failed for a useful reason and then passed +after the smallest implementation change. + +## The parcel you are pricing + +> [!SCENARIO] +> A 2 kg parcel going to zone B with no options costs €10: €8 for the zone and +> €2 for the weight band. This is a small enough decision to test before you +> design a larger service. + +Choose a separate project folder for the service. The project belongs to you; +LearnDeck does not create it, install its dependencies, or run its commands. +Use a Node.js + TypeScript project with Vitest. The minimal bootstrap below +creates the exact files and learner-run commands needed by this course. It is +deliberately one source file; later modules may separate the HTTP adapter. + +## Copyable empty-folder setup + +From the parent directory of your project, choose the folder name +`parcel-pricing`, then run: + +```sh +mkdir -p parcel-pricing/src parcel-pricing/test +cd parcel-pricing +``` + +Create `package.json` with this content: + +```json +{ + "name": "parcel-pricing", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/parcel-pricing.ts", + "test": "vitest run --reporter=verbose" + } +} +``` + +Install the learner-run tools yourself: + +```sh +npm install --save-dev vitest tsx typescript @types/node @vitest/coverage-v8 +``` + +Create `tsconfig.json` so the editor and TypeScript agree on modern modules and +Node types; without it, `import.meta` and the `node:` imports show false errors: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +} +``` + +Create `test/parcel-pricing.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { priceParcel } from "../src/parcel-pricing"; + +describe("parcel pricing", () => { + it("prices a 2 kg zone B parcel at €10", () => { + const result = priceParcel({ weightKg: 2, zone: "B", options: [] }); + + expect(result.total).toBe(10); + }); +}); +``` + +Create `src/parcel-pricing.ts` with this temporary implementation first: + +```ts +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; + +export type Parcel = { + weightKg: number; + zone: "A" | "B" | "C"; + options: string[]; +}; + +export type ParcelPrice = { total: number }; + +export function priceParcel(_parcel: Parcel): ParcelPrice { + throw new Error("priceParcel is not implemented"); +} + +function startServer() { + createServer((request, response) => { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not found" })); + }).listen(3000, "127.0.0.1", () => { + console.log("Parcel-pricing server listening at http://127.0.0.1:3000"); + }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) startServer(); +``` + +Run the first test before implementing the behaviour: + +```sh +npm test +``` + +The exact useful output is one named failure and this summary: + +```text +❯ test/parcel-pricing.test.ts > parcel pricing > prices a 2 kg zone B parcel at €10 + → Error: priceParcel is not implemented +Test Files 1 failed (1) +Tests 1 failed (1) +``` + +Now replace only `priceParcel` in `src/parcel-pricing.ts` with: + +```ts +export function priceParcel(parcel: Parcel): ParcelPrice { + const baseRates: Record = { A: 5, B: 8, C: 12 }; + const weightSurcharge = parcel.weightKg > 1 ? 2 : 0; + + return { total: baseRates[parcel.zone] + weightSurcharge }; +} +``` + +> [!TIP] +> Strictly, the smallest change that makes this one test pass is +> `return { total: 10 };`. Hard-coding the answer and letting the next failing +> test force real logic is a legitimate TDD move. We jump straight to the small +> zone table because module 01 immediately adds cases that would force it, but +> notice what the honest claim is either way: one green test proves only the +> 2 kg zone B example, not the whole pricing rule. + +Run `npm test` again. The exact useful output is now: + +```text +✓ test/parcel-pricing.test.ts > parcel pricing > prices a 2 kg zone B parcel at €10 +Test Files 1 passed (1) +Tests 1 passed (1) +``` + +Vitest may add its version, timing, colours, and stack-location lines around +these stable lines. The required evidence is the named failure followed by the +named pass; do not replace either assertion with a weaker one. + +The front-matter commands are now executable: `npm test` runs the test above, +and `npm run dev` starts the status server from `src/parcel-pricing.ts`. Verify +the second command yourself with: + +```sh +npm run dev +curl http://127.0.0.1:3000/health +``` + +The terminal must print +`Parcel-pricing server listening at http://127.0.0.1:3000`, and `curl` must +return exactly `{"status":"ok"}`. Stop the server after checking it. + +```learndeck +type: checklist +id: start-failure-ready +label: Before you build +items: + - Node.js 20 or newer and npm are available on my machine. + - The parcel-pricing folder is separate from LearnDeck. + - I saw the named test fail before I implemented the behaviour. +``` + +## Build + +1. Create the project at `package.json`, `tsconfig.json`, + `src/parcel-pricing.ts`, and `test/parcel-pricing.test.ts`. Keep the + production code and tests easy to find; do not create an architecture + catalogue. +2. Write a test for the 2 kg, zone B, no-options example. Write the expected + €10 before implementing the pricing behaviour. +3. Run `npm test` and keep the visible failure: the test name and the mismatch + are useful evidence. +4. Implement only enough pricing logic to make that test pass. Run `npm test` + again and record the green output. +5. Verify that `npm run dev` starts the server and that `GET /health` returns + `200 {"status":"ok"}`. The HTTP pricing edge comes in module 02; do not + build it now. + +The output for this module is not “I have tests”. It is a before-and-after +record at `test/parcel-pricing.test.ts`: one named behaviour, one meaningful +failure, one meaningful pass, and the health check response above. If a guide +is connected, you may tell it the command and output so it can optionally +review the evidence. If no guide is connected, write the same record in your +own notes, answer the question, and select **Mark as self-reviewed and +continue** in the app. + +## Definition of done + +- A separate parcel-pricing workspace contains `package.json`, + `tsconfig.json`, `src/parcel-pricing.ts`, and `test/parcel-pricing.test.ts`; + the test runs with Vitest. +- The 2 kg, zone B, no-options behaviour failed before its implementation existed. +- The same test passes after the smallest implementation change. +- `npm run dev` returns `200 {"status":"ok"}` from `GET /health`. +- You recorded the `npm test` command and the visible failing and passing results. diff --git a/courses/testing-fundamentals/modules/01-name-the-behaviour.md b/courses/testing-fundamentals/modules/01-name-the-behaviour.md new file mode 100644 index 0000000..b77a6d7 --- /dev/null +++ b/courses/testing-fundamentals/modules/01-name-the-behaviour.md @@ -0,0 +1,105 @@ +--- +id: name-the-behaviour +title: Name what a test proves +goal: Turn a passing example into focused arrange–act–assert tests that each describe one parcel-pricing behaviour. +action: Add focused cases in `test/parcel-pricing.test.ts` for ordinary pricing and one option, with test names that state the behaviour and output that shows the cases separately. +sources: + - ./01-name-the-behaviour.md + - ../notes/testing-principles.md +questions: + - id: behaviour-aaa-diagnostic + kind: diagnostic + prompt: In a parcel-pricing test, what belongs in arrange, act, and assert, and why does that shape help a future reader? + reference: ../notes/testing-principles.md + rubric: + - Places the parcel input and any dependency setup in arrange, one pricing call in act, and observable price or failure checks in assert. + - Explains that the shape makes the behaviour and the reason for the assertions readable. + - id: behaviour-aaa-exit + kind: exit + prompt: Choose one of your tests and state the single behaviour it proves. What would be a different test rather than another assertion in this one? + reference: ./01-name-the-behaviour.md + rubric: + - States one specific parcel-pricing behaviour and connects it to the test's input and outcome. + - Gives a plausible separate behaviour, such as an option surcharge or an invalid zone, instead of splitting an inseparable outcome mechanically. +--- + +# 01 · Name what a test proves + +## Outcome + +You can read a test title and its arrange–act–assert sections and say exactly +what behaviour the test proves. + +## Keep the scenario concrete + +> [!SCENARIO] +> The 2 kg zone B parcel costs €10 without options. The same parcel with +> `express` costs €16. Those are related examples, but they answer different +> questions: the base-and-weight rule, and whether the express option changes +> the outcome correctly. + +One behaviour per test is a naming tool, not a ban on useful assertions. Keep +assertions together when they explain one outcome; split the test when a +failure would leave a reader unsure which behaviour broke. + +## Worked example 1 — make one decision, then fade it + +Here is a complete decision for the express option. Import the `Parcel` type +alongside `priceParcel`; annotating the variable keeps TypeScript from widening +`zone: "B"` to `string`: + +```ts +import { priceParcel, type Parcel } from "../src/parcel-pricing"; + +it("adds the express surcharge to a 2 kg zone B parcel", () => { + // arrange + const parcel: Parcel = { weightKg: 2, zone: "B", options: ["express"] }; + + // act + const result = priceParcel(parcel); + + // assert + expect(result.total).toBe(16); +}); +``` + +Your module 00 implementation ignores `options`, so this test fails first with +a visible mismatch: expected 16, received 10. That is the red step working for +you. Extend `priceParcel` with the shared scenario's option surcharges — €6 for +`express`, €3 for `fragile` — and run the suite again. + +Pause before reading on. Write the next focused test yourself for the same +parcel with the `fragile` option. What should its total be, and which part of +the test should change? + +The answer is €13: zone B €8, the 2 kg surcharge €2, and fragile €3. Keep the +arrange–act–assert shape, change the option and expected outcome, and give the +test a name about fragile pricing. The test should not also decide whether the +HTTP endpoint parses JSON; that is a different boundary and a later module. + +## Build + +1. Rename the first test so its title states the behaviour rather than the + function name alone. Keep it in `test/parcel-pricing.test.ts`. +2. Add focused tests for an ordinary zone-and-weight price and one option in + that file. Use names such as `prices a 2 kg zone B parcel at €10` and + `adds the express surcharge to a 2 kg zone B parcel`. Keep the input visible + in arrange, one pricing call in act, and the price or deliberate failure in + assert. +3. Run `npm test`. The visible output must list each case name separately and + end with `Test Files 1 passed` and a `Tests` count matching your cases — + two at minimum, three if you kept the fragile exercise. When a new option + case fails against the module 00 implementation, that is the expected red + step: extend the pricing behaviour deliberately. Never weaken an assertion + just to get green. +4. Ask yourself whether each test could fail for a different reason. If the + answer is yes, split it and make the new behaviour explicit. + +## Definition of done + +- Tests use a visible arrange–act–assert shape for parcel-pricing decisions. +- The focused cases live in `test/parcel-pricing.test.ts` and their names are + visible in `npm test` output. +- Each test name states one behaviour a learner can explain in one sentence. +- At least one base price and one option price have separate observable cases. +- `npm test` passes and its output makes the focused cases visible. diff --git a/courses/testing-fundamentals/modules/02-choose-a-boundary.md b/courses/testing-fundamentals/modules/02-choose-a-boundary.md new file mode 100644 index 0000000..3555c34 --- /dev/null +++ b/courses/testing-fundamentals/modules/02-choose-a-boundary.md @@ -0,0 +1,181 @@ +--- +id: choose-a-boundary +title: Choose a useful test boundary +goal: Keep pure pricing decisions fast and use one HTTP integration test to prove the transport translation. +action: Keep the direct pricing test in `test/parcel-pricing.test.ts` and add one real HTTP test in `test/http-price.test.ts` for `POST /price`, recording the distinction in the test output. +sources: + - ./02-choose-a-boundary.md + - ../notes/testing-principles.md +questions: + - id: boundary-diagnostic + kind: diagnostic + prompt: Which part of parcel pricing should be tested without starting a server, and which part is worth crossing the HTTP boundary to test? + reference: ../notes/testing-principles.md + rubric: + - Places the pure price decision in a fast isolated test and the JSON/request/response translation at the HTTP boundary. + - Explains why a boundary test can catch a translation mistake that a direct function test cannot. + - id: boundary-exit + kind: exit + prompt: Trace your valid POST /price test from request to response. What does it prove, and what important behaviour does it intentionally leave to the unit tests? + reference: ./02-choose-a-boundary.md + rubric: + - Names the request shape, HTTP edge, and observable response that the integration test exercises. + - Separates transport translation from the full set of pricing rules and names a behaviour left to direct tests. +--- + +# 02 · Choose a useful test boundary + +## Outcome + +You can choose a boundary because it protects a behaviour, not because a test +taxonomy says every file needs its own test. + +## The parcel edge + +> [!SCENARIO] +> A direct pricing test can prove that 2 kg in zone B costs €10. It cannot +> prove that `POST /price` reads `weightKg` from JSON, passes the right value to +> the pricing decision, and returns the result in the response. One HTTP test +> can prove that translation without turning every rule into a slow end-to-end +> test. + +Use the smallest useful split: + +- A unit test calls the pricing decision directly with an ordinary parcel, + option, or invalid input. +- Add the adapter at `src/server.ts`, keep `priceParcel` in + `src/parcel-pricing.ts`, and change `package.json` so `npm run dev` starts + `src/server.ts`. +- An integration test in `test/http-price.test.ts` starts the real adapter on + an ephemeral port and checks the response status and body. For the shared + case, send `POST /price` with `{"weightKg":2,"zone":"B","options":[]}` and + expect `200` with a JSON body whose `total` is `10`. +- Do not mock the pricing function inside the test that claims to prove the + `/price` behaviour. That would prove only that the handler called a mock. + +## A minimal adapter you can test for real + +The HTTP plumbing is not this module's learning goal, so here is a complete +adapter. Move the server out of `src/parcel-pricing.ts` (delete `startServer` +and its imports there) and create `src/server.ts`: + +```ts +import { createServer, type Server } from "node:http"; +import { fileURLToPath } from "node:url"; +import { priceParcel, type Parcel } from "./parcel-pricing"; + +export function createPricingServer(): Server { + return createServer((request, response) => { + const respond = (status: number, payload: unknown) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); + }; + + if (request.method === "GET" && request.url === "/health") { + respond(200, { status: "ok" }); + return; + } + + if (request.method === "POST" && request.url === "/price") { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + let parcel: Parcel; + try { + parcel = JSON.parse(body) as Parcel; + } catch { + respond(400, { error: "malformed JSON" }); + return; + } + try { + respond(200, priceParcel(parcel)); + } catch (error) { + respond(422, { error: error instanceof Error ? error.message : "invalid parcel" }); + } + }); + return; + } + + respond(404, { error: "not found" }); + }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + createPricingServer().listen(3000, "127.0.0.1", () => { + console.log("Parcel-pricing server listening at http://127.0.0.1:3000"); + }); +} +``` + +Update the `dev` script to `"tsx src/server.ts"` so `npm run dev` and the +`/health` check from module 00 keep working. The 400 branch stops malformed +transport input at the boundary now. The 422 branch stays dormant until +module 04 adds domain validation to `priceParcel` — that is a recorded promise, +not a tested behaviour yet. + +The integration test starts the same adapter on port 0 (an ephemeral port) and +crosses the boundary with a real request. Create `test/http-price.test.ts`: + +```ts +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { AddressInfo } from "node:net"; +import { createPricingServer } from "../src/server"; + +describe("POST /price", () => { + const server = createPricingServer(); + let baseUrl = ""; + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }); + + it("translates a valid POST /price request into a €10 response", async () => { + const response = await fetch(`${baseUrl}/price`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ weightKg: 2, zone: "B", options: [] }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ total: 10 }); + }); +}); +``` + +## Build + +1. Keep one direct test for the shared parcel-pricing rule in + `test/parcel-pricing.test.ts`. +2. Add `src/server.ts` and the valid `POST /price` test in + `test/http-price.test.ts` as shown above, and point `npm run dev` at the new + adapter. The test must exercise the real adapter, not a mocked pricing + function. +3. Run `npm test`. The case name + `translates a valid POST /price request into a €10 response` must be visible + alongside `Test Files 2 passed` and a passing `Tests` summary. +4. Write down one transport failure you will test in module 04, such as + malformed JSON returning a 400 response, and one domain failure such as an + invalid weight returning a distinct 422 response. Keep those as separate + promises. + +## What this is NOT + +It is not “unit test every line and mock everything else.” A unit test is +useful when it isolates a decision; a boundary test is useful when it protects +the translation at that boundary. Neither is a score for how many test files +you created. + +## Definition of done + +- A direct pricing test and an HTTP-edge test both run with the same test command. +- `test/http-price.test.ts` sends the documented JSON through `POST /price` and + checks `200` plus a response `total` of `10`. +- The HTTP test does not replace the pricing decision with a mock. +- `npm run dev` now starts `src/server.ts` and the `/health` check from + module 00 still returns `{"status":"ok"}`. +- Your answer names one transport failure and one domain failure that deserve distinct tests. diff --git a/courses/testing-fundamentals/modules/03-use-doubles-honestly.md b/courses/testing-fundamentals/modules/03-use-doubles-honestly.md new file mode 100644 index 0000000..516acfa --- /dev/null +++ b/courses/testing-fundamentals/modules/03-use-doubles-honestly.md @@ -0,0 +1,139 @@ +--- +id: use-doubles-honestly +title: Use test doubles honestly +goal: Choose a small fake for replaceable data and recognise when a mock or spy can make a test pass without proving the outcome. +action: Put one deterministic rate fake at `test/fixtures/zone-rates.ts`, use it at a real pricing seam, and assert the resulting price or failure in `test/parcel-pricing.test.ts`. +sources: + - ./03-use-doubles-honestly.md + - ../notes/testing-principles.md +questions: + - id: doubles-diagnostic + kind: diagnostic + prompt: What is the difference between a fake, a mock, and a spy in the parcel-pricing scenario, and which one would you start with for zone rates? + reference: ../notes/testing-principles.md + rubric: + - Distinguishes a working substitute, scripted or interaction-checked substitute, and call-recording wrapper. + - Chooses an in-memory fake for zone rates and explains that it keeps the price outcome meaningful. + - id: doubles-exit + kind: exit + prompt: Show how your test double lets you make a pricing decision, then explain one way a spy or mock assertion could pass while the customer-facing price is wrong. + reference: ./03-use-doubles-honestly.md + rubric: + - Describes a small fake or fixture with believable zone-rate behaviour and an asserted parcel outcome. + - Gives a concrete interaction-only failure, such as verifying a lookup call while never checking the returned total. + - id: doubles-review + kind: review + prompt: A suite stays green while customers see wrong totals. Its pricing test asserts only that the rate lookup was called once with "B". Which test-double mistake is this, and what assertion is missing? + reference: ../notes/testing-principles.md + rubric: + - Identifies interaction-only verification standing in for an outcome check. + - Names the missing assertion on the observable total the caller receives. +--- + +# 03 · Use test doubles honestly + +## Outcome + +You can substitute a dependency without pretending that an interaction proves +the parcel price. + +## A small seam, not a new architecture + +> [!SCENARIO] +> Imagine that zone base prices come from a rate lookup. For this course, an +> in-memory rate table is enough. It lets you test the pricing decision without +> a network, database, or “mock everything” setup. The service should still +> return the right total for the parcel. + +If your current implementation has the zone rates inline, you do not need to +invent an external service. Open a small seam instead: accept a rate source as +a parameter with the production table as its default, so every existing test +keeps passing unchanged. In `src/parcel-pricing.ts`: + +```ts +export type RateSource = { getBase(zone: Parcel["zone"]): number }; + +export const defaultRates: RateSource = { + getBase(zone) { + return { A: 5, B: 8, C: 12 }[zone]; + }, +}; + +const optionPrices: Record = { express: 6, fragile: 3 }; + +export function priceParcel(parcel: Parcel, rates: RateSource = defaultRates): ParcelPrice { + const weightSurcharge = parcel.weightKg > 1 ? 2 : 0; + const optionSurcharge = parcel.options.reduce( + (sum, option) => sum + (optionPrices[option] ?? 0), + 0, + ); + + return { total: rates.getBase(parcel.zone) + weightSurcharge + optionSurcharge }; +} +``` + +Your module 01 surcharge code may look different; keep it. The only required +change is the `rates` parameter and reading the base through +`rates.getBase(parcel.zone)`. + +Give the fake deliberately different numbers from production. If the fake +returned the same €8 for zone B, a green test could not tell you whether the +substitute was actually used. In `test/fixtures/zone-rates.ts`: + +```ts +import type { RateSource } from "../../src/parcel-pricing"; + +export const zoneRateFake: RateSource = { + getBase(zone) { + return { A: 1, B: 2, C: 3 }[zone]; + }, +}; +``` + +The important assertion is still the parcel outcome. A 2 kg zone B parcel +priced through the fake costs €4: fake base €2 plus the €2 weight surcharge. +That single number proves the zone flowed through the seam and the surcharge +logic ran. A test that only says `getBase` was called with `"B"` can pass even +if the result is ignored or a wrong surcharge is added. + +```ts +import { zoneRateFake } from "./fixtures/zone-rates"; + +it("prices zone B through the in-memory rate fake", () => { + const result = priceParcel({ weightKg: 2, zone: "B", options: [] }, zoneRateFake); + + expect(result.total).toBe(4); +}); +``` + +## Build + +1. Identify one replaceable input in `src/parcel-pricing.ts`: a zone-rate + lookup, a clock, or another small dependency that genuinely exists. +2. Create the deterministic in-memory fake in + `test/fixtures/zone-rates.ts`. Keep its behaviour obvious and its numbers + deliberately different from production; do not reproduce the whole + production implementation. +3. Write or revise one test in `test/parcel-pricing.test.ts` so it checks the + resulting parcel price or the deliberate failure, not only the fake's call + history. Name it, for example, `prices zone B through the in-memory rate fake`, + and make sure you can explain its expected total from the fake's numbers. +4. Run `npm test` and inspect that case name plus the passing `Test Files` and + `Tests` summaries. If you keep a spy, say what contract the interaction + itself protects; otherwise remove it. + +## What this is NOT + +It is not a test where every collaborator is a mock and every assertion checks +call count. That style can report green while the price a caller receives is +wrong. A fake is not automatically better either: use it because it isolates a +real boundary while preserving a meaningful outcome. + +## Definition of done + +- One test uses a deterministic fake or fixture at a real seam in the project. +- The fake is at `test/fixtures/zone-rates.ts` and the outcome assertion is in + `test/parcel-pricing.test.ts`. +- The fake is smaller than the production dependency and has believable behaviour. +- The test asserts a parcel price or deliberate failure, not only an interaction. +- `npm test` passes and your explanation names when a spy would be misleading. diff --git a/courses/testing-fundamentals/modules/04-drive-the-edges.md b/courses/testing-fundamentals/modules/04-drive-the-edges.md new file mode 100644 index 0000000..8db27f9 --- /dev/null +++ b/courses/testing-fundamentals/modules/04-drive-the-edges.md @@ -0,0 +1,113 @@ +--- +id: drive-the-edges +title: Drive the edges deliberately +goal: Use table-driven cases to make pricing boundaries, options, and failure paths visible. +action: Add a table to `test/parcel-pricing.test.ts` covering both sides of at least two pricing boundaries plus deliberate invalid-input cases, extend `priceParcel` until they pass, add the promised 400 and 422 cases to `test/http-price.test.ts`, then run the suite and inspect the named cases. +sources: + - ./04-drive-the-edges.md + - ../notes/testing-principles.md +questions: + - id: edges-diagnostic + kind: diagnostic + prompt: Which values around the 1 kg, 5 kg, and 10 kg thresholds are more useful than a single typical parcel, and why? + reference: ../notes/testing-principles.md + rubric: + - Names values on both sides of at least one threshold, including the threshold itself where the rule includes it. + - Explains that adjacent values can reveal an incorrect comparison or band assignment. + - id: edges-exit + kind: exit + prompt: Choose one table case and one failure case. What rule does each protect, and what would a vague “it throws” assertion fail to tell you? + reference: ./04-drive-the-edges.md + rubric: + - Connects the table case to a specific pricing boundary, option, or expected total. + - Names the invalid input and the promised error distinction rather than only saying that an exception occurred. +--- + +# 04 · Drive the edges deliberately + +## Outcome + +You can choose cases because they protect a rule, not because a test generator +produced a large number of examples. + +## Make the boundaries visible + +> [!SCENARIO] +> The 1 kg band includes 1 kg, but 1.01 kg moves to the next surcharge. The +> same question exists at 5 kg and 10 kg. A table keeps those decisions in one +> readable place and gives each case a useful name. + +Start with a small table like this, then add the invalid inputs that matter to +your service: + +| Case name | Weight | Zone | Options | Expected | +| --- | ---: | :---: | :--- | ---: | +| `one-kg-is-in-first-band` | 1.00 | A | none | €5 | +| `just-over-one-kg-adds-surcharge` | 1.01 | A | none | €7 | +| `five-kg-stays-in-second-band` | 5.00 | B | none | €10 | +| `just-over-five-kg-uses-third-band` | 5.01 | B | none | €13 | +| `ten-kg-stays-in-third-band` | 10.00 | C | none | €17 | +| `just-over-ten-kg-uses-fourth-band` | 10.01 | C | none | €21 | + +Add at least one case for `express` or `fragile`, an invalid weight, and an +unknown zone or option. For failures, assert the stable distinction your +caller needs: an error category, status, or documented message fragment. Do +not make the test depend on a private stack trace. + +In Vitest, `it.each` turns the table into named cases. Typing the rows as +`Parcel` plus expectations keeps the zone literals narrow: + +```ts +import { priceParcel, type Parcel } from "../src/parcel-pricing"; + +type PricingCase = Parcel & { name: string; total: number }; + +const boundaryCases: PricingCase[] = [ + { name: "one-kg-is-in-first-band", weightKg: 1, zone: "A", options: [], total: 5 }, + { name: "just-over-one-kg-adds-surcharge", weightKg: 1.01, zone: "A", options: [], total: 7 }, + // ...the remaining rows from the table above +]; + +it.each(boundaryCases)("$name", ({ name, total, ...parcel }) => { + expect(priceParcel(parcel).total).toBe(total); +}); +``` + +Expect red before green: your implementation so far has only the single €2 +surcharge from module 00, so the 5.01 kg and 10.01 kg rows must fail first. +That failure is the instruction to extend `priceParcel` to the full shared +scenario — weight bands at 1, 5, 10, and 20 kg, and rejection of invalid +weights, zones, and options. + +## Build + +1. Put related pricing examples in a Vitest table or equivalent data-driven + structure in `test/parcel-pricing.test.ts`. Give each row a case name that + will appear in test output, including the six names shown above. +2. Include values at and immediately beside at least two thresholds. Let the + failing rows drive the band table into `src/parcel-pricing.ts`; do not edit + the expectations to match the old behaviour. +3. Add deliberate failure cases for invalid input, for example + `expect(() => priceParcel({ weightKg: 0, zone: "B", options: [] })).toThrowError(/weight/)` + if you chose a thrown error with a documented message. These cases exercise + the pricing rule itself. +4. Now keep module 02's promise at the HTTP edge in `test/http-price.test.ts`: + malformed JSON must return `400`, and a well-formed request whose parcel + breaks a pricing rule must return the distinct `422` from the adapter. +5. Run `npm test`. Read the six boundary case names, the option case, and the + invalid-input cases—not only the final count. The output must end with + passing `Test Files` and `Tests` summaries. If a boundary fails, decide + whether the rule or the test expectation is wrong before changing either. + +## Definition of done + +- A readable table covers both sides of at least two pricing thresholds. +- The table is in `test/parcel-pricing.test.ts` and its important case names + appear in `npm test` output. +- `src/parcel-pricing.ts` now implements the full weight bands and rejects + invalid weights, zones, and options, driven there by failing cases. +- Options have a focused case and invalid input has a deliberate failure case. +- `test/http-price.test.ts` proves the 400 malformed-JSON and 422 invalid-parcel + responses promised in module 02. +- Failure assertions identify a useful distinction beyond “something threw”. +- `npm test` passes and the output exposes the names of the important cases. diff --git a/courses/testing-fundamentals/modules/05-refactor-under-green.md b/courses/testing-fundamentals/modules/05-refactor-under-green.md new file mode 100644 index 0000000..3ae4df7 --- /dev/null +++ b/courses/testing-fundamentals/modules/05-refactor-under-green.md @@ -0,0 +1,98 @@ +--- +id: refactor-under-green +title: Refactor under green +goal: Change internal structure while a behaviour-focused suite protects the parcel-pricing contract. +action: Refactor one internal helper in `src/parcel-pricing.ts` while `test/parcel-pricing.test.ts` stays green before and after, then replace one implementation-detail assertion with an observable outcome assertion. +sources: + - ./05-refactor-under-green.md + - ../notes/testing-principles.md +questions: + - id: refactor-diagnostic + kind: diagnostic + prompt: What makes a test a safety net during a refactor, and what kind of assertion makes that net brittle? + reference: ../notes/testing-principles.md + rubric: + - Explains that a passing behaviour test gives evidence that an observable contract stayed intact across an internal change. + - Distinguishes a brittle assertion about private calls, order, or structure from an assertion about a parcel outcome. + - id: refactor-exit + kind: exit + prompt: What did you change internally, what stayed observable, and which test result gives you confidence without claiming that every possible implementation is safe? + reference: ./05-refactor-under-green.md + rubric: + - Names one internal refactor and one unchanged parcel-pricing or HTTP behaviour. + - Uses passing test evidence while stating a limit, such as untested inputs, dependencies, or production conditions. +--- + +# 05 · Refactor under green + +## Outcome + +You can change names, helpers, or internal structure while preserving the +behaviour a caller relies on. + +## Protect the outcome, not the shape + +> [!SCENARIO] +> You can replace nested weight `if` statements with a small band table, or +> extract a `weightSurcharge` helper. If the parcel totals and deliberate +> failures stay the same, the refactor should not require a new customer-facing +> contract. + +## Worked example 2 — fade from an interaction to an outcome + +Here is the brittle decision a test author might make with the module 03 seam: + +```ts +import { vi } from "vitest"; +import { zoneRateFake } from "./fixtures/zone-rates"; + +const getBase = vi.spyOn(zoneRateFake, "getBase"); +const result = priceParcel({ weightKg: 2, zone: "B", options: [] }, zoneRateFake); + +expect(getBase).toHaveBeenCalledWith("B"); +expect(getBase).toHaveBeenCalledTimes(1); +``` + +Your turn: rewrite the meaningful part of this test so it proves what the +caller receives for a 2 kg zone B parcel. Keep an interaction assertion only if +the lookup call itself is a documented contract. + +The stronger default is an outcome: `expect(result.total).toBe(4)` through the +module 03 fake, or `toBe(10)` through the production rates, with the parcel +input visible in arrange. The implementation may later cache, batch, or replace +the lookup without changing the price contract, and the call-count assertion +above would break on a harmless memoisation. A call-order or call-count +assertion is useful only when that interaction is observable and required, not +because the spy makes it easy to inspect. + +## Build + +1. Run `npm test` and note the green baseline for `test/parcel-pricing.test.ts` + before editing internals. The output must show the existing parcel case + names and passing `Test Files` and `Tests` summaries. +2. Make one refactor in `src/parcel-pricing.ts` that should not change the + parcel-pricing contract: name a helper, extract a weight-band table, or + simplify a branch. +3. In `test/parcel-pricing.test.ts`, replace one assertion about a private call, + order, or helper with an observable price, response, or deliberate failure + assertion. +4. Run `npm test` again. The same named behaviour must pass. If it fails, use + the failure to decide whether you + found a real behaviour change or a test coupled to the old implementation. +5. Record the before-and-after commands and the refactor in your evidence. + +## What this is NOT + +It is not “green means production-ready,” and it is not “never assert an +interaction.” Green means only that the exercised contracts held for the cases +you wrote. Assert a call when the call is itself the contract; otherwise prefer +the outcome a parcel-pricing caller can observe. + +## Definition of done + +- The suite was green before and after one internal refactor. +- `src/parcel-pricing.ts` and `test/parcel-pricing.test.ts` show the changed + implementation and the outcome-focused protection. +- At least one brittle implementation-detail assertion became a behaviour assertion. +- The changed code still covers a parcel total or deliberate failure that a caller can observe. +- Your evidence states what the green suite does not prove. diff --git a/courses/testing-fundamentals/modules/06-read-confidence-honestly.md b/courses/testing-fundamentals/modules/06-read-confidence-honestly.md new file mode 100644 index 0000000..661bd31 --- /dev/null +++ b/courses/testing-fundamentals/modules/06-read-confidence-honestly.md @@ -0,0 +1,88 @@ +--- +id: read-confidence-honestly +title: Read confidence honestly +goal: Finish with a test and evidence report that separates executed coverage from confidence about untested behaviour. +action: Run `npm test` and `npm test -- --coverage`, then write `TEST-EVIDENCE.md` in the project root listing tested behaviours, observed output, and limits. +sources: + - ./06-read-confidence-honestly.md + - ../notes/testing-principles.md +questions: + - id: confidence-diagnostic + kind: diagnostic + prompt: What can a coverage percentage tell you about the parcel-pricing suite, and what can it not tell you? + reference: ../notes/testing-principles.md + rubric: + - States that coverage reports which code was executed by the selected tests. + - Names a missing guarantee, such as correct assertions, untested boundaries, HTTP translation, dependencies, or production traffic. + - id: confidence-exit + kind: exit + prompt: "Give your final evidence list: commands and outcomes, important parcel cases, one HTTP observation, and two things your suite does not prove." + reference: ./06-read-confidence-honestly.md + rubric: + - Names learner-run commands with observed pass or coverage output and identifies important behaviour cases. + - Includes an HTTP observation and two specific limits instead of treating coverage or a green suite as production proof. +--- + +# 06 · Read confidence honestly + +## Outcome + +You can hand another developer a compact record of what you tested, what you +saw, and what remains outside the claim. + +## Coverage is a map, not a verdict + +> [!SCENARIO] +> A high line percentage might mean that a happy-path test executed every +> branch while never checking whether the HTTP adapter maps an invalid parcel +> correctly. A lower percentage can still accompany a thoughtful suite if the +> important boundaries and outcomes are covered. Read the report alongside +> the cases, not instead of them. + +```learndeck +type: textarea +id: confidence-limits +label: Two things my suite does not prove +placeholder: e.g. concurrent requests, real rate data, behaviour beyond 20 kg +``` + +## Build + +1. Run `npm test` and record the command plus its passing `Test Files` and + `Tests` summaries. +2. Run `npm test -- --coverage`. Module 00 installed + `@vitest/coverage-v8`, so this command should produce a `coverage/` report + and a coverage table. If you intentionally used a different provider, + record its command and observed result; never invent a percentage. +3. Read the report for the pricing decision and HTTP edge. Note which important + cases executed and which behaviours remain untested. +4. In the project root, write `TEST-EVIDENCE.md` with the commands, the exact + observed pass/coverage summaries, the boundary and edge cases covered, one + health or `POST /price` observation, and at least two limits on the + confidence claim. +5. If a guide is connected, you may ask it to review the evidence, not to + certify the project; guide evaluation is optional. If no guide is connected, + review `TEST-EVIDENCE.md` yourself, answer the question, and select **Mark as + self-reviewed and continue** in the app. Your final record should be + explainable from the repository and your observations. + +## Final evidence + +Finish with four concrete items, recorded in `TEST-EVIDENCE.md`: + +- a small parcel-pricing repository you can explain; +- a passing Vitest command and the important behaviours it exercises; +- one observed HTTP-edge result, including the status route or price request; +- `TEST-EVIDENCE.md` with an honest coverage reading and explicit limits. + +This is **not production readiness**. The course does not prove scale, +security, resilience, financial correctness, or every possible parcel input. +It proves that you built and inspected a bounded suite with enough evidence to +discuss its strengths and gaps. + +## Definition of done + +- `npm test` passes and its command and output are recorded. +- Coverage is reported when configured, or its absence is recorded without a made-up number. +- `TEST-EVIDENCE.md` lists important pricing, failure, and HTTP observations. +- The final report names at least two behaviours or conditions the suite does not prove. diff --git a/courses/testing-fundamentals/notes/testing-principles.md b/courses/testing-fundamentals/notes/testing-principles.md new file mode 100644 index 0000000..1c30193 --- /dev/null +++ b/courses/testing-fundamentals/notes/testing-principles.md @@ -0,0 +1,83 @@ +# Testing Fundamentals: shared scenario and source notes + +This file is a compact source for the course's parcel-pricing decisions. The +module that names a claim should still explain it in context; this note keeps +the shared scenario stable across the pack. + +## Parcel-pricing behaviour + +The service prices one parcel from three inputs: + +- `weightKg`, a positive number no greater than 20; +- `zone`, one of `A`, `B`, or `C`; and +- zero or more options, currently `express` and `fragile`. + +The base prices are €5 for zone A, €8 for zone B, and €12 for zone C. Add a +weight surcharge of €0 for weight up to and including 1 kg, €2 for weight over +1 kg up to and including 5 kg, €5 for weight over 5 kg up to and including +10 kg, and €9 for weight over 10 kg up to and including 20 kg. Add €6 for +`express` and €3 for `fragile`. The result is an integer euro total and should +also make the chosen inputs visible enough for a caller to explain the price. + +Reject a weight that is zero, negative, not numeric, or greater than 20. Reject +an unknown zone and an unknown option. The service should represent these as a +deliberate domain failure; the exact error type or message is the learner's +choice if the observable distinction stays clear. + +The first useful examples are: + +| Weight | Zone | Options | Expected total | +| ---: | :---: | :--- | ---: | +| 1 kg | A | none | €5 | +| 2 kg | B | none | €10 | +| 2 kg | B | express | €16 | +| 2 kg | B | fragile | €13 | +| 10 kg | C | express, fragile | €26 | + +The HTTP edge should accept a JSON `POST /price` request and return a success +response containing the price for valid input. Malformed transport input should +stop at the HTTP boundary with a 400-level response. A well-formed request +whose parcel violates a pricing rule should receive a different deliberate +4xx response. A separate `GET /health` or equivalent status route may prove +that the process is reachable; it cannot prove that pricing is correct. + +## Testing decisions + +Arrange–act–assert gives a test a readable shape: establish the inputs and +dependencies, perform one meaningful operation, then check the observable +outcome. “One behaviour per test” does not mean one assertion per test. It +means that a reader can name the behaviour and understand why the assertions +belong together. + +A unit boundary is useful when it keeps a small decision fast and isolated. An +integration test is useful when it crosses a boundary whose translation could +be wrong, such as JSON input becoming a pricing request or a domain failure +becoming an HTTP response. Tests should not mock away the code whose behaviour +they claim to prove. + +Test doubles differ in what they claim. A fake is a small working substitute +with believable behaviour, such as an in-memory rate table. A stub returns +pre-arranged answers so a test can reach the path under study; it makes no +claim about how it was called. A spy additionally records the calls it +receives so a test can inspect them afterwards. A mock is set up in advance +with expected interactions and the test fails verification when those +expectations are not met. Interaction assertions from spies and mocks are only +meaningful when the interaction is itself the contract; a mock-heavy test can +pass while the outcome a caller receives is wrong. Otherwise, assert the +observable result. Giving a fake deliberately different data from production +makes the substitution itself observable in the asserted outcome. + +Table-driven cases make a family of related inputs visible. Boundary cases +should include the values on both sides of a rule, not only a typical middle +value. Failure-path tests should name the invalid input and the promised +distinction, rather than merely asserting that “something threw”. + +Coverage is a map of executed code, not a certificate of correct behaviour. +It reports which statements, branches, functions, and lines the selected tests +executed; line coverage can be complete while an untaken branch hides a wrong +comparison, and executed code proves nothing if the assertions are weak. High +coverage can coexist with missing boundaries, an untested HTTP translation, or +a suite that exercises only happy paths. The final evidence should state the +command, the result, the important cases, and the limits of the confidence +claim. + diff --git a/references/learning-protocol.md b/references/learning-protocol.md index 91c4761..0f87b1e 100644 --- a/references/learning-protocol.md +++ b/references/learning-protocol.md @@ -16,6 +16,9 @@ claim that one method, interval, or score works identically for every learner. evidence before seeing the evaluation. 5. **Evaluate and revise.** The agent compares the answer with a named source, identifies one exact gap, and asks for a separate revision when needed. + Without a connected guide, the learner can instead mark a submitted answer + as self-reviewed and continue; the record stays honest by keeping + self-reviewed work distinct from evaluated work. 6. **Revisit a related distinction.** Store a review question in the per-workspace database. On a later session, retrieve before rereading. diff --git a/references/progress-database.md b/references/progress-database.md index 0743865..6b632c3 100644 --- a/references/progress-database.md +++ b/references/progress-database.md @@ -6,6 +6,10 @@ LearnDeck keeps a private local SQLite database at: /.learndeck/progress.db ``` +The packaged macOS app uses +`~/Library/Application Support/LearnDeck/progress.db` instead, so rebuilding +the app never touches progress. + The runner uses a single database with one learning record per confirmed workspace. This lets the browser UI and MCP agent see the same progress while keeping attempts from separate backend projects apart. Override the path with @@ -20,13 +24,18 @@ record through its tools. | Record | Why it exists | | --- | --- | | Learning record | Course runtime, project workspace, label, and update time. | -| Section progress | Active/revision/complete state, reported evidence, and review prompt. | -| Question attempt | Exact submitted answer, confidence, source, agent feedback, and result. | +| Section progress | Section state — `not_started`, `active`, `revision`, `self_reviewed`, or `complete` — with the latest evidence and review prompt. | +| Question attempt | Exact submitted answer, confidence, source, feedback, and result (`submitted`, `correct`, `partial`, `incorrect`, or `self_reviewed`). | +| Evidence | Every learner- or guide-reported note, with its optional file or command reference and who recorded it. | | Activity log | Learner, agent, or system action for a local audit trail. | Each answer is a new attempt. A revision never overwrites a partial or -incorrect answer. The database helps a later agent resume honestly; it does not -semantically grade prose by itself. +incorrect answer, and evidence records are additive. The database helps a +later agent resume honestly; it does not semantically grade prose by itself. + +Per-path progress can be exported as JSON with `GET /api/paths/:id/export` or +reset with `DELETE /api/paths/:id`; the browser exposes both as Export +progress and Reset path. ## Inspect locally